0%

Caching and Conditional Requests · practice

A Client That Remembers

Your server now tags every read, says how long it may be reused, and answers a matching If-None-Match with a bodiless 304. This lesson gives wire.py a small cache: cached_get(url, cache, *, now, make_connection, timeout), a GET that remembers.

Deciding whether a stored reply is still fresh needs the current time, a problem you have solved before. In Python in Practice’s capstone, Chapter 13, Lesson 2, cache_is_stale took now_epoch as a instead of calling time.time(), so a test could hand it any moment it liked. cached_get does the same: its caller passes now as a number of seconds, and nothing in wire.py reads a clock.

Fresh, stale, gone

cache is a that the caller owns and passes in on every call, empty at first. cached_get keeps one entry per , under the URL:

cache[url] = {"etag": tag, "expires": now + max_age, "record": record}

record is the record request_once returned for the 200 (Chapter 6, Lesson 4): "status", "reason", "headers", "body" and "url". etag is the reply’s tag, kept for asking later. expires is the moment the copy goes stale: the time it was stored plus the reply’s max-age.

Each call is in one of three situations, and the record cached_get returns names it in a new key, "source":

  • Nothing stored: send a plain GET, store the reply if it can be stored, and return it with "source": "network".

  • Fresh: send nothing. Return a copy of the stored record with "source": "cache".

  • Stale: ask the server with If-None-Match and the stored tag. A 304 means the copy is still right: return it with "source": "revalidated". A 200 means it changed: store and return the new one, with "source": "network".

Against your server, whose replies carry max-age=60, three calls go like this:

At 0 the reply is fetched and stored, at 30 it comes from the cache, at 61 a 304 renews it.

At 0 the 200, with tag T and max-age=60, is stored, stale from 60. At 30 nothing is sent. At 61, If-None-Match: T gets a 304 with max-age=60, fresh until 121. Three calls, two requests, one body on the wire.

The boundary is HTTP’s

When exactly does a copy go stale? RFC 9111 (section 4.2) gives a formula: a response is fresh when its freshness lifetime is greater than its current age. Here the lifetime is max-age and the age is now minus the time it was stored. At the moment the age reaches max-age, “greater than” is false, so the copy is stale. With expires holding the store time plus max-age, the test is now < entry["expires"]: fresh before that moment, stale from that moment on.

That is not the line Python in Practice drew. Its file cache was a contract of its own, and it said that an age equal to the limit still counted as fresh, so it compared age > max_age. Both are reasonable choices for a cache you design yourself. Here the rules are ’s, because the server that sends max-age=60 means them in HTTP’s sense, so a copy stored at 0 is stale at exactly 60.

A reply stored at 100 carries max-age=60. What does this print?

stored_at = 100
max_age = 60
for now in [130, 160]:
    print(now < stored_at + max_age)

A reply stored at 100 has max-age=60. cached_get is called with now=160. What does it do?

Revalidate

A stale entry is not thrown away, because its tag can still save the download. cached_get sends GET with one header, {"If-None-Match": entry["etag"]}, through request_once, passing on the make_connection and timeout it was given, as follow did. Then it reads the reply’s cache-control with cache_rules.

On a 304, the stored record is the answer, and the 304 decides how long it stays fresh: the entry’s expires becomes now plus the 304’s own max-age. That is why your server repeats cache-control on a 304. A 304 without cache-control would give max_age 0, so this client simply asks again next time. A 304’s body is empty, so returning the 304’s own record would hand the caller nothing; return a copy of the stored record instead.

On a 200, the new version replaces the entry like any reply from the network.

A reply is stored only when it is a 200 with exactly one etag, so a stored entry always has a tag to ask with. A 404, or a 200 without a tag, is returned and not stored. That is a simplification this client makes: HTTP would also let a cache keep a 200 that has max-age and no tag, and reuse it while it is fresh.

Return copies: setting "source", or any other top-level key, on entry["record"].copy() leaves the stored record as it was. The copy is shallow, though: its nested headers dict is still shared with the cache.

At 160 a 304 arrives with cache-control: max-age=30. When does cached_get send its next request for that URL?

Real caches track more: Age, how long a reply already sat in another cache; Vary, for replies that differ by ; and a guessed freshness when there is no max-age. This cache lives in a dict for one run; the final project keeps one in a file.

Your task

In wire.py, write two .

cache_rules(values) takes the of cache-control values of a reply and returns {"store": True, "max_age": seconds}. Split each at commas, strip and lowercase each directive, and when one starts with max-age=, read the number after it. Only ASCII digits count, as in read_body: check isascii() and isdigit() before int(). Without a usable max-age, max_age is 0. store is always True for now; Lesson 6 adds the directives that change it.

cached_get(url, cache, *, now, make_connection=http.client.HTTPConnection, timeout=TIMEOUT) follows the three situations above, with no clock.

Then run:

python pl_http.py cache

The helper calls your cached_get against your own server at 0, 30 and 61 for /books, then twice for /me, and prints the source of each record and how many requests reached the server. At 30 you should see:

--- your cached_get() at now 30 for /books ---
source: 'cache'
status: 200

and further down, requests: 0 for that call. At 61 one request goes out with the tag:

request 1  GET /books  (If-None-Match: "ba811c01801a2c40d5e507c3c439e7e966380095a4001999178055355cebbec1")  ->  304 Not Modified
--- your cached_get() at now 61 for /books ---
source: 'revalidated'

/me is asked twice, because its reply has no tag. Then press Check my work.

Your client would also keep a reply naming who is logged in, if it came with a tag. Lesson 6 makes sure it never is.

Task

Write cache_rules(values) and cached_get(url, cache, *, now, make_connection, timeout) in wire.py. Do not import time or read a clock anywhere: every time comes from now.

  • cache_rules(values) returns {"store": True, "max_age": seconds}. Split each at commas, strip and lowercase each directive; for max-age=N with N made of ASCII digits, max_age is N. Otherwise it is 0.

  • cached_get keeps one entry per in cache: {"etag": tag, "expires": now + max_age, "record": record}.

    • Fresh (now is less than the entry’s expires): send nothing; return a copy of the stored record with "source": "cache".

    • Stale: send GET with If-None-Match set to the stored tag, through request_once. On a 304, set the entry’s expires to now plus the 304’s max-age, and return a copy of the stored record with "source": "revalidated".

    • Otherwise, and when nothing is stored, the reply’s record gets "source": "network". Store it when it is a 200 with exactly one etag; otherwise store nothing.

    • Pass make_connection and timeout on to request_once.

Run python pl_http.py cache in the to watch your cache against your own server.

Then press Check my work. It calls cache_rules with values you have not seen, including Max-Age, two lines, max-age=abc and max-age=². It calls cached_get with a stand-in for make_connection whose connections answer with scripted replies, at times 1000 and 86400 with max-age 45 and 7, on hosts and you have not seen. It checks the request each call sends (none when fresh, a plain GET on a miss, If-None-Match with the stored tag when stale), the record and its "source", that the entry is stale at exactly the time it was stored plus max-age, that a 304 restarts the timer with its own max-age and returns the stored body, that a 200 replaces the entry, that a 404, a 200 without a tag and a 200 with two tags are not stored, that changing a returned record does not change the cache, that every connection is closed and the timeout passed on, and that wire.py imports no time. Nothing from your terminal is graded.