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
- RFC 9110: HTTP Semantics — IETF / RFC Editor.
- RFC 9111: HTTP Caching — IETF / RFC Editor.
- Fetch Standard — WHATWG.
- HTTP conditional requests — MDN Web Docs.
- ETag header — MDN Web Docs.
- Last-Modified header — MDN Web Docs.
- http.server — HTTP servers — Python Software Foundation.
- email.utils: Miscellaneous utilities — Python Software Foundation.
- curl — How To Use — curl project.
- Overview of Google crawlers and fetchers (user agents) — Google.
- Troubleshoot Google Search crawling errors — Google Search Central.