fix(web): never 304 a rewritten page — pages drop conditional validators, assets keep them
This commit is contained in:
+79
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user