0%

Caching and Conditional Requests · capstone

Capstone project: Project: A Cache-Aware Reading List

Both halves of caching are in place. Your server tags every read, says max-age=60, and answers a tag it recognizes with a bodiless 304. Your client reuses a fresh copy without asking, revalidates a stale one with If-None-Match, and restarts its timer from the 304. This project closes the one gap left, which is about privacy rather than speed, and then proves that the two halves agree.

Requirements

SERVICE_CONTRACT.md already says all of it, in its “Caching” section and in “The client in wire.py”:

  • Every 200 to GET or HEAD on /books (with or without ?limit=) and /books/{id} carries etag and cache-control: max-age=60, the same on HEAD as on GET.

  • A matching if-none-match gets 304 with the same etag and cache-control, no body, and no content-length.

  • Every reply for /login, /logout and /me carries cache-control: no-store.

  • cached_get reuses a stored copy while now is less than its expiry, revalidates a stale one, restarts the timer from a 304, and replaces the entry on a 200.

  • A reply marked no-store is never stored. A reply marked no-cache is stored and revalidated every time.

New are no-store on three routes and the last line; the rest is Lessons 3 to 5, checked again.

Not everything should be kept

GET /me answers {"user": "ana"} or {"user": null}: who is logged in on this client, right now. It changes with every login and logout, and it belongs to one person, so no copy of it should sit anywhere. Today your client keeps nothing from /me only because the reply has no tag: a side effect, not a decision. A later change that tagged every 200 would quietly make your client remember a login after the user logged out. On a shared cache it would be worse: the next user could be shown the previous user’s name.

So the server says it outright, with the directive from Lesson 1 that means “do not keep this”: cache-control: no-store, on every reply for /login, /logout and /me, the refusals included. And the client obeys it, whatever else the reply carries.

The client learns no-cache at the same time. A server sends it for a reply that may be kept but must be checked before every use, for example a page that changes often and costs little to revalidate. In cache_rules that is max_age 0: the entry’s expiry is the moment it was stored, so the next call finds it stale and asks with its tag. no-cache beats any max-age in the same reply.

Milestones

First, cache_rules stage 2. A no-store directive sets "store" to False. A no-cache directive sets "max_age" to 0, wherever it appears among the directives, before or after a max-age. Directive names still ignore case. Then make sure cached_get stores a reply only when cache_rules says "store": True, as well as being a 200 with one etag.

Then the server. In handle, after the route has produced its response, set cache-control to no-store on the reply when the path is /login, /logout or /me. The Chapter 7 scenario who shows the new line: run python pl_http.py who, and under each reply for /login, /me and /logout the helper prints cache-control: no-store.

Then watch the turning point. Run:

python pl_http.py cache

This is the chapter in three calls: at 0 a GET brings the body, at 30 nothing is sent at all, and at 61 a GET with the tag brings back a 304 with no body, so requests reads 1, 0 and 1. The last two calls are /me, and each sends a request:

--- your cached_get() at now 0 for /me ---
source: 'network'

Optional: test the cache yourself. fakes.py has scripted_response, and scripted_connections from Chapter 6’s project. Add scripted_connections to the from fakes import line of tests/test_wire.py, and a test such as:

def test_a_fresh_copy_needs_no_request():
    calls = []
    reply = scripted_response(200, "OK", [["etag", '"v1"'], ["cache-control", "max-age=60"]], b"[]")
    connections = scripted_connections([reply], calls)
    cache = {}
    wire.cached_get("http://books.test/books", cache, now=0, make_connection=connections)
    record = wire.cached_get("http://books.test/books", cache, now=59, make_connection=connections)
    assert record["source"] == "cache"
    assert len(calls) == 1

The second call makes no connection, so one scripted reply is enough. A test of no-store follows the same pattern: two calls, two connections. Then run every test from the project folder:

python -m pytest -q

Check my work runs your tests once, so they must all pass, and none of them may need a : use the stand-ins in fakes.py.

What is proven, and how

Check my work checks each half on its own with values you have not seen, then connects your cached_get to your own reading_server.handle in one process, as Chapter 6’s project did. Each connection your client opens builds the request http_adapter would build, runs your read_body and handle, and turns your response dict back into a reply. It also keeps each response dict, so a 304 that carried a body is caught. At 0, 30 and 61 your server must see two requests, the second with your tag and answered with a bodiless 304, and all three results must hold the same body. A PUT through your handle between two calls must make the next stale GET bring the new body, and /me must be asked every time and never stored. The cache run shows the same agreement over a real connection; it is for you to watch, not graded.

Then press Check my work.

Every exchange so far has opened a connection, sent one request, read one reply, and closed it. That is HTTP/1.0’s way, and your server still speaks it. Chapter 9 switches the server to HTTP/1.1, where one connection carries many requests, and teaches your client to tell where each reply ends.

Task

Finish the cache-aware reading : no-store and no-cache in wire.py, and no-store on three routes in reading_server.py.

  • cache_rules(values): a no-store directive sets "store" to False; a no-cache directive sets "max_age" to 0, whatever max-age the reply also carries. Directive names ignore case, and every line counts.

  • cached_get stores a reply only when it is a 200 with exactly one etag and cache_rules says "store": True. Everything from Lesson 5 stays.

  • In handle, every reply for /login, /logout and /me carries cache-control: no-store.

  • Your tests in tests/ must pass with python -m pytest -q, and none may import socket.

Run python pl_http.py cache and python pl_http.py who in the to watch both halves on a real connection.

Then press Check my work. It checks the server with a state you have not seen: tags on GET and HEAD, a tag that changes after a PUT, a bodiless 304 that repeats etag and cache-control, and no-store on POST /login, GET and HEAD /me, and POST /logout. It calls cache_rules with no-store, no-cache, max-age=60, max-age=60, no-store, private, max-age=5 and other values, and cached_get with scripted replies for freshness, the boundary at exactly max-age, the timer a 304 restarts, no-store and no-cache. Then it connects your cached_get to your own handle in one process: at 0, 30 and 61 two requests reach your server and the second is answered 304 with no body; a PUT between calls brings the new body; /me is asked every time and never stored. Last, it runs your tests once, stopping a run that takes longer than 3 seconds. Nothing from your terminal is graded.