0%

Caching and Conditional Requests · practice

Answer If-None-Match with 304

Every read now carries a tag, and a client can send that tag back. Your server does not listen yet. Run the helper’s conditional scenario:

python pl_http.py conditional

It sends four requests. The first is a plain GET /books. The second sends the tag the first reply carried back in a new header:

client -> server  b'GET /books HTTP/1.1\r\nHost: books.test\r\nIf-None-Match: "ba811c01801a2c40d5e507c3c439e7e966380095a4001999178055355cebbec1"\r\n\r\n'  (124 bytes)

The third is a HEAD with W/ in front of the same tag, and the fourth sends If-None-Match: "stale". Today all four get 200 OK with the full 132-byte , or its length for HEAD, though the second request already holds that list byte for byte.

The question in a header

If-None-Match turns a GET into a question: “send the body only if its tag is none of these”. The client puts in the tag of the copy it holds. The server compares it with the tag of what it would send now and answers in one of two ways:

The client holds a body and its tag T. It sends:

  GET /books
  If-None-Match: T

The server's current tag is T:
  304 Not Modified: no body, keep your copy
The server's current tag is not T:
  200 OK: the new body, with its new tag

A GET with If-None-Match is called a GET. Its 304 is short, a status line and a few headers, because the client already has the body.

Matching

The header’s is a list, not one tag (RFC 9110, section 13.1.2). A client that holds several versions of a resource can name all of them, separated by commas, and a client may also send the header on more than one line. The adapter hands your handle every line: request["headers"]["if-none-match"] is a list of str, one per line, as for every header. So etag_matches(values, etag) looks at every member of every line:

  1. Split each value at commas, and strip each member, because a space may follow a comma.

  2. A member that is * matches at once.

  3. A member that starts with W/ loses those two characters: W/"ba81..." becomes "ba81...".

  4. A member equal to etag is a match. The quotes stay on both sides: your tags carry them, and so do the members.

If no member matches, the answer is False.

What does this print?

print('W/"a1", "b2"'.split(","))

Step 3 is the weak comparison, which RFC 9110 requires for If-None-Match: two tags match when their quoted parts are equal, whether or not either has W/ in front. Your server never sends W/, but it reads it.

Step 2 handles *, which stands for any tag at all: “send it only if none of these” is false whenever the resource exists. It is mostly sent with writes, but RFC 9110 lets it match any current representation on a read too. Your server answers * with a 304 for any list or book that exists, and with the usual 404 for a book that does not.

On a write, a condition that fails gets a status you will see named in documentation, 412 Precondition Failed; conditions like these keep two clients from overwriting each other’s changes. This server checks no conditions on writes.

Splitting at commas is right for this server’s tags, which hold only hex digits; RFC 9110 (section 8.8.3) allows a comma inside a tag, and this reader would misread one.

A client sends if-none-match: * with GET /books/99, and book 99 does not exist. Which status does your server send?

What a 304 carries

A 304 says “the copy you hold is still the right one”, and RFC 9110 (section 15.4.5) says what it must repeat from the 200 it stands in for: among others the ETag and the Cache-Control the 200 would have carried. Both have a job here. The etag confirms which version the client holds. The cache-control starts a new freshness period, so a client that stored the copy with max-age=60 may reuse it for another 60 seconds. Lesson 5 builds a client that does exactly that.

A 304 has no body: it ends at the blank line after its headers. http.client knows this and reads nothing after a 304, so a body your server sent anyway would stay unread on the connection. On a connection that carries more than one reply, as in Chapter 9, those bytes would be read as the start of the next reply. It needs no content-length either. RFC 9110 (section 8.6) allows one only if it equals the length the 200 would have had, and leaving it out is the simple correct choice. content-length: 0 is wrong, because it is not the 200’s length.

So not_modified(response) takes the 200 and returns a new response : status 304, headers with only etag and cache-control copied from the 200, and the body b"". http.server adds its own Date and Server lines.

Which 304 is right for this server?

Where the rule goes

The rule belongs in handle after routing, where the 200 already exists with its tag, and before the HEAD rule. Only a 200 for a read carries an etag, so that is the response to check: when it has one and the request’s if-none-match values match it, replace it with not_modified(response). Every other reply is left alone, which is why * on a missing book still gets its 404. GET and HEAD share this path, so both can get a 304.

Your task

In reading_server.py, write etag_matches(values, etag) and not_modified(response), and add the rule to handle. Then run the scenario again:

python pl_http.py conditional

The second and third requests now get 304 Not Modified, the fourth still gets the full 200, and under each 304 the helper prints the two headers it repeats:

  etag: "ba811c01801a2c40d5e507c3c439e7e966380095a4001999178055355cebbec1"
  cache-control: max-age=60

If a 304 of yours carries a body, the helper shows those bytes on a line of their own, (N bytes followed the reply: ...), right under the reply. A correct 304 has no such line. Then press Check my work.

Your server can now save a client the download. No client asks yet: Lesson 5 gives wire.py a memory that keeps a reply, reuses it while it is fresh, and asks with If-None-Match once it is not.

Task

Write etag_matches(values, etag) and not_modified(response) in reading_server.py, and add the If-None-Match rule to handle.

  • etag_matches(values, etag): values is the of if-none-match lines. Split each line at commas and strip each member. Return True when a member is *, or when the member, with a leading W/ removed, equals etag. Otherwise return False.

  • not_modified(response): return a new response with status 304, headers holding only the 200’s etag and cache-control, and the body b"". No content-length.

  • In handle, after the route has produced its response and before the HEAD rule: when the response has an etag header and the request has if-none-match values that match it, replace the response with not_modified(response).

Run python pl_http.py conditional in the to see the 304s.

Then press Check my work. It calls etag_matches with one tag, a list on one line, two lines, W/ in front, *, no values, and tags that differ in one character or lack their quotes, and not_modified with a 200 whose cache-control you have not seen. It calls handle with a state you have not seen: GET and HEAD with the current tag, with W/, and inside a list get 304 with no body, the 200’s etag and cache-control, and no content-length (one equal to the 200’s is accepted, 0 is not); a stale tag gets the 200 with the body; after a POST the old tag gets the new list; * gets 304 on /books and the usual 404 or 400 elsewhere; writes keep their Chapter 7 answers. It also writes one 304 through http_adapter in memory, with the header name in odd casing, and checks that the bytes end at the blank line. Nothing from your terminal is graded.