Implement conditional GET by sending stable validators with each document, checking the client’s saved validators, and returning either the current document or a bodyless 304 Not Modified. This Mac tutorial demonstrates the process with a synthetic text page.

The task is to make repeat requests for a public document transfer its contents only when needed. You will run a small local Python server, test it from Terminal, and simulate a document update. The example stays local; publishing it is a separate task.

How should the server choose between 200 and 304?

An initial request receives 200 OK, the document body, and its validators. A later conditional request sends saved validators back. If the document is unchanged, the server returns 304 Not Modified, allowing the client to reuse its stored body. Changed content receives a new 200 response. HTTP conditional requests.

For an existing document that would otherwise return 200, apply this decision sequence. If-None-Match takes precedence over If-Modified-Since; they are not two independent tests. RFC 9110, conditional request fields.

Request condition Response
No conditional headers 200 with the document
If-None-Match matches the current tag 304, without a body
If-None-Match is present but does not match 200, even if a supplied date suggests no change
Only a valid If-Modified-Since is supplied, and the document is no newer 304, without a body
Only a valid If-Modified-Since is supplied, and the document is newer 200 with the document

Evaluate these conditions after selecting the document and establishing its normal response. A missing document must still produce its normal missing-resource response. A 304 cannot contain content or trailers. It must carry applicable cache metadata, including the Date, ETag, Vary, Cache-Control, Expires, and Content-Location fields that would accompany its 200 response. RFC 9110, precondition evaluation and 304 requirements.

Choose validators that describe the published document

An ETag is a quoted version identifier, such as "doc-v1". Its generation method is implementation-defined: a revision identifier or a content hash can work. Keep it stable for the same representation and change it when that representation changes. The example uses two explicit revisions whose contents are fixed. ETag header.

A strong tag identifies a specific representation; a weak tag has the prefix W/ and can describe semantically equivalent content whose bytes differ. Conditional GET comparisons with If-None-Match use weak comparison, so W/"doc-v1" matches "doc-v1". Requests can also contain a list of tags, or the standalone wildcard *. RFC 9110, If-None-Match.

Last-Modified describes when the document changed. Use a consistently determined modification time, incorporating relevant document components, rather than the time of every request. It must not be later than the response’s Date. RFC 9110, Last-Modified generation.

The sample assigns deliberately illustrative publication times to its two synthetic revisions: noon GMT on January 1 and January 2, 2024. Those dates are fixture data. For a real publishing workflow, use maintained publication metadata. Last-Modified is less precise than an ETag for detecting content differences and provides a useful fallback. Last-Modified header.

Select a cache policy separately

Validators answer whether a stored copy still matches. Freshness policy determines when a cache needs to ask. This tutorial sends Cache-Control: no-cache, which permits storage but requires successful validation before reuse. That makes repeat-request behavior easy to observe. no-store prohibits storage, while max-age permits reuse while a response remains fresh. RFC 9111, response cache directives.

For a public document that can tolerate a short delay before an update becomes visible, a policy such as max-age=60 may be suitable. That is an illustrative editorial choice: choose the interval from the document’s update requirements. A fresh cached response can be reused without contacting the server, so every repeat visit need not produce a 304. RFC 9111, freshness.

Run a complete local example on your Mac

Open Terminal and check the prerequisites with the following commands. Use Python 3.9 or newer because the program uses str.removeprefix. If python3 is unavailable, install Python 3 before continuing. No third-party Python packages are needed.

python3 --version
curl --version

In a new working folder, save the following complete program as server.py using a plain-text code editor. It serves only /page.txt, implements GET and HEAD, and selects a document revision from its command-line argument. Python’s BaseHTTPRequestHandler dispatches requests to the corresponding methods and supplies response-writing helpers. Python HTTP server documentation.

import argparse
import re
from datetime import datetime, timezone
from email.utils import format_datetime, parsedate_to_datetime
from http.server import BaseHTTPRequestHandler, HTTPServer

parser = argparse.ArgumentParser()
parser.add_argument("version", type=int, choices=(1, 2))
args = parser.parse_args()

BODY = {
    1: b"Paper Shapes\nA circle and a square.\n",
    2: b"Paper Shapes\nA circle, a square, and a triangle.\n",
}[args.version]
ETAG = '"doc-v%d"' % args.version
MODIFIED = datetime(2024, 1, args.version, 12, tzinfo=timezone.utc)
LAST_MODIFIED = format_datetime(MODIFIED, usegmt=True)
TOKEN = r'(?:W/)?"[\x21\x23-\x7e\x80-\xff]*"'
TAG_LIST = re.compile(rf'[ \t]*(?:{TOKEN})?(?:[ \t]*,[ \t]*(?:{TOKEN})?)*[ \t]*')

