How to Set Cache-Control Headers in FastAPI and nginx, Prove What Browsers and Shared Caches Keep, and Stop Cross-User Cache Leaks
Build a FastAPI origin, watch Edge and nginx 1.30.6 react to a dozen Cache-Control profiles, reproduce a cross-user cache leak with two made-up users, and audit any URL with a tested script.
Click a link, press the back button, and a page comes back instantly. Publish a fix to your own site and some visitors still see the old version a day later. Log in to an account page and, if a cache in front of the site is set up badly, someone else can be shown your name. All three are caching behavior, and all three are decided by a few HTTP response headers that most developers never look at.
Table Of Content
- Words we will use
- Prerequisites
- The idea: three questions every cache asks
- Question 1: may I store this at all?
- Question 2: is it still fresh?
- Question 3: when it is stale, can I check cheaply?
- The directives you will use in this tutorial
- Step 1: Build an origin server that can answer with any caching headers
- Two helpers: start the servers and run a step
- Step 2: Look at the headers a response really carries
- Step 3: Watch what a real browser does with each set of headers
- Reading the table row by row
- bare: nothing to reuse and nothing to check
- lastmod: the browser guessed
- max-age: the deliberate version of the same result
- typo: a header that looks right and does nothing
- no-cache and max-age=0, must-revalidate: stored, but checked every time
- no-store: nothing is kept
- immutable: a year-long lifetime
- max-age-2: what happens when the lifetime runs out
- Reload versus hard reload
- How long does a browser’s guess last?
- Step 4: Choose a header for each kind of response and make it the default
- Step 5: Put nginx in front and see what a shared cache keeps
- Reading nginx’s answers
- Step 6: Reproduce a cache leak, then fix it
- Scenarios A and B: a missing Vary header serves the wrong language
- Scenarios C and D: a personal page marked cacheable goes to the wrong person
- Scenarios E and F: private works until the proxy is told to ignore it
- Scenario G: a safety net on the proxy
- What to do on your own sites
- Step 7: Serve stale content on purpose with stale-while-revalidate and stale-if-error
- Reading the timings
- Step 8: Audit the caching headers of any URL
- Tests, and a check that the tests would notice a bug
- Common mistakes and how to spot them
- Reading no-cache as “do not cache”
- A long lifetime on a URL that does not change with the content
- Protecting personal pages with the login alone
- Testing with F5
- Silent typos
- Confirm the whole lab works end to end
- Where to go next
In this tutorial you will learn to read those headers, set them on purpose, and prove what each one does. You will build a small FastAPI “origin” server that can answer with any caching headers you choose. You will watch Microsoft Edge (driven by a script) and nginx react to each set of headers, reproduce a cache leak on your own machine with two made-up users, and finish with a tested script that audits the caching headers of any URL. Every table of results below comes from a run on my machine, and every script is on this page.
Words we will use
- A cache keeps a copy of a response so the next request for the same thing can be answered without asking the server again. A private cache belongs to one person; your browser’s cache is one. A shared cache serves many people; a CDN or a reverse proxy such as nginx is one.
- The origin is the server that creates the response. Here it is a FastAPI app.
- A response is fresh until its freshness lifetime runs out, and a cache may reuse it without asking anyone. Afterwards it is stale.
- A validator is a label for one version of a file: an
ETag(a fingerprint the server chooses) or aLast-Modifieddate. A cache can send it back in a conditional request (If-None-MatchorIf-Modified-Since), and the server answers 304 Not Modified, with no body, if nothing changed. - A directive is one instruction inside a
Cache-Controlheader, such asmax-age=60. - A fingerprinted URL contains a hash of the file’s content, such as
app.3f9a1c2b.css, so the URL changes whenever the content does.
Prerequisites
- Python 3.12 or newer. The lab ran on Python 3.13.14 with FastAPI 0.162.2, uvicorn 0.54.0, requests 2.34.2, pytest 9.2.0, httpx2 2.16.0 and Playwright 1.64.1. Starlette 1.10.0 and httpx2 ask for at least Python 3.11, and one of Playwright’s own dependencies, pyee 14.0.0, asks for 3.12.
- Windows 11 is where I ran every command, in Git Bash. The Python scripts are portable, but
labkit.pybelow usestaskkilland the Windows build of nginx, so on Linux or macOS you would install nginx with your package manager and adapt three small functions. I did not run the lab on Linux or macOS. - Microsoft Edge, which comes with Windows 11. The browser experiment drives it through Playwright with
channel="msedge"(the run used Edge 157.0.4341.42), so no separate browser download is needed. Playwright can also drive Chrome withchannel="chrome", but I only ran Edge. - nginx 1.30.6.
labkit.pydownloads the Windows zip from nginx.org over HTTPS and unpacks it next to the lab. That is fine for a throwaway lab; for a real server use your package manager or verify the release signature. - No caching knowledge. If you can read a short Python function and a table, you have what you need.
Create a folder for the lab, open a terminal in it, and set up an isolated environment. The commands below are for Git Bash; in PowerShell activate with .venv\Scripts\Activate.ps1.
python -m venv .venv
source .venv/Scripts/activate
pip install fastapi==0.162.2 uvicorn==0.54.0 requests==2.34.2 pytest==9.2.0 httpx2==2.16.0 playwright==1.64.1
Every file in this tutorial starts with a comment that names it, such as # origin.py. Save each one under that name in the lab folder.
The idea: three questions every cache asks
When a cache sees a request, it asks itself three questions in order. Every caching header exists to answer one of them.
Question 1: may I store this at all?
Two directives answer this. no-store is the strongest: RFC 9111, the HTTP caching standard, says it means “a cache MUST NOT store any part of either the immediate request or the response”, and that it applies to private and shared caches alike. private is narrower. The standard says it “indicates that a shared cache MUST NOT store the response (i.e., the response is intended for a single user)”, while a browser’s own cache may keep it. The related directive public tells a cache it MAY store the response even where it would otherwise be prohibited.
MDN, the web platform documentation, puts the practical stake plainly: if you forget to add private to a response with personalized content, “then that response can be stored in a shared cache and end up being reused for multiple users, which can cause personal information to leak.” Step 6 reproduces exactly that.
Question 2: is it still fresh?
If the answer to the first question is yes, the cache works out how long the copy may be reused without asking the origin. max-age=N says N seconds. The standard says the directive “indicates that the response is to be considered stale after its age is greater than the specified number of seconds.” s-maxage=N is the same instruction for shared caches only and “overrides the maximum age specified by either the max-age directive or the Expires header field”. That lets you tell a CDN to keep something for a minute while telling browsers to ask every time.
What if the response has no lifetime at all? The standard allows a cache to guess: when a response carries a Last-Modified date, “caches are encouraged to use a heuristic expiration value that is no more than some fraction of the interval since that time. A typical setting of this fraction might be 10%.” So a file that last changed 10 days ago may be reused for a day, with no header telling anyone to. Step 3 measures this in a real browser. The standard also says what to do with a lifetime that makes no sense: “Caches are encouraged to consider responses that have invalid freshness information (e.g., a max-age directive with non-integer content) to be stale.”
Question 3: when it is stale, can I check cheaply?
A stale copy is not useless. If the response carried an ETag or Last-Modified, the cache can ask the origin “is my copy still current?” and receive a body-less 304 when it is. no-cache is the directive that forces this on every reuse. Despite its name it does not forbid storing: the standard says the response “MUST NOT be used to satisfy any other request without forwarding it for validation and receiving a successful response”. must-revalidate is the milder cousin that applies once the response has gone stale. The MDN reference notes that max-age=0, must-revalidate is equivalent to no-cache, which Step 3 confirms.
The directives you will use in this tutorial
| Directive | What it says |
|---|---|
max-age=N |
Fresh for N seconds; after that, stale. |
s-maxage=N |
The same, for shared caches only, and it wins over max-age there. |
no-cache |
May be stored, but must be checked with the origin before every reuse. |
no-store |
Do not store it anywhere. |
private |
Only the user’s own cache may store it, never a shared one. |
public |
A shared cache may store it even where it normally would not. |
immutable |
This file will not change while it is fresh, so do not bother checking. |
stale-while-revalidate=N |
For N seconds after going stale, a cache may serve the old copy while it refreshes in the background. |
stale-if-error=N |
For N seconds after going stale, a cache may serve the old copy if the origin is failing. |
Enough theory. The next steps make every row of that table visible.
Step 1: Build an origin server that can answer with any caching headers
To study caching you need a server whose headers you control and whose log shows every request that actually reached it. A request that never appears in that log was answered by a cache somewhere else, which is the whole point of caching. Save this as origin.py.
# origin.py
"""The origin server for the caching lab.
It answers with whatever caching headers a named profile asks for, and it remembers every request that
actually reached it. A request that never shows up in that log was answered by a cache somewhere else.
"""
import asyncio
import hashlib
import time
from email.utils import formatdate
from fastapi import FastAPI, Request, Response
app = FastAPI()
LAST_MODIFIED_AGE = {"lastmod": 10 * 86400, "lastmod-100s": 100} # how old the file claims to be at its first request
LAST_MODIFIED: dict[str, str] = {}
HITS: list[dict] = [] # every request that reached this server, oldest first
COUNTER = {"n": 0} # numbers the responses of /item so a cached copy can be told from a fresh one
# profile name -> (Cache-Control value or None, validators to send, extra response headers)
PROFILES = {
"bare": (None, (), {}),
"lastmod": (None, ("last-modified",), {}),
"lastmod-100s": (None, ("last-modified",), {}),
"max-age": ("max-age=60", ("etag",), {}),
"max-age-2": ("max-age=2", ("etag",), {}),
"typo": ("max-age: 60", ("etag",), {}),
"no-cache": ("no-cache", ("etag",), {}),
"no-store": ("no-store", ("etag",), {}),
"must-revalidate": ("max-age=0, must-revalidate", ("etag",), {}),
"immutable": ("public, max-age=31536000, immutable", ("etag",), {}),
"swr": ("max-age=2, stale-while-revalidate=30, stale-if-error=60", ("etag",), {}),
"private": ("private, max-age=60", ("etag",), {}),
"s-maxage": ("max-age=0, s-maxage=30", ("etag",), {}),
"set-cookie": ("max-age=60", ("etag",), {"Set-Cookie": "seen=1"}),
"vary-star": ("max-age=60", ("etag",), {"Vary": "*"}),
"novary-lang": ("max-age=60", (), {}),
"vary-lang": ("max-age=60", (), {"Vary": "Accept-Language"}),
"me-public": ("max-age=60", (), {}),
"me-private": ("private, no-store", (), {}),
}
def answer(request: Request, name: str, body: str, media_type: str) -> Response:
"""Build the response for one profile, honour If-None-Match and If-Modified-Since, and log the request."""
if name not in PROFILES:
return Response("unknown profile\n", status_code=404, media_type="text/plain")
cache_control, validators, extra = PROFILES[name]
etag = '"' + hashlib.sha256(body.encode()).hexdigest()[:12] + '"'
headers = dict(extra)
if cache_control is not None:
headers["Cache-Control"] = cache_control
if "etag" in validators:
headers["ETag"] = etag
last_modified = None
if "last-modified" in validators:
last_modified = LAST_MODIFIED.setdefault(name, formatdate(time.time() - LAST_MODIFIED_AGE[name], usegmt=True))
headers["Last-Modified"] = last_modified
if_none_match = request.headers.get("if-none-match")
if_modified_since = request.headers.get("if-modified-since")
revalidated = (if_none_match == etag) or (last_modified is not None and if_modified_since == last_modified)
status = 304 if revalidated else 200
HITS.append({
"path": request.url.path, "status": status,
"inm": if_none_match is not None, "ims": if_modified_since is not None,
"cc": request.headers.get("cache-control"),
})
if revalidated:
return Response(status_code=304, headers=headers) # a 304 repeats the caching headers of the 200
return Response(body, media_type=media_type, headers=headers)
def who(request: Request) -> str:
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer "):
return auth[len("Bearer "):]
return request.cookies.get("user", "anonymous")
@app.get("/page/{name}")
def page(name: str, request: Request) -> Response:
html = f"<!doctype html><title>{name}</title><link rel=stylesheet href=/css/{name}.css><h1>{name}</h1>\n"
return answer(request, name, html, "text/html")
@app.get("/css/{name}.css")
def css(name: str, request: Request) -> Response:
return answer(request, name, "h1 { color: #1a5276 }\n", "text/css")
@app.get("/item/{name}")
async def item(name: str, request: Request, delay: float = 0.0) -> Response:
"""A personalised, numbered response: the greeting follows Accept-Language, the name follows the login."""
COUNTER["n"] += 1
number = COUNTER["n"]
if delay:
await asyncio.sleep(delay)
french = request.headers.get("accept-language", "en").lower().startswith("fr")
body = f"{'Bonjour' if french else 'Hello'} {who(request)} (origin response number {number})\n"
return answer(request, name, body, "text/plain")
@app.get("/blank")
def blank() -> Response:
return Response("<!doctype html><title>blank</title>\n", media_type="text/html", headers={"Cache-Control": "no-store"})
@app.get("/__hits")
def hits(clear: int = 0) -> list[dict]:
seen = list(HITS)
if clear:
HITS.clear()
return seen
@app.get("/__reset")
def reset() -> dict:
HITS.clear()
COUNTER["n"] = 0
return {"ok": True}
Read it from the top. PROFILES is a table of named header sets: bare sends nothing, lastmod sends only a Last-Modified date (ten days old), max-age sends max-age=60 plus an ETag, and so on. typo deliberately writes max-age: 60 with a colon instead of an equals sign, a mistake that is easy to make and silent when you make it. The answer() function builds a response for one profile, computes an ETag from the body, and compares it with any If-None-Match or If-Modified-Since the client sent. On a match it returns a 304 that repeats the caching headers, as the HTTP standard (RFC 9110) requires of a 304: the server “MUST generate any of the following header fields that would have been sent in a 200 (OK) response to the same request”, and that list includes Cache-Control. Every call appends a record to HITS, which /__hits returns.
Three routes serve the profiles. /page/NAME is an HTML page that links to a stylesheet, /css/NAME.css is that stylesheet, and /item/NAME returns a short numbered text, “Hello alice (origin response number 7)”, whose greeting follows the Accept-Language header and whose name follows a cookie or a bearer token. The number goes up on every request that reaches the origin, so a cached copy is easy to recognize: it repeats an old number.
Two helpers: start the servers and run a step
The experiments need the origin and, later, nginx running. These two files start both, run the step you name, save its output to out/, and stop everything. Save the first as labkit.py.
# labkit.py
"""Start and stop the lab servers: the origin (uvicorn) and nginx (a real build, unzipped next to the lab)."""
import io
import os
import socket
import subprocess
import sys
import time
import zipfile
import requests
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out")
os.makedirs(OUT, exist_ok=True)
NGINX_VERSION = "1.30.6"
ORIGIN = "http://127.0.0.1:8100"
PROXY = "http://127.0.0.1:8181"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
def wait_port(port: int, seconds: float = 15.0) -> None:
deadline = time.time() + seconds
while time.time() < deadline:
try:
socket.create_connection(("127.0.0.1", port), timeout=0.3).close()
return
except OSError:
time.sleep(0.1)
raise RuntimeError(f"nothing is listening on port {port}")
def start_origin(port: int = 8100) -> subprocess.Popen:
cmd = [sys.executable, "-m", "uvicorn", "origin:app", "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"]
proc = subprocess.Popen(cmd, cwd=HERE, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
wait_port(port)
return proc
def kill(proc: subprocess.Popen) -> None:
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
def nginx_command() -> list[str]:
dist = os.path.join(HERE, "nginx_dist")
exe = os.path.join(dist, f"nginx-{NGINX_VERSION}", "nginx.exe")
if not os.path.exists(exe):
data = requests.get(f"https://nginx.org/download/nginx-{NGINX_VERSION}.zip", headers={"User-Agent": UA}, timeout=120).content
zipfile.ZipFile(io.BytesIO(data)).extractall(dist)
prefix = os.path.join(HERE, "nginx_run")
for folder in ("logs", "temp"):
os.makedirs(os.path.join(prefix, folder), exist_ok=True)
return [exe, "-p", prefix, "-c", os.path.join(HERE, "nginx.conf")]
def start_nginx() -> subprocess.Popen:
base = nginx_command()
check = subprocess.run(base + ["-t"], capture_output=True, text=True)
if check.returncode != 0:
raise RuntimeError(check.stderr)
proc = subprocess.Popen(base)
wait_port(8181)
return proc
def stop_nginx(proc: subprocess.Popen) -> None:
subprocess.run(nginx_command() + ["-s", "stop"], capture_output=True)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
kill(proc)
def drain_hits() -> list[dict]:
"""Return the requests that reached the origin since the last call, and forget them."""
return requests.get(f"{ORIGIN}/__hits", params={"clear": 1}, timeout=5).json()
Save the second as run_all.py. It already lists every step of this tutorial; a step only needs its script to exist when you ask for it by name.
# run_all.py
"""Start the origin and nginx, run the lab steps, save each step's output to out/<step>.txt, stop everything.
Usage: python run_all.py [step ...] (no arguments runs every step)
"""
import os
import subprocess
import sys
import labkit
STEPS = {
"peek": [[sys.executable, "peek.py", f"{labkit.ORIGIN}/css/max-age.css"],
[sys.executable, "peek.py", f"{labkit.ORIGIN}/css/lastmod.css"],
[sys.executable, "peek.py", f"{labkit.ORIGIN}/css/bare.css"]],
"browser": [[sys.executable, "browser_lab.py"]],
"proxy": [[sys.executable, "proxy_lab.py"]],
"leak": [[sys.executable, "leak_lab.py"]],
"stale": [[sys.executable, "stale_lab.py"]],
"audit": [[sys.executable, "cachecheck.py", f"{labkit.ORIGIN}{path}", *flags] for path, flags in (
("/css/typo.css", []), ("/css/lastmod.css", []), ("/css/immutable.css", []), ("/css/no-cache.css", []),
("/item/set-cookie", []), ("/item/vary-star", []), ("/item/me-public", ["--auth"]), ("/item/me-private", ["--auth"]))],
"policytests": [[sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "test_cache_policy.py"]],
"tests": [[sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "test_cachecheck.py", "test_cache_policy.py"]],
"mutations": [[sys.executable, "mutations.py"]],
}
wanted = sys.argv[1:] or list(STEPS)
origin = labkit.start_origin()
nginx = labkit.start_nginx()
try:
for step in wanted:
chunks = []
for command in STEPS[step]:
done = subprocess.run(command, cwd=labkit.HERE, capture_output=True, text=True, encoding="utf-8",
env={**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1"})
chunks.append(done.stdout + (f"\n[stderr]\n{done.stderr}" if done.stderr.strip() else ""))
text = "\n".join(chunks)
with open(os.path.join(labkit.OUT, f"{step}.txt"), "w", encoding="utf-8", newline="\n") as handle:
handle.write(text)
print(f"== {step} ==")
print(text)
finally:
labkit.stop_nginx(nginx)
labkit.kill(origin)
Step 2: Look at the headers a response really carries
Before involving a browser, see what the origin sends. peek.py prints the status line and only the headers that matter for caching. Save it, then run the first step. (curl -i URL shows the same headers if you prefer; in PowerShell call it as curl.exe, because curl there is an alias for a different command.)
# peek.py
"""Print the status line and the headers that decide caching for one URL: python peek.py URL [Header: value ...]"""
import sys
import requests
CACHE_HEADERS = ("cache-control", "expires", "etag", "last-modified", "vary", "set-cookie", "age", "x-cache-status", "pragma")
def peek(url: str, extra: dict[str, str] | None = None) -> requests.Response:
response = requests.get(url, headers=extra or {}, timeout=10)
print(f"GET {url} -> {response.status_code}")
for name in CACHE_HEADERS:
if name in response.headers:
print(f" {name}: {response.headers[name]}")
return response
if __name__ == "__main__":
pairs = dict(item.split(": ", 1) for item in sys.argv[2:])
peek(sys.argv[1], pairs)
python run_all.py peek
The output should look like this, apart from the date:
GET http://127.0.0.1:8100/css/max-age.css -> 200
cache-control: max-age=60
etag: "9ab2ba2b938d"
GET http://127.0.0.1:8100/css/lastmod.css -> 200
last-modified: Fri, 30 Oct 2026 11:37:51 GMT
GET http://127.0.0.1:8100/css/bare.css -> 200
Compare the three responses. The first has a Cache-Control with a lifetime and an ETag, so any cache can both reuse it for 60 seconds and validate it afterwards. The second has only Last-Modified, which is a validator but says nothing about lifetime. The third has nothing, so a cache has no information at all. These three are the baseline for the next step, where a real browser has to decide what to do with each.
Step 3: Watch what a real browser does with each set of headers
Headers are promises; a browser decides what to do with them. This script opens a fresh Edge profile for each header set, so no earlier visit can leave a copy behind, and performs four actions. A first visit loads the page. A second visit navigates away to a blank page and back, which is what clicking a link looks like to the cache. A reload is what F5 does. A hard reload is what Ctrl+Shift+R does; the script sends it through the browser’s DevTools Protocol as a reload with the cache ignored. After each action it asks the origin which requests actually arrived. Save it as browser_lab.py.
# browser_lab.py
"""What does a real browser (Edge, driven by Playwright) ask the origin for? One fresh browser context per profile.
Each cell is "page/stylesheet": the status the origin answered for each of the two files, or "-" when the
request never reached the origin because the browser answered from its own cache.
"""
import time
from playwright.sync_api import sync_playwright
from labkit import ORIGIN, drain_hits
from origin import PROFILES
ROWS = [ # (profile, seconds to wait before the second visit)
("bare", 0), ("lastmod", 0), ("max-age", 0), ("typo", 0), ("no-cache", 0), ("no-store", 0),
("must-revalidate", 0), ("immutable", 0), ("max-age-2", 3),
]
def cell(hits: list[dict], prefix: str) -> str:
codes = [str(h["status"]) for h in hits if h["path"].startswith(prefix)]
return "+".join(codes) if codes else "-"
def summary(hits: list[dict]) -> str:
return f"{cell(hits, '/page/')}/{cell(hits, '/css/')}"
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
print(f"browser: Edge {browser.version}, Playwright driver")
print(f"{'profile':16}{'Cache-Control sent':54}{'1st visit':11}{'2nd visit':11}{'reload':11}{'hard reload':11}")
notes = []
for name, wait in ROWS:
context = browser.new_context()
page = context.new_page()
url = f"{ORIGIN}/page/{name}"
drain_hits()
page.goto(url)
first = drain_hits()
page.goto(f"{ORIGIN}/blank")
drain_hits()
time.sleep(wait)
page.goto(url)
second = drain_hits()
page.reload()
reloaded = drain_hits()
cdp = context.new_cdp_session(page)
cdp.send("Page.reload", {"ignoreCache": True}) # what Ctrl+Shift+R does
page.wait_for_load_state("load")
time.sleep(0.5)
hard = drain_hits()
context.close()
sent = PROFILES[name][0] or "(none)"
label = f"{name}" + (f" +{wait}s" if wait else "")
print(f"{label:16}{sent:54}{summary(first):11}{summary(second):11}{summary(reloaded):11}{summary(hard):11}")
if name == "max-age":
page_reload = [h for h in reloaded if h["path"].startswith("/page/")][0]
page_hard = [h for h in hard if h["path"].startswith("/page/")][0]
notes.append(f"max-age, reload request for the page: Cache-Control={page_reload['cc']!r}, If-None-Match sent={page_reload['inm']}")
notes.append(f"max-age, hard reload request for the page: Cache-Control={page_hard['cc']!r}, If-None-Match sent={page_hard['inm']}")
print()
for line in notes:
print(line)
# heuristic freshness: Last-Modified says the file was changed 100 seconds before it was first sent
context = browser.new_context()
page = context.new_page()
url = f"{ORIGIN}/page/lastmod-100s"
drain_hits()
page.goto(url)
print()
print(f"lastmod-100s, first visit: {summary(drain_hits())}")
visits = []
for pause in (5, 8):
page.goto(f"{ORIGIN}/blank")
drain_hits()
time.sleep(pause)
page.goto(url)
visits.append(summary(drain_hits()))
print(f"lastmod-100s, visit 5 s later: {visits[0]}; visit 13 s after the first: {visits[1]}")
context.close()
browser.close()
Each cell of the result is “page/stylesheet”: the status the origin answered for each of the two files, or a dash when nothing reached the origin because the browser answered from its own cache. So 200/200 means both files were downloaded in full, 304/304 means both were checked and found unchanged, and -/- means the browser did not contact the server at all. The script waits about half a minute in total, so be patient.
python run_all.py browser
browser: Edge 157.0.4341.42, Playwright driver
profile Cache-Control sent 1st visit 2nd visit reload hard reload
bare (none) 200/200 200/200 200/200 200/200
lastmod (none) 200/200 -/- 304/- 200/200
max-age max-age=60 200/200 -/- 304/- 200/200
typo max-age: 60 200/200 304/304 304/304 200/200
no-cache no-cache 200/200 304/304 304/304 200/200
no-store no-store 200/200 200/200 200/200 200/200
must-revalidate max-age=0, must-revalidate 200/200 304/304 304/304 200/200
immutable public, max-age=31536000, immutable 200/200 -/- 304/- 200/200
max-age-2 +3s max-age=2 200/200 304/304 304/- 200/200
max-age, reload request for the page: Cache-Control='max-age=0', If-None-Match sent=True
max-age, hard reload request for the page: Cache-Control='no-cache', If-None-Match sent=False
lastmod-100s, first visit: 200/200
lastmod-100s, visit 5 s later: -/-; visit 13 s after the first: 304/304
Reading the table row by row
bare: nothing to reuse and nothing to check
With no lifetime and no validator, Edge downloaded both files on every action (200/200 in all four columns). This is correct behavior, just wasteful.
lastmod: the browser guessed
The origin sent only a Last-Modified date ten days in the past, no Cache-Control. On the second visit Edge made no request at all (-/-). Nobody told it to keep the files; it applied a heuristic of the kind the standard describes. That is the surprise behind many “I never set any caching headers, so why is it cached?” questions.
max-age: the deliberate version of the same result
max-age=60 gives -/- on the second visit too, but now it is a promise you made, not a guess the browser made.
typo: a header that looks right and does nothing
max-age: 60 uses a colon where the grammar needs an equals sign. Edge ignored the directive, found no usable lifetime, and checked with the origin on every visit (304/304). Nothing failed and nothing warned; the only symptom is a log full of 304s. Step 8 builds a tool that catches this.
no-cache and max-age=0, must-revalidate: stored, but checked every time
Both rows show 304/304 on the second visit and on reload. The files were kept (the 304s carry no body), but the origin was asked each time. The two rows are identical, which matches MDN’s remark that they are equivalent. no-cache saves bandwidth, not requests.
no-store: nothing is kept
200/200 everywhere. From the origin’s side it looks like bare, but the meaning is different: bare is a missing instruction, while no-store is an explicit request not to keep a copy.
immutable: a year-long lifetime
Same as max-age in this table. RFC 8246 defines the directive so that “Clients SHOULD NOT issue a conditional request during the response’s freshness lifetime (e.g., upon a reload) unless explicitly overridden by the user”. MDN’s caching guide adds that Chrome took another route: “Chrome has changed its implementation so that revalidation is not performed during reloads for subresources.” The reload column shows that behavior in Edge: the stylesheet is not requested on a reload, with or without immutable. The page itself, the main resource, is still checked (304), because the lab serves it with the same header and Edge revalidates the main resource on reload regardless.
max-age-2: what happens when the lifetime runs out
The script waited three seconds before the second visit. Both files had gone stale, and Edge checked them (304/304) instead of downloading them again, which is the validator doing its job. On the following reload the stylesheet shows a dash: the check had just refreshed its lifetime, so it was fresh again.
Reload versus hard reload
The notes under the table show what Edge puts on the wire. A reload asks about the page with Cache-Control: max-age=0 and sends If-None-Match, so a 304 comes back. A hard reload sends Cache-Control: no-cache and no validator, so the origin must send everything. This is why pressing F5 is a poor test of your caching headers: it deliberately does not behave like a normal visit. Use the “second visit” column, or the Network panel with a normal link click, to see what visitors get.
How long does a browser’s guess last?
The last two lines of the output measure the guess. The origin’s lastmod-100s profile claims the file changed 100 seconds before it was first sent. A visit five seconds later made no request, and a visit 13 seconds after the first checked both files. The standard’s example fraction of 10 percent would give a lifetime of 10 seconds, which fits between those two readings. I did not search for the exact cutoff, so read this as “consistent with 10 percent of the file’s age”, not as a measurement of Edge’s formula. The practical conclusion does not depend on it: a file that last changed months ago can be reused for days or weeks with no header asking for that, so when you finally edit it, visitors who already hold a copy may keep seeing the old version for just as long. Always send an explicit Cache-Control.
Step 4: Choose a header for each kind of response and make it the default
Now that you have seen what each header does, here is a short list of recipes. They are the ones the measurements above support.
| What you are serving | Send | Why |
|---|---|---|
Fingerprinted static files (app.3f9a1c2b.css, images, fonts) |
public, max-age=31536000, immutable |
The URL changes whenever the content does, so a year is safe. On later visits browsers make no requests for the file at all (the immutable row). |
| HTML pages and any file whose URL stays the same | no-cache plus an ETag |
Stored but checked on every use. It costs one small 304 round trip, and an outdated copy is never reused without that check. |
| Personal data, or anything behind a login | private, no-store |
Shared caches must not store it, and no-store asks the browser not to keep it either. |
| Public data that may be a minute old | public, max-age=60, optionally with stale-while-revalidate |
A short lifetime keeps traffic off the origin without letting data get far out of date. Step 7 shows how the stale extensions behave. |
| Everything you forgot | no-cache as the default |
A default of “check first” is safe. A default of “nothing” lets browsers guess. |
The last row is the one people skip, and it is where a framework helps. This middleware gives every response that does not set its own Cache-Control a default chosen by URL prefix. The first matching prefix wins, so the narrow rules go first. Save it as cache_policy.py.
# cache_policy.py
"""A default Cache-Control for every FastAPI response that does not set its own, chosen by URL prefix.
The first prefix that matches wins, so the narrow rules go first. Routes that need something else set their own header.
"""
from fastapi import FastAPI, Request
DEFAULTS = [
("/static/", "public, max-age=31536000, immutable"), # only safe because file names carry a hash of their content
("/api/me", "private, no-store"), # personal data: not in shared caches, not in the browser's cache either
("/api/", "no-cache"), # store it, but ask the origin before each reuse
]
FALLBACK = "no-cache"
def add_cache_policy(app: FastAPI) -> None:
@app.middleware("http")
async def cache_policy(request: Request, call_next):
response = await call_next(request)
if "cache-control" not in response.headers:
path = request.url.path
response.headers["Cache-Control"] = next((value for prefix, value in DEFAULTS if path.startswith(prefix)), FALLBACK)
return response
Routes that need something else simply set their own header and the middleware leaves it alone. The one-year rule for /static/ is only safe if the file names change when the contents do, so here is the other half: a function that copies a file to a name containing a hash of its content. Run it as the last step of your build, and write the new name into your HTML. Save it as fingerprint.py.
# fingerprint.py
"""Copy a file to a name that contains a hash of its content: app.css becomes app.3f9a1c2b.css."""
import hashlib
import shutil
from pathlib import Path
def fingerprint(source: Path, out_dir: Path) -> str:
digest = hashlib.sha256(source.read_bytes()).hexdigest()[:8]
target = out_dir / f"{source.stem}.{digest}{source.suffix}"
shutil.copyfile(source, target)
return target.name
Both pieces come with tests. The tests build a small FastAPI app, add the middleware, and check the header each kind of route receives, including a route that sets its own header and a URL that does not exist (an error page can be cached too). Save this as test_cache_policy.py and run it.
# test_cache_policy.py
import re
import pytest
from fastapi import FastAPI, Response
from fastapi.testclient import TestClient
from cache_policy import add_cache_policy
from fingerprint import fingerprint
app = FastAPI()
add_cache_policy(app)
@app.get("/static/app.3f9a1c2b.css")
def asset():
return Response("h1 { color: red }", media_type="text/css")
@app.get("/api/me")
def me():
return {"user": "alice"}
@app.get("/api/orders")
def orders():
return [1, 2, 3]
@app.get("/api/prices")
def prices(response: Response):
response.headers["Cache-Control"] = "public, max-age=60" # this route chooses for itself
return {"coffee": 3}
@app.get("/")
def home():
return Response("<h1>hi</h1>", media_type="text/html")
client = TestClient(app)
@pytest.mark.parametrize("path, expected", [
("/static/app.3f9a1c2b.css", "public, max-age=31536000, immutable"),
("/api/me", "private, no-store"),
("/api/orders", "no-cache"),
("/api/prices", "public, max-age=60"),
("/", "no-cache"),
("/missing", "no-cache"),
])
def test_each_route_gets_the_intended_header(path, expected):
assert client.get(path).headers["cache-control"] == expected
def test_fingerprint_changes_with_content_only(tmp_path):
source = tmp_path / "app.css"
source.write_text("h1 { color: red }")
first = fingerprint(source, tmp_path)
assert re.fullmatch(r"app\.[0-9a-f]{8}\.css", first)
assert fingerprint(source, tmp_path) == first
source.write_text("h1 { color: blue }")
assert fingerprint(source, tmp_path) != first
python run_all.py policytests
....... [100%]
7 passed in 0.22s
Seven passing tests mean the defaults behave as the table says. The fingerprint test also checks the reverse: the same content always gets the same name, and one changed character gets a new one.
Step 5: Put nginx in front and see what a shared cache keeps
A browser cache serves one person, so its mistakes are mostly about stale files. A shared cache serves everyone, so its mistakes can be about the wrong person. nginx can act as one with its proxy_cache feature, and it is a good lab animal because its rules are simple and documented. Save this configuration as nginx.conf.
# nginx.conf
worker_processes 1;
error_log logs/error.log warn;
pid logs/nginx.pid;
events { worker_connections 64; }
http {
access_log off;
proxy_cache_path cache keys_zone=lab:1m max_size=10m inactive=10m use_temp_path=off;
server {
listen 127.0.0.1:8181;
add_header X-Cache-Status $upstream_cache_status always;
# 1. the default: obey whatever Cache-Control, Expires, Set-Cookie and Vary the origin sends
location / {
proxy_pass http://127.0.0.1:8100;
proxy_cache lab;
}
# 2. a shortcut that is easy to copy: ignore the origin and keep every 200 for 60 seconds
location /leaky/ {
proxy_pass http://127.0.0.1:8100/;
proxy_cache lab;
proxy_ignore_headers Cache-Control Expires Set-Cookie Vary;
proxy_cache_valid 200 60s;
}
# 3. the fix for personal pages: never read from or write to the cache when the request carries credentials
location /guarded/ {
proxy_pass http://127.0.0.1:8100/;
proxy_cache lab;
proxy_cache_bypass $http_authorization $cookie_user;
proxy_no_cache $http_authorization $cookie_user;
}
# 4. a second origin that the stale tests can switch off; only the Cache-Control extensions apply here
location /ext/ {
proxy_pass http://127.0.0.1:8101/;
proxy_cache lab;
}
# 5. the same origin with nginx's own stale rules turned on
location /stale/ {
proxy_pass http://127.0.0.1:8101/;
proxy_cache lab;
proxy_cache_use_stale error timeout updating;
proxy_cache_background_update on;
}
}
}
The proxy_cache_path line creates a small on-disk cache named lab. The add_header line adds an X-Cache-Status header to every response, which is how you see what nginx did: MISS means it asked the origin, HIT means it answered from its own copy, and EXPIRED means its copy had run out and it asked again. Only the first location block is used in this step. It is the default setup, and it simply obeys the origin. The other four appear in Steps 6 and 7.
The next script requests each profile three times through nginx on port 8181 and reports the status of each answer and how many requests the origin saw. Save it as proxy_lab.py and run it.
# proxy_lab.py
"""What does a shared cache (nginx 1.30.6 with proxy_cache) do with each set of caching headers?
Three requests per profile through the proxy. MISS means nginx asked the origin; HIT means it did not.
"""
import time
import uuid
import requests
from labkit import ORIGIN, PROXY, drain_hits
NAMES = ["bare", "lastmod", "max-age", "typo", "no-cache", "no-store", "must-revalidate", "immutable",
"private", "s-maxage", "set-cookie", "vary-star"]
def three_requests(name: str) -> tuple[list[str], requests.Response, int]:
key = uuid.uuid4().hex[:8] # a new cache key per run, so an old entry cannot answer
drain_hits()
statuses = []
for _ in range(3):
response = requests.get(f"{PROXY}/item/{name}?k={key}", timeout=10)
statuses.append(response.headers.get("X-Cache-Status", "?"))
return statuses, response, len(drain_hits())
print(f"{'profile':17}{'origin sends':58}{'nginx, 3 requests':20}{'origin saw'}")
for name in NAMES:
statuses, response, seen = three_requests(name)
sent = "; ".join(f"{h}: {response.headers[h]}" for h in ("Cache-Control", "Vary", "Set-Cookie") if h in response.headers)
print(f"{name:17}{sent or '(no caching headers)':58}{' '.join(statuses):20}{seen}")
# a short lifetime: how nginx treats an entry once it has expired
key = uuid.uuid4().hex[:8]
drain_hits()
steps = []
for pause in (0, 0, 3, 0):
time.sleep(pause)
steps.append(requests.get(f"{PROXY}/item/max-age-2?k={key}", timeout=10).headers["X-Cache-Status"])
print(f"\nmax-age=2 through nginx: {steps[0]}, {steps[1]}, (wait 3 s), {steps[2]}, {steps[3]}; origin saw {len(drain_hits())} requests")
python run_all.py proxy
profile origin sends nginx, 3 requests origin saw
bare (no caching headers) MISS MISS MISS 3
lastmod (no caching headers) MISS MISS MISS 3
max-age Cache-Control: max-age=60 MISS HIT HIT 1
typo Cache-Control: max-age: 60 MISS MISS MISS 3
no-cache Cache-Control: no-cache MISS MISS MISS 3
no-store Cache-Control: no-store MISS MISS MISS 3
must-revalidate Cache-Control: max-age=0, must-revalidate MISS MISS MISS 3
immutable Cache-Control: public, max-age=31536000, immutable MISS HIT HIT 1
private Cache-Control: private, max-age=60 MISS MISS MISS 3
s-maxage Cache-Control: max-age=0, s-maxage=30 MISS HIT HIT 1
set-cookie Cache-Control: max-age=60; Set-Cookie: seen=1 MISS MISS MISS 3
vary-star Cache-Control: max-age=60; Vary: * MISS MISS MISS 3
max-age=2 through nginx: MISS, HIT, (wait 3 s), EXPIRED, HIT; origin saw 2 requests
Reading nginx’s answers
Where a row says MISS HIT HIT, the origin saw one request and nginx answered the other two itself. That happened for max-age=60, for the year-long immutable profile, and for max-age=0, s-maxage=30. The last one is the s-maxage rule at work: a browser would ask every time, while nginx kept the response for 30 seconds. Everything else was MISS MISS MISS, and the reasons are instructive.
The nginx documentation says it reads X-Accel-Expires first and, if that is absent, Expires or Cache-Control. It says that if the response includes a Set-Cookie field, “such a response will not be cached”, and the same for a Vary: * field. A Vary field with any other value makes nginx cache the response “taking into account the corresponding request header fields”. The source agrees: in src/http/ngx_http_upstream.c of release 1.30.6 the function that reads Cache-Control looks for the text no-cache, no-store and private and stops caching if it finds one; otherwise it looks for s-maxage=, then max-age=; and it reads stale-while-revalidate= and stale-if-error= separately. Put together with the table:
no-cache,no-store,privateandmax-age=0are never stored. Unlike a browser, nginx does not keep ano-cacheresponse and validate it later; it simply does not cache it.typo(max-age: 60) contains nomax-age=, so nginx found no lifetime.bareandlastmodhave no lifetime. The row forlastmodis the sharpest difference from Step 3: Edge guessed a lifetime from theLast-Modifieddate, and nginx did not.set-cookieandvary-starare refused by the rules the documentation gives.
The last line of the output shows expiry: a response with max-age=2 was stored (MISS, then HIT), and after three seconds nginx reported EXPIRED, went back to the origin once, and then served HIT again. The origin saw two requests in total.
Step 6: Reproduce a cache leak, then fix it
Everything so far was about efficiency. This step is about safety. Before a shared cache reuses a stored response it has to decide whether the new request is “the same” as the one that created it, and it decides by comparing a cache key. The nginx documentation gives the default as proxy_cache_key $scheme$proxy_host$request_uri: the scheme, the upstream host and the URL. The key does not contain cookies, an Authorization header or the visitor’s language. The only way a request header can enter the decision is if the origin names it in a Vary header, and RFC 9111 spells out what that obliges a cache to do: it “MUST NOT use that stored response without revalidation unless all the presented request header fields nominated by that Vary field value match those fields in the original request”.
The script below runs seven scenarios through nginx with two made-up visitors, Alice and Bob. Nothing in it touches a real account; the “users” are cookie and token values that the lab origin simply echoes back. Save it as leak_lab.py and run it.
# leak_lab.py
"""When a shared cache serves the wrong person: a missing Vary, a personal page marked cacheable, an Authorization header,
and a proxy that was told to ignore the origin. The visitors are made up (alice and bob)."""
import uuid
import requests
from labkit import ORIGIN, PROXY
requests.get(f"{ORIGIN}/__reset", timeout=5) # start the origin's response numbers at 1
def visit(prefix: str, name: str, key: str, label: str, **headers: str) -> None:
response = requests.get(f"{PROXY}{prefix}/item/{name}?k={key}", headers=headers, timeout=10)
print(f" {label:22}{response.headers.get('X-Cache-Status', '?'):8}{response.text.strip()}")
def scenario(title: str, prefix: str, name: str, visits: list[tuple[str, dict]]) -> None:
print(title)
key = uuid.uuid4().hex[:8]
for label, headers in visits:
visit(prefix, name, key, label, **headers)
print()
english, french = {"Accept-Language": "en"}, {"Accept-Language": "fr"}
alice_cookie, bob_cookie = {"Cookie": "user=alice"}, {"Cookie": "user=bob"}
alice_token, bob_token = {"Authorization": "Bearer alice"}, {"Authorization": "Bearer bob"}
scenario("A. language chosen from Accept-Language, no Vary header (max-age=60)", "", "novary-lang",
[("visitor 1 asks for en", english), ("visitor 2 asks for fr", french)])
scenario("B. the same page with Vary: Accept-Language", "", "vary-lang",
[("visitor 1 asks for en", english), ("visitor 2 asks for fr", french), ("visitor 2 again", french), ("visitor 1 again", english)])
scenario("C. a personal page sent with max-age=60, identified by cookie", "", "me-public",
[("alice (cookie)", alice_cookie), ("bob (cookie)", bob_cookie)])
scenario("D. the same page identified by an Authorization header", "", "me-public",
[("alice (bearer token)", alice_token), ("bob (bearer token)", bob_token)])
scenario("E. the same page sent with Cache-Control: private, no-store", "", "me-private",
[("alice (cookie)", alice_cookie), ("bob (cookie)", bob_cookie)])
scenario("F. private, no-store again, but through the proxy location that ignores the origin", "/leaky", "me-private",
[("alice (cookie)", alice_cookie), ("bob (cookie)", bob_cookie)])
scenario("G. the max-age=60 personal page again, through the guarded location", "/guarded", "me-public",
[("alice (cookie)", alice_cookie), ("bob (bearer token)", bob_token), ("anonymous", {}), ("anonymous again", {})])
python run_all.py leak
Scenarios A and B: a missing Vary header serves the wrong language
A. language chosen from Accept-Language, no Vary header (max-age=60)
visitor 1 asks for en MISS Hello anonymous (origin response number 1)
visitor 2 asks for fr HIT Hello anonymous (origin response number 1)
B. the same page with Vary: Accept-Language
visitor 1 asks for en MISS Hello anonymous (origin response number 2)
visitor 2 asks for fr MISS Bonjour anonymous (origin response number 3)
visitor 2 again HIT Bonjour anonymous (origin response number 3)
visitor 1 again HIT Hello anonymous (origin response number 2)
In A, the origin chooses the greeting from the Accept-Language header but does not say so in a Vary header. Visitor 1 asked for English and nginx stored the answer. Visitor 2 asked for French and received the English copy as a HIT, with the origin response number 1 repeated. Scenario B adds Vary: Accept-Language and the picture changes: nginx stored a separate copy for each language (origin responses 2 and 3), and each visitor got the right language on a repeat visit. The wrong language is a mild symptom. The same mechanism, a response that depends on who asked but is stored as if it did not, causes the next two scenarios.
Scenarios C and D: a personal page marked cacheable goes to the wrong person
C. a personal page sent with max-age=60, identified by cookie
alice (cookie) MISS Hello alice (origin response number 4)
bob (cookie) HIT Hello alice (origin response number 4)
D. the same page identified by an Authorization header
alice (bearer token) MISS Hello alice (origin response number 5)
bob (bearer token) HIT Hello alice (origin response number 5)
Here the page differs by visitor, identified by a cookie in C and by a bearer token in D, but the origin sends only max-age=60. Alice’s request was a MISS and nginx stored her page. Bob’s request was a HIT and he received “Hello alice”. Nothing in the key or the headers told nginx the two requests were different people. MDN describes this failure in its documentation of private, quoted earlier: the response “can be stored in a shared cache and end up being reused for multiple users”.
Scenario D deserves a closer look because the standard has a rule for it. RFC 9111 says “A shared cache MUST NOT use a cached response to a request with an Authorization header field” to answer later requests unless the response carries a directive that allows shared storage, and it names three that qualify: must-revalidate, public and s-maxage. The response here carried none of them, yet nginx 1.30.6 served Alice’s copy to Bob. I only tested one version and one configuration, so I make no claim beyond this: do not rely on a cache to notice credentials for you.
Scenarios E and F: private works until the proxy is told to ignore it
E. the same page sent with Cache-Control: private, no-store
alice (cookie) MISS Hello alice (origin response number 6)
bob (cookie) MISS Hello bob (origin response number 7)
F. private, no-store again, but through the proxy location that ignores the origin
alice (cookie) MISS Hello alice (origin response number 8)
bob (cookie) HIT Hello alice (origin response number 8)
In E the origin sends private, no-store and nginx stores nothing, so Alice and Bob each get their own page (origin responses 6 and 7). That is the fix at the origin, and it is the one to rely on. Scenario F goes through the second location block of nginx.conf, which is a shortcut some people add to force caching whatever the origin says: proxy_ignore_headers Cache-Control Expires Set-Cookie Vary; followed by proxy_cache_valid 200 60s;. The nginx documentation describes the first directive as one that “Disables processing of certain response header fields from the proxied server.” With the origin’s headers ignored, the same private, no-store response was stored and Bob received Alice’s page again. A header is a request to the cache. The proxy’s configuration has the last word.
Scenario G: a safety net on the proxy
G. the max-age=60 personal page again, through the guarded location
alice (cookie) BYPASS Hello alice (origin response number 9)
bob (bearer token) BYPASS Hello bob (origin response number 10)
anonymous MISS Hello anonymous (origin response number 11)
anonymous again HIT Hello anonymous (origin response number 11)
The third location block of nginx.conf uses two directives that the nginx documentation shows with an Authorization example. proxy_cache_bypass “Defines conditions under which the response will not be taken from a cache”, and proxy_no_cache “Defines conditions under which the response will not be saved to a cache.” Each takes variables and acts if any of them is non-empty. The lab uses $http_authorization (the Authorization header) and $cookie_user (a cookie called user; use the name of your own session cookie). In the output, Alice and Bob show BYPASS and each receives their own page even though the origin said max-age=60, while anonymous visitors are still cached: MISS for the first and HIT for the repeat. This is a backstop for the day someone forgets private. It does not replace sending the right header.
What to do on your own sites
- Mark every personal response
private, and addno-storeif it is sensitive. Do it in the application, so it holds no matter which cache sits in front. - Add a
Varyheader for every request header that changes the response body, such asAccept-LanguageorAccept-Encoding, and keep the list short.Vary: CookieandVary: User-Agentare poor leak fixes: they store one copy per visitor or per browser build, which defeats the point of a shared cache, and Step 8 flags them. Useprivateinstead. - At the proxy, bypass the cache for requests that carry credentials, as in scenario G.
- Do not use
proxy_ignore_headersforCache-Control,Set-CookieorVaryunless you control every route behind that location. - Test with two users, as the script does. A single-user test can never show a leak.
Step 7: Serve stale content on purpose with stale-while-revalidate and stale-if-error
Sometimes a slightly old answer is better than a slow or failed one. RFC 5861 defines two extensions for that. “The stale-while-revalidate HTTP Cache-Control extension allows a cache to immediately return a stale response while it revalidates it in the background, thereby hiding latency (both in the network and on the server) from clients.” The stale-if-error extension allows a cache to return a stale response when an error occurs, such as a server error, a network failure or a DNS failure, instead of returning a hard error. They only make sense for data that is allowed to be a little out of date, so they do not belong on personal or time-critical responses.
nginx supports both in two ways. It reads them from the origin’s Cache-Control header, where its documentation says the stale-while-revalidate extension “permits using a stale cached response if it is currently being updated” and the stale-if-error extension “permits using a stale cached response in case of an error”. And it has directives of its own: proxy_cache_use_stale chooses when a stale response may be used, and proxy_cache_background_update on “Allows starting a background subrequest to update an expired cache item, while a stale cached response is returned to the client.” The documentation adds that for this to work, “it is necessary to allow the usage of a stale cached response when it is being updated.”
The next script tests both setups. It starts a second origin on port 8101 so it can switch the origin off, makes the origin take one second to answer, and sends the same request through nginx at carefully chosen moments. The first location it uses, /ext/, has no stale rules in nginx at all, so only the origin’s header can matter. The second, /stale/, turns on proxy_cache_use_stale error timeout updating and proxy_cache_background_update on. The origin sends max-age=2, stale-while-revalidate=30, stale-if-error=60. Save it as stale_lab.py and run it.
# stale_lab.py
"""stale-while-revalidate and stale-if-error through nginx.
A second origin on port 8101 lets this script switch the origin off. /ext/ has no stale rules of its own, so only the
Cache-Control extensions can matter; /stale/ turns nginx's own rules on. The origin takes 1 second to answer.
"""
import socket
import threading
import time
import uuid
import requests
import labkit
from labkit import PROXY
def timed(prefix: str, key: str) -> tuple[str, str, float]:
started = time.perf_counter()
response = requests.get(f"{PROXY}{prefix}/item/swr?k={key}&delay=1", timeout=15)
return response.headers.get("X-Cache-Status", "?"), response.text.strip().split("number ")[-1].rstrip(")"), time.perf_counter() - started
def show(label: str, result: tuple[str, str, float]) -> None:
print(f" {label:38}{result[0]:10}origin response {result[1]:>3} {result[2]:.2f} s")
origin2 = labkit.start_origin(8101)
try:
for prefix, title in (("/ext", "no stale rules in nginx; the origin sends max-age=2, stale-while-revalidate=30, stale-if-error=60"),
("/stale", "proxy_cache_use_stale error timeout updating; proxy_cache_background_update on")):
print(f"{prefix}: {title}")
key = uuid.uuid4().hex[:8]
show("1. first request", timed(prefix, key))
show("2. immediately again", timed(prefix, key))
time.sleep(3)
if prefix == "/ext":
first = {}
worker = threading.Thread(target=lambda: first.update(result=timed(prefix, key)))
worker.start() # the first request after expiry starts the refresh
time.sleep(0.3)
show("3b. a second request, 0.3 s into it", timed(prefix, key))
worker.join()
show("3a. (the first one, which waited)", first["result"])
else:
show("3. after the entry expired", timed(prefix, key))
show("4. right after that", timed(prefix, key))
time.sleep(1.5)
show("5. 1.5 s later", timed(prefix, key))
time.sleep(3)
labkit.kill(origin2)
started = time.perf_counter()
try:
socket.create_connection(("127.0.0.1", 8101), timeout=5).close()
except OSError:
print(f" (for scale: a plain connection to the closed port fails after {time.perf_counter() - started:.2f} s)")
try:
show("6. origin switched off", timed(prefix, key))
except requests.RequestException as error:
print(f" 6. origin switched off {type(error).__name__}")
origin2 = labkit.start_origin(8101)
print()
finally:
labkit.kill(origin2)
python run_all.py stale
/ext: no stale rules in nginx; the origin sends max-age=2, stale-while-revalidate=30, stale-if-error=60
1. first request MISS origin response 1 1.03 s
2. immediately again HIT origin response 1 0.01 s
3b. a second request, 0.3 s into it UPDATING origin response 1 0.01 s
3a. (the first one, which waited) EXPIRED origin response 2 1.04 s
4. right after that HIT origin response 2 0.02 s
5. 1.5 s later HIT origin response 2 0.01 s
(for scale: a plain connection to the closed port fails after 2.07 s)
6. origin switched off STALE origin response 2 2.07 s
/stale: proxy_cache_use_stale error timeout updating; proxy_cache_background_update on
1. first request MISS origin response 1 1.04 s
2. immediately again HIT origin response 1 0.01 s
3. after the entry expired STALE origin response 1 0.03 s
4. right after that UPDATING origin response 1 0.02 s
5. 1.5 s later HIT origin response 2 0.01 s
(for scale: a plain connection to the closed port fails after 2.06 s)
6. origin switched off STALE origin response 2 0.01 s
Reading the timings
With only the header (/ext), a request after the entry expired still has to wait. Request 3a was the first one after the three-second pause, and nginx reported EXPIRED and waited the full second for the origin to produce response number 2. But request 3b, which arrived 0.3 seconds into that refresh, got UPDATING: the old response, number 1, in 0.01 seconds. That matches the documentation’s wording, “if it is currently being updated”. Later requests were normal hits on the new copy. With the origin switched off (request 6), nginx answered STALE with the last copy it had instead of an error. It took 2.07 seconds, which is the time a plain connection to the closed port needed on this Windows machine, as the line above it shows.
With nginx’s own directives (/stale) the first request after expiry no longer waits: it got STALE, the old copy, in 0.03 seconds while nginx refreshed in the background. A request a moment later still got the old copy (UPDATING), and one and a half seconds later the entry had been replaced (HIT, response number 2). With the origin off, the visitor got STALE in 0.01 seconds because nginx no longer waits for the failed connection before answering.
For a public page or API that tolerates being a few seconds old, the lab’s settings are a sensible starting point: proxy_cache_use_stale error timeout updating; proxy_cache_background_update on; in nginx, and a Cache-Control header such as public, max-age=60, stale-while-revalidate=30, stale-if-error=3600 from the origin. I tested the pattern with small numbers so it would finish quickly; choose the windows to fit how old your data may be.
Step 8: Audit the caching headers of any URL
The experiments gave you a mental model. The last tool turns it into a check you can run against any response. cachecheck.py reads the headers of one URL and prints how long a browser and a shared cache may reuse the response, following the order of RFC 9111: s-maxage for shared caches, then max-age, then Expires, then (for browsers only, like Edge in Step 3) a guess from Last-Modified. It then flags the mistakes you saw in this tutorial. It reads only the response headers you give it, so it does not model the Age header, request directives, or what a particular CDN overrides. Save it as cachecheck.py.
# cachecheck.py
"""Read the caching headers of one response and say who may keep it, for how long, and what looks wrong.
python cachecheck.py URL [--auth] fetch URL and report; --auth sends an Authorization header like a logged-in client
"""
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
DAY = 86400
TOKEN = r"[!#$%&'*+.^_`|~0-9A-Za-z-]+"
SECONDS_DIRECTIVES = ("max-age", "s-maxage", "stale-while-revalidate", "stale-if-error")
FINGERPRINT = re.compile(r"[.\-_][0-9a-f]{8,}\.[a-z0-9]+$|[?&](v|ver|version|hash)=")
@dataclass
class Report:
browser: int | None # seconds a browser may reuse the response unchecked; None when it may not store it at all
shared: int | None # the same for a CDN or a proxy such as nginx
findings: list[tuple[str, str]] = field(default_factory=list) # (level, message)
def parse_cache_control(value: str | None) -> tuple[dict[str, str | None], list[str]]:
"""Split a Cache-Control value into directives. The first copy of a repeated directive wins."""
directives: dict[str, str | None] = {}
problems: list[str] = []
for part in re.split(r',(?=(?:[^"]*"[^"]*")*[^"]*$)', value or ""):
part = part.strip()
if not part:
continue
match = re.fullmatch(rf"({TOKEN})(?:=(.*))?", part)
if not match:
problems.append(f"cannot read {part!r}: write name or name=value, and separate directives with commas")
continue
name, argument = match.group(1).lower(), match.group(2)
if name in SECONDS_DIRECTIVES and not re.fullmatch(r"[0-9]+", argument or ""):
problems.append(f"{name} needs a whole number of seconds, got {argument!r}")
continue
directives.setdefault(name, argument)
return directives, problems
def lifetime(h: dict[str, str], max_age: int | None, guess: bool) -> int:
"""Seconds of freshness in the order of RFC 9111 section 4.2.1: max-age, then Expires, then (browsers only) a guess."""
if max_age is not None:
return max_age
date = parsedate_to_datetime(h["date"]) if "date" in h else datetime.now(timezone.utc)
if "expires" in h:
try:
return max(0, int((parsedate_to_datetime(h["expires"]) - date).total_seconds()))
except (TypeError, ValueError):
return 0 # an invalid date such as "0" means already expired
if guess and "last-modified" in h:
return max(0, int((date - parsedate_to_datetime(h["last-modified"])).total_seconds() // 10))
return 0
def analyze(headers: dict[str, str], url: str = "", authorized: bool = False) -> Report:
h = {name.lower(): value for name, value in headers.items()}
d, problems = parse_cache_control(h.get("cache-control"))
out = [("error", problem) for problem in problems]
max_age = int(d["max-age"]) if "max-age" in d else None
s_maxage = int(d["s-maxage"]) if "s-maxage" in d else None
validators = [name for name in ("etag", "last-modified") if name in h]
def plain(name: str) -> bool:
"""True for the plain form of a directive; a qualified form such as no-cache="Set-Cookie" only names header fields."""
return name in d and d[name] is None
if "no-store" in d:
browser = shared = None
ignored = sorted(set(d) & {"max-age", "s-maxage", "public", "immutable", "stale-while-revalidate"})
if ignored:
out.append(("warn", f"no-store wins, so {', '.join(ignored)} do nothing"))
else:
browser = lifetime(h, max_age, guess=True)
shared = None if plain("private") else (s_maxage if s_maxage is not None else lifetime(h, max_age, guess=False))
if plain("no-cache"):
browser = 0
shared = None if shared is None else 0
if plain("private") and ("public" in d or s_maxage is not None):
out.append(("warn", "private says shared caches must not keep it; public or s-maxage says they may. Remove one"))
if browser == 0 and not validators:
out.append(("warn", "every request needs the full body again: add an ETag or Last-Modified so a 304 can replace it"))
if max_age is None and s_maxage is None and "expires" not in h and not plain("no-cache"):
if "last-modified" in h:
out.append(("warn", f"no lifetime is given, so a browser will guess {browser} s (10 percent of the file's age) "
"and a proxy such as nginx will not store it"))
else:
out.append(("info", "nothing says how to cache this: every request goes to the origin"))
longest = max(max_age or 0, s_maxage or 0)
if longest >= DAY and url and not FINGERPRINT.search(url):
out.append(("warn", f"a {longest // DAY}-day lifetime on a URL that does not change when the file does: "
"visitors keep the old copy until it expires"))
if "immutable" in d and (max_age or 0) < DAY:
out.append(("warn", "immutable is only useful with a long max-age (a year is typical)"))
if shared and (authorized or "set-cookie" in h):
out.append(("error" if authorized else "warn", "this may be a personal response but a shared cache may keep it: add private or no-store"))
vary = {item.strip().lower() for item in h.get("vary", "").split(",") if item.strip()}
if "*" in vary:
out.append(("warn", "Vary: * means no shared cache can reuse a stored response"))
elif vary & {"cookie", "user-agent"}:
out.append(("warn", "Vary on Cookie or User-Agent stores one copy per visitor or per browser build, so there is little to reuse"))
if "no-cache" in h.get("pragma", "").lower() and "cache-control" not in h:
out.append(("warn", "Pragma is not a reliable response header; send Cache-Control: no-cache instead"))
return Report(browser, shared, out)
def human(seconds: int | None) -> str:
if seconds is None:
return "never (it may not store it)"
return "0 s (it must ask the origin first)" if seconds == 0 else f"{seconds} s"
def main(argv: list[str]) -> int:
url = argv[1]
request_headers = {"Authorization": "Bearer demo"} if "--auth" in argv else {}
response = requests.get(url, headers=request_headers, timeout=10)
report = analyze(dict(response.headers), url=url, authorized=bool(request_headers))
print(f"GET {url} -> {response.status_code}")
print(f" a browser may reuse it for: {human(report.browser)}")
print(f" a shared cache may reuse it for: {human(report.shared)}")
for level, message in report.findings:
print(f" {level:5} {message}")
return 1 if any(level == "error" for level, _ in report.findings) else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
Three functions do the work. parse_cache_control splits the header on commas that are not inside quotes, keeps the first copy of a repeated directive, and reports anything it cannot read, including a seconds directive whose value is not a whole number. lifetime follows the freshness order above. analyze combines them into a Report with a browser lifetime, a shared-cache lifetime (None meaning “may not store it”) and a list of findings at three levels: error for things that can leak or break, warn for things that probably are not what you meant, and info for notes. Run it against the lab origin with the audit step. The last two URLs are requested with --auth, which sends a dummy Authorization header the way a logged-in client would.
python run_all.py audit
GET http://127.0.0.1:8100/css/typo.css -> 200
a browser may reuse it for: 0 s (it must ask the origin first)
a shared cache may reuse it for: 0 s (it must ask the origin first)
error cannot read 'max-age: 60': write name or name=value, and separate directives with commas
info nothing says how to cache this: every request goes to the origin
GET http://127.0.0.1:8100/css/lastmod.css -> 200
a browser may reuse it for: 86399 s
a shared cache may reuse it for: 0 s (it must ask the origin first)
warn no lifetime is given, so a browser will guess 86399 s (10 percent of the file's age) and a proxy such as nginx will not store it
GET http://127.0.0.1:8100/css/immutable.css -> 200
a browser may reuse it for: 31536000 s
a shared cache may reuse it for: 31536000 s
warn a 365-day lifetime on a URL that does not change when the file does: visitors keep the old copy until it expires
GET http://127.0.0.1:8100/css/no-cache.css -> 200
a browser may reuse it for: 0 s (it must ask the origin first)
a shared cache may reuse it for: 0 s (it must ask the origin first)
GET http://127.0.0.1:8100/item/set-cookie -> 200
a browser may reuse it for: 60 s
a shared cache may reuse it for: 60 s
warn this may be a personal response but a shared cache may keep it: add private or no-store
GET http://127.0.0.1:8100/item/vary-star -> 200
a browser may reuse it for: 60 s
a shared cache may reuse it for: 60 s
warn Vary: * means no shared cache can reuse a stored response
GET http://127.0.0.1:8100/item/me-public -> 200
a browser may reuse it for: 60 s
a shared cache may reuse it for: 60 s
error this may be a personal response but a shared cache may keep it: add private or no-store
GET http://127.0.0.1:8100/item/me-private -> 200
a browser may reuse it for: never (it may not store it)
a shared cache may reuse it for: never (it may not store it)
Each result ties back to a step. typo.css gets an error because max-age: 60 cannot be read, and the tool explains the grammar, which is the problem Edge hid from you in Step 3. lastmod.css shows the browser’s guess, about 86,400 seconds (a tenth of ten days, plus however long the origin has been running), against zero for a shared cache, and says why. immutable.css is flagged because a 365-day lifetime on a URL like /css/immutable.css is only safe when the URL changes with the content, and this one does not; the same header on app.3f9a1c2b.css would pass. no-cache.css has no findings, because it has an ETag and asks to be validated. set-cookie and vary-star are the two nginx refused in Step 5. me-public with --auth is an error because a shared cache may keep a response to an authenticated request, which is scenario D, while me-private is clean. To check your own site, run python cachecheck.py https://your-site.example/page, and add --auth for any route that serves personal data. The exit code is 1 when there is an error, so it can also gate a build.
Tests, and a check that the tests would notice a bug
A checker is only useful if you can trust it. These tests pin down each rule, including the edge cases that matter: a comma inside a quoted value, an invalid Expires date, the qualified forms of private and no-cache, header names in any letter case, and the difference between what browsers and proxies do without a lifetime. Save them as test_cachecheck.py.
# test_cachecheck.py
import pytest
from cachecheck import analyze, parse_cache_control
DATE = "Mon, 09 Nov 2026 12:00:00 GMT"
def messages(report, level=None):
return [text for found, text in report.findings if level in (None, found)]
def has(report, fragment):
return any(fragment in text for text in messages(report))
def test_parse_directives_and_arguments():
assert parse_cache_control("public, max-age=60") == ({"public": None, "max-age": "60"}, [])
def test_parse_nothing():
assert parse_cache_control(None) == ({}, [])
assert parse_cache_control("") == ({}, [])
def test_colon_is_not_an_equals_sign():
directives, problems = parse_cache_control("max-age: 60")
assert directives == {} and "cannot read" in problems[0]
def test_max_age_must_be_whole_seconds():
directives, problems = parse_cache_control("max-age=1h")
assert directives == {} and "whole number" in problems[0]
def test_first_copy_of_a_repeated_directive_wins():
assert parse_cache_control("max-age=10, max-age=99")[0]["max-age"] == "10"
def test_a_comma_inside_quotes_does_not_split():
directives, problems = parse_cache_control('private="a,b", max-age=5')
assert directives == {"private": '"a,b"', "max-age": "5"} and problems == []
def test_names_are_case_insensitive():
report = analyze({"CACHE-CONTROL": "MAX-AGE=5", "Date": DATE, "ETag": '"x"'})
assert report.browser == 5 and report.shared == 5
def test_max_age_applies_to_both():
report = analyze({"Cache-Control": "max-age=60", "Date": DATE, "ETag": '"x"'})
assert (report.browser, report.shared) == (60, 60)
def test_s_maxage_overrides_for_shared_caches_only():
report = analyze({"Cache-Control": "max-age=0, s-maxage=30", "Date": DATE, "ETag": '"x"'})
assert (report.browser, report.shared) == (0, 30)
def test_private_is_not_stored_by_shared_caches():
report = analyze({"Cache-Control": "private, max-age=60", "Date": DATE, "ETag": '"x"'})
assert (report.browser, report.shared) == (60, None)
def test_no_store_means_not_stored_anywhere():
report = analyze({"Cache-Control": "no-store", "Date": DATE})
assert (report.browser, report.shared) == (None, None)
assert not has(report, "full body")
def test_no_store_beats_the_other_directives():
assert has(analyze({"Cache-Control": "no-store, max-age=60", "Date": DATE}), "no-store wins")
def test_no_cache_stores_but_always_asks():
report = analyze({"Cache-Control": "no-cache", "ETag": '"x"', "Date": DATE})
assert (report.browser, report.shared) == (0, 0)
assert not has(report, "full body")
def test_revalidation_without_a_validator_repeats_the_whole_body():
assert has(analyze({"Cache-Control": "no-cache", "Date": DATE}), "full body")
assert has(analyze({"Cache-Control": "max-age=0", "Date": DATE}), "full body")
def test_expires_is_measured_from_date():
report = analyze({"Expires": "Mon, 09 Nov 2026 12:05:00 GMT", "Date": DATE, "ETag": '"x"'})
assert report.browser == 300 and report.shared == 300
def test_an_invalid_expires_means_already_expired():
assert analyze({"Expires": "0", "Date": DATE, "ETag": '"x"'}).browser == 0
def test_max_age_beats_expires():
report = analyze({"Cache-Control": "max-age=10", "Expires": "Mon, 09 Nov 2026 13:00:00 GMT", "Date": DATE})
assert report.browser == 10
def test_browsers_guess_a_tenth_of_the_age_but_proxies_do_not():
report = analyze({"Last-Modified": "Mon, 09 Nov 2026 11:58:20 GMT", "Date": DATE})
assert (report.browser, report.shared) == (10, 0)
assert has(report, "will guess 10 s")
def test_no_information_at_all():
report = analyze({"Date": DATE})
assert messages(report, "info") and (report.browser, report.shared) == (0, 0)
@pytest.mark.parametrize("url, expected", [
("https://example.test/app.css", True),
("https://example.test/app.3f9a1c2b.css", False),
("https://example.test/app.css?v=3", False),
("", False),
])
def test_long_lifetime_needs_a_url_that_changes(url, expected):
report = analyze({"Cache-Control": "public, max-age=31536000, immutable", "Date": DATE}, url=url)
assert has(report, "day lifetime") is expected
def test_immutable_needs_a_long_max_age():
assert has(analyze({"Cache-Control": "max-age=60, immutable", "Date": DATE}), "immutable is only useful")
assert not has(analyze({"Cache-Control": "max-age=31536000, immutable", "Date": DATE}), "immutable is only useful")
def test_authorized_responses_must_not_be_shared():
shared = analyze({"Cache-Control": "max-age=60", "Date": DATE}, authorized=True)
assert messages(shared, "error")
private = analyze({"Cache-Control": "private, max-age=60", "Date": DATE}, authorized=True)
assert not messages(private, "error")
def test_cookies_on_a_shared_response_are_a_warning():
report = analyze({"Cache-Control": "max-age=60", "Set-Cookie": "a=1", "Date": DATE})
assert has(report, "personal") and not messages(report, "error")
@pytest.mark.parametrize("vary, expected", [("*", True), ("Cookie", True), ("User-Agent, Accept", True), ("Accept-Encoding", False)])
def test_vary(vary, expected):
report = analyze({"Cache-Control": "max-age=60", "Vary": vary, "Date": DATE})
assert has(report, "Vary") is expected
def test_pragma_is_not_a_response_header():
assert has(analyze({"Pragma": "no-cache", "Date": DATE}), "Pragma")
assert not has(analyze({"Pragma": "no-cache", "Cache-Control": "no-cache", "Date": DATE}), "Pragma")
def test_private_with_public_contradicts_itself():
assert has(analyze({"Cache-Control": "private, public, max-age=5", "Date": DATE}), "Remove one")
def test_a_qualified_private_names_header_fields_only():
report = analyze({"Cache-Control": 'private="set-cookie", max-age=60', "Date": DATE, "ETag": '"x"'})
assert report.shared == 60
def test_a_qualified_no_cache_does_not_force_revalidation():
report = analyze({"Cache-Control": 'no-cache="set-cookie", max-age=60', "Date": DATE, "ETag": '"x"'})
assert (report.browser, report.shared) == (60, 60)
python run_all.py tests
......................................... [100%]
41 passed in 0.28s
Passing tests prove little if they would also pass for broken code. So the last file plants one bug at a time in cachecheck.py and cache_policy.py (swap the precedence of Expires and max-age, ignore s-maxage, drop the Authorization check, make the guess 20 percent instead of 10, read a qualified directive as the plain one, and so on), runs the tests against each broken copy, and reports whether any test failed. A mutant that survives would mean a rule without a test. Save it as mutations.py.
# mutations.py
"""Plant one bug at a time in cachecheck.py or cache_policy.py and check that the tests notice."""
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).parent
MUTANTS = [ # (file, original text, replacement text, what the bug does)
("cachecheck.py", "directives.setdefault(name, argument)", "directives[name] = argument", "the last copy of a repeated directive wins"),
("cachecheck.py", " if max_age is not None:\n return max_age", " if max_age is not None and \"expires\" not in h:\n return max_age", "Expires beats max-age"),
("cachecheck.py", "shared = None if plain(\"private\") else (s_maxage if s_maxage is not None else lifetime(h, max_age, guess=False))",
"shared = None if plain(\"private\") else lifetime(h, max_age, guess=False)", "s-maxage is ignored"),
("cachecheck.py", "else lifetime(h, max_age, guess=False))", "else lifetime(h, max_age, guess=True))", "proxies guess a lifetime like browsers"),
("cachecheck.py", "(authorized or \"set-cookie\" in h)", "(\"set-cookie\" in h)", "the Authorization header is not checked"),
("cachecheck.py", "// 10))", "// 5))", "the guess is 20 percent instead of 10"),
("cachecheck.py", "|[?&](v|ver|version|hash)=", "", "a ?v= query string is not a fingerprint"),
("cachecheck.py", "if \"*\" in vary:", "if \"x\" in vary:", "Vary: * is not noticed"),
("cachecheck.py", "browser == 0 and not validators", "browser == 0 and validators", "the missing-validator warning is inverted"),
("cachecheck.py", " return 0 # an invalid date", " return 3600 # an invalid date", "a bad Expires date means one hour"),
("cachecheck.py", "return name in d and d[name] is None", "return name in d", "a qualified private or no-cache is read as the plain form"),
("cache_policy.py", 'FALLBACK = "no-cache"', 'FALLBACK = "public, max-age=3600"', "unmarked responses are cached for an hour"),
("cache_policy.py", ' ("/api/me", "private, no-store"), # personal data: not in shared caches, not in the browser\'s cache either\n', "", "the personal-data rule is missing"),
]
def run_tests(folder: Path) -> tuple[bool, str]:
done = subprocess.run([sys.executable, "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider", "test_cachecheck.py", "test_cache_policy.py"],
cwd=folder, capture_output=True, text=True, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "COLUMNS": "170"})
last = [line for line in done.stdout.splitlines() if line.strip()][-1]
return done.returncode == 0, last
caught = 0
for number, (name, old, new, meaning) in enumerate(MUTANTS, 1):
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
for source in HERE.glob("*.py"):
shutil.copy(source, folder / source.name)
text = (folder / name).read_text(encoding="utf-8")
assert text.count(old) == 1, f"mutant {number}: pattern found {text.count(old)} times"
(folder / name).write_text(text.replace(old, new), encoding="utf-8", newline="\n")
passed, last = run_tests(folder)
caught += not passed
print(f"mutant {number:2}: {'caught' if not passed else 'SURVIVED':8}{meaning} [{last}]")
print(f"{caught} of {len(MUTANTS)} planted bugs caught")
python run_all.py mutations
mutant 1: caught the last copy of a repeated directive wins [1 failed, 4 passed in 0.29s]
mutant 2: caught Expires beats max-age [1 failed, 16 passed in 0.30s]
mutant 3: caught s-maxage is ignored [1 failed, 8 passed in 0.29s]
mutant 4: caught proxies guess a lifetime like browsers [1 failed, 17 passed in 0.30s]
mutant 5: caught the Authorization header is not checked [1 failed, 24 passed in 0.30s]
mutant 6: caught the guess is 20 percent instead of 10 [1 failed, 17 passed in 0.30s]
mutant 7: caught a ?v= query string is not a fingerprint [1 failed, 21 passed in 0.30s]
mutant 8: caught Vary: * is not noticed [1 failed, 26 passed in 0.30s]
mutant 9: caught the missing-validator warning is inverted [1 failed, 12 passed in 0.31s]
mutant 10: caught a bad Expires date means one hour [1 failed, 15 passed in 0.31s]
mutant 11: caught a qualified private or no-cache is read as the plain form [1 failed, 32 passed in 0.30s]
mutant 12: caught unmarked responses are cached for an hour [1 failed, 38 passed in 0.32s]
mutant 13: caught the personal-data rule is missing [1 failed, 35 passed in 0.31s]
13 of 13 planted bugs caught
All thirteen planted bugs were caught: each one made at least one test fail.
Common mistakes and how to spot them
Reading no-cache as “do not cache”
The name suggests it, and Step 3 shows it is wrong: Edge stored the file and checked it on every visit. If you want nothing kept, send no-store. If you want “always fresh, but cheap”, send no-cache with an ETag. Remember that a shared cache such as nginx may simply refuse to store a no-cache response, as Step 5 showed.
A long lifetime on a URL that does not change with the content
A year-long max-age is perfect for app.3f9a1c2b.css and a trap for app.css. Once a browser has the file, you cannot recall it; visitors keep the old copy until it expires or they clear their cache. Fingerprint the file name first, as in Step 4, or use no-cache. cachecheck.py flags this.
Protecting personal pages with the login alone
A login protects a page from people who cannot reach it. It does nothing about a cache that has already stored a copy for someone who could. Send private, and bypass the cache on the proxy for requests that carry credentials.
Testing with F5
Reload sends max-age=0 and a validator for the page, and a hard reload sends no-cache. Neither behaves like a visitor who clicks a link. Test by navigating, or watch the origin’s log as this lab does.
Silent typos
A directive written with a colon, or with a unit such as max-age=1h, is not valid. The standard encourages caches to treat a non-integer max-age as stale, and the failure is silent. Put the audit script in your build so a typo fails a test instead of going live.
Confirm the whole lab works end to end
Run every step in one go. It takes a minute or two, mostly for the timed waits, plus a one-time nginx download on the first run.
python run_all.py
You should see, in order: the headers of the three responses from Step 2, the browser table from Step 3 with the same statuses, the nginx table from Step 5, the seven leak scenarios, the stale timings (the exact seconds will differ a little on your machine, the statuses should not), the audit report, 41 passing tests, and “13 of 13 planted bugs caught”. Two checks are worth doing by eye. In the browser table, the max-age and immutable rows should show dashes on the second visit. In the leak output, scenario C must show Bob receiving Alice’s page, and scenario G must not. If those hold, you have reproduced the central results.
Where to go next
- Add an
ETagto your dynamic HTML sono-cachepages can answer with a 304; the ETag and If-Match tutorial builds the validator logic. - If a cached copy expiring at the wrong moment sends a burst of traffic to your origin, read about cache stampedes and single-flight loads; nginx has a related directive,
proxy_cache_lock, that limits how many requests may refill one cache entry at a time. - Running Apache instead of nginx? The Apache reverse proxy basics post covers the proxy side.
- Browser requests that cross origins have a caching story of their own; the FastAPI CORS tutorial measures how long browsers cache a preflight answer.
- Check what your CDN does with each directive in its own documentation. This lab covered nginx 1.30.6 and Edge 157, not any CDN.








No Comment! Be the first one.