fix(web): never 304 a rewritten page — pages drop conditional validators, assets keep them

This commit is contained in:
2026-08-31 01:35:28 -04:00
parent 9518d9d5d1
commit c564e317ed
6 changed files with 742 additions and 81 deletions
+79 -17
View File
@@ -7,12 +7,27 @@ Two layers, one module:
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
— applies the caching behavior at the transport layer: the known
HTML pages (``HTML_PAGES``) and the dynamic share page
``/shared/<token>`` (phase 51) are always revalidated (``no-cache``)
and their local asset references are rewritten to carry
``/shared/<token>`` (phase 51) are always revalidated with a full
200 body (``no-cache``, never a 304) publishing no validators, and
their local asset references are rewritten to carry
``?v=<token>``; ``/assets/*`` is
served ``immutable`` for a year; everything else — all of ``/api/*``,
including the SSE chat stream — passes through byte-identical.
Why pages must never 304 (phase 54): the static file's validators
(``etag`` / ``last-modified``) describe the *file*, but the bytes the
browser receives are the *rewritten body* this process built from its
own token. A conditional request that matched those validators would
304 out of the rewrite — the browser keeps the HTML it already has,
whose ``?v=`` points at the previous commit's CSS/JS, which are cached
``immutable`` for a year. So on page paths the middleware strips
``if-none-match`` / ``if-modified-since`` from the inbound request
(before the downstream ``FileResponse`` can act on them) and drops the
outbound validators. The asymmetry is deliberate: a 304 on
``/assets/*`` is safe because the URL itself encodes the version, while
a 304 on an HTML page is never safe — the served body depends on the
process token, which the validator ignores.
The token is computed **once per process** (``functools.cache``, i.e.
``lru_cache(maxsize=None)``) — zero per-request git/file cost. It changes
when a new commit lands (git path) or the frontend tree's mtimes/sizes
@@ -40,6 +55,7 @@ import subprocess
from pathlib import Path
from fastapi import FastAPI
from starlette.datastructures import MutableHeaders
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
@@ -153,6 +169,17 @@ HTML_CACHE_CONTROL = "no-cache"
_ASSET_REF_RE = re.compile(r'((?:href|src)="(?:/)?assets/[^"?#]+)(")')
def _is_known_page(path: str) -> bool:
"""One source of truth for the known-page contract (phase 54).
True for ``HTML_PAGES`` and for the dynamic share page
``/shared/<token>`` (phase 51) by path prefix. Shared by the
inbound conditional-header strip and the outbound rewrite branch so
the two can never drift apart.
"""
return path in HTML_PAGES or path.startswith("/shared/")
def rewrite_asset_refs(html: str, token: str) -> str:
"""Append ``?v=<token>`` before the closing quote of every local
``assets/…`` ``href``/``src`` reference.
@@ -186,24 +213,33 @@ async def _read_body(response: Response) -> bytes:
def _no_cache_headers(response: Response) -> dict[str, str]:
"""Copy the original headers, drop the stale ``content-length`` (the
body size changes on rewrite), and force ``Cache-Control: no-cache``."""
body size changes on rewrite), drop the upstream validators —
``etag`` / ``last-modified`` describe the static file, not the
rewritten body, so a page response must never be revalidated against
them later (phase 54) — and force ``Cache-Control: no-cache``."""
headers = {k: v for k, v in response.headers.items()}
headers.pop("content-length", None)
for name in ("etag", "last-modified"):
headers.pop(name, None)
headers["Cache-Control"] = HTML_CACHE_CONTROL
return headers
class CachingMiddleware(BaseHTTPMiddleware):
"""Transport-layer cache busting (phase 33).
"""Transport-layer cache busting (phase 33, revalidation fix phase 54).
Touches exactly two response shapes:
* ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
(header only — the body is never read).
(header only — the body is never read; conditional requests may
still 304, which is safe because the URL carries ``?v=<token>``).
* the known HTML pages (``HTML_PAGES``) plus the dynamic share page
``/shared/<token>`` (phase 51, by path prefix) —
``Cache-Control: no-cache``, and (for ``text/html`` bodies) every
local asset reference gains ``?v=<token>``.
``/shared/<token>`` (phase 51, by path prefix) — always revalidated
with a full 200 body (no 304), publishing no validators: the inbound
``if-none-match`` / ``if-modified-since`` are stripped before the
downstream app runs, ``Cache-Control: no-cache`` is set, the outbound
``etag`` / ``last-modified`` are dropped, and (for ``text/html``
bodies) every local asset reference gains ``?v=<token>``.
Everything else — all of ``/api/*`` (including the SSE chat stream) —
passes through byte-identical: no header changes, the body stream is
@@ -213,24 +249,50 @@ class CachingMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
response = await call_next(request)
path = request.url.path
# Phase 54: on known page paths, strip the conditional request
# headers BEFORE the downstream app runs. The 304 is produced
# downstream (Starlette's ``FileResponse``/``StaticFiles`` honours
# ``If-None-Match`` / ``If-Modified-Since`` before this middleware
# sees a response), so a validator match would return a bodiless
# 304 whose body can never be rewritten to carry the current
# ``?v=<token>`` refs — the browser would keep HTML pointing at
# the previous commit's assets, immutable-cached for a year.
# ``MutableHeaders`` mutates ``request.scope["raw_headers"]`` in
# place, so the downstream app only ever sees the full 200. The
# strip is scoped to page paths only — ``/assets/*`` 304s are safe
# (the URL is versioned) and ``/api/*`` (incl. the SSE stream)
# must stay byte-identical.
if _is_known_page(path):
headers = MutableHeaders(scope=request.scope)
for name in ("if-none-match", "if-modified-since"):
if name in headers:
del headers[name]
response = await call_next(request)
if path.startswith(ASSETS_PREFIX):
response.headers["Cache-Control"] = ASSET_CACHE_CONTROL
return response
# Phase 51: the dynamic share page — ``/shared/<token>`` is a
# REAL route (not a static file) serving ``shared.html``, so it
# joins the known-page contract by path prefix: no-cache +
# ``?v=`` asset rewrite. (``/api/shared/<token>`` — the JSON
# read — starts with ``/api/`` and passes through below.)
is_known_page = path in HTML_PAGES or path.startswith("/shared/")
if not is_known_page:
if not _is_known_page(path):
# /api/* (incl. SSE), /favicon.ico, unknown paths: untouched.
return response
# Phase 54: a bodiless downstream status (204/304) on a page
# path is NEVER rewritten — starlette forbids a body on those
# statuses, and the rewrite path would build exactly that.
# Belt-and-braces: after the inbound conditional-header strip,
# StaticFiles cannot 304 these paths; a future route or proxy
# could still, so pass through with no-cache and no validators.
if response.status_code in (204, 304):
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
for name in ("etag", "last-modified"):
if name in response.headers:
del response.headers[name]
return response
content_type = response.headers.get("content-type", "")
if content_type.split(";", 1)[0].strip().lower() != "text/html":
# A page path with a non-HTML body (e.g. the 404 JSON served