def tag_matches(value):
    if value.strip() == "*":
        return True
    if not TAG_LIST.fullmatch(value):
        return False
    return any(tag.removeprefix("W/") == ETAG
               for tag in re.findall(TOKEN, value))

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.serve_document()

    def do_HEAD(self):
        self.serve_document()

    def serve_document(self):
        if self.path != "/page.txt":
            self.send_error(404)
            return
        not_modified = False
        tags = self.headers.get_all("If-None-Match")
        if tags is not None:
            not_modified = tag_matches(",".join(tags))
        else:
            dates = self.headers.get_all("If-Modified-Since", [])
            if len(dates) == 1:
                try:
                    since = parsedate_to_datetime(dates[0])
                    if since.tzinfo is None:
                        since = since.replace(tzinfo=timezone.utc)
                    not_modified = MODIFIED <= since
                except (TypeError, ValueError, OverflowError):
                    pass
        self.send_response(304 if not_modified else 200)
        self.send_header("ETag", ETAG)
        self.send_header("Cache-Control", "no-cache")
        if not not_modified:
            self.send_header("Last-Modified", LAST_MODIFIED)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(BODY)))
        self.end_headers()
        if not not_modified and self.command == "GET":
            self.wfile.write(BODY)

if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

This is a local demonstration of the two named validators, rather than a complete HTTP serving stack. Its date conversion uses Python’s permissive email-date parser; a deployed implementation should use HTTP-specific date validation and conditional-request handling. Python documents that parser’s accepted inputs and exceptions. Python email date utilities.

In Terminal, change into the folder containing server.py and start revision 1. Leave that window running. If port 8000 is already occupied, choose another available port in the program and use the same port in every test URL.

python3 server.py 1

Test unchanged content, changed content, and precedence

Open another Terminal window. The following requests display response headers and any returned body. The -i option includes headers, -H supplies a request header, and --max-time bounds each transfer. curl manual.

Establish the initial response

curl --max-time 5 -sS -i http://127.0.0.1:8000/page.txt

Expect 200 OK, the synthetic page text, ETag: "doc-v1", and Last-Modified: Mon, 01 Jan 2024 12:00:00 GMT. Record the values actually returned when applying this procedure to another document; validators belong to that particular response.

Revalidate with each validator

curl --max-time 5 -sS -i \
  -H 'If-None-Match: "doc-v1"' \
  http://127.0.0.1:8000/page.txt

curl --max-time 5 -sS -i \
  -H 'If-Modified-Since: Mon, 01 Jan 2024 12:00:00 GMT' \
  http://127.0.0.1:8000/page.txt

Both requests should return 304 Not Modified without the page text. The response retains ETag and Cache-Control; Python’s response helper adds Date. Repeating the first request with W/"doc-v1" should also return 304. The sample parses quoted tags rather than splitting blindly on commas, because a comma can occur inside a tag.

Prove that ETag takes precedence

curl --max-time 5 -sS -i \
  -H 'If-None-Match: "older-revision"' \
  -H 'If-Modified-Since: Wed, 03 Jan 2024 12:00:00 GMT' \
  http://127.0.0.1:8000/page.txt

Expect 200 OK with the document. The tag does not match, so the later date must not trigger a 304. This deliberately conflicting request detects an implementation that mistakenly combines the two tests.

Simulate publishing the next revision

In the server window, press Control-C, then start revision 2. Keep the request URL unchanged.

python3 server.py 2

Repeat the two earlier revalidation requests containing revision 1’s validators. Both should now receive 200, the updated sentence mentioning a triangle, ETag: "doc-v2", and the January 2 modification date. Repeating a request with If-None-Match: "doc-v2" should return 304.

These results give you an acceptance sequence: initial download, unchanged validation, conflicting validators, changed document, then unchanged validation of the new revision. The sample was exercised with 24 local checks across both revisions, including weak tags, tag lists, wildcard matching, HEAD, and missing paths.

Why browser and Google requests can look different

Browsers normally manage HTTP validators through their cache. The WHATWG Fetch algorithm adds saved validators when revalidating and, after a successful 304, updates the stored response’s headers and returns the stored response. Consequently, application JavaScript can receive a usable 200 response and document body even though network revalidation received 304. Fetch Standard, HTTP-network-or-cache fetch.

When diagnosing server behavior, begin with the explicit curl requests above. Manually supplying conditional headers in browser JavaScript changes the test: Fetch switches a default-cache request containing those headers to no-store. Let the browser manage its cache when testing ordinary browser revalidation. Fetch Standard, request cache mode.

Google’s crawling infrastructure supports both validator pairs and recommends ETags because they avoid date-formatting issues. Its documentation also says other HTTP caching directives are unsupported by that infrastructure. Choose your browser cache policy separately from expectations about Google’s recrawl behavior. Overview of Google crawlers and fetchers.

Google does not send conditional headers on every crawl attempt. Its Search documentation explains that a 304 permits reuse of previously crawled content, while newer content can receive a 200 with the updated body. Keep ordinary unconditional requests working; a crawler request without validators is not evidence that your implementation failed. Troubleshoot Google Search crawling errors.

Before applying this pattern to a published document, repeat the acceptance sequence against its actual serving URL. Check that unchanged revisions preserve their validators, edits update them, and a 304 carries no document body. The useful outcome is correct document reuse while changed content remains retrievable.

Sources