fix(cache): bust static asset URLs by content hash (v1.38.7) - #72
Merged
Conversation
Root cause: no static asset carried a version in its URL and the HTML that
loads them never asked the browser to revalidate. After an update the browser
kept serving the spool.js/spool.css it already had -- same URL, so it never
even asked. Nothing failed visibly: 200, no console error, just the old
behaviour. A customer tested an already-published change, saw the old code and
concluded the change did not work.
Fix, both halves:
- @app.url_defaults stamps ?v=<sha256[:8] of the file contents> on every
url_for('static', ...). Being the canonical Flask hook, no template changes
are needed and the pages that load assets outside base.html (login,
login_2fa) are covered for free. Memoized on (mtime_ns, size) via one
os.stat per call, so the file is only re-read when it actually changes.
- set_security_headers now sends `public, max-age=31536000, immutable` for
stamped assets and `no-cache` for HTML. The HTML half is essential: it is
what carries the stamped URLs, so a heuristically cached page would keep
handing out stale stamps and make the stamping pointless. Responses that set
Cache-Control deliberately (no-store on the API-key reveal) are left alone.
Content hash rather than app version on purpose: the deploy applies the tree
with `git archive | tar`, so every file's mtime changes on each release, and
static/brands/ is generated on the server outside git and changes with no
version bump.
Fail-safe: a missing file, a path escaping static_folder, or any OSError
yields no stamp instead of raising -- a missing brand logo must not take down
the page.
Adds tests/test_cache_busting.py (9 tests), including one proving the stamp
follows content and not mtime in both directions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The symptom
A customer tested a change that had already been published, saw the old
behaviour, and reasonably concluded the change did not work. Nothing was broken
on the server — the browser was simply still running the
spool.js/spool.cssit had cached. No static asset carried a version in its URL, so the URL never
changed and the browser never even asked whether there was a newer copy. The
failure is completely silent: HTTP 200, no console error, just stale code.
The mechanism
Two halves, both required.
1. Stamped asset URLs. A
@app.url_defaultshook adds?v=<first 8 hex of the SHA-256 of the file contents>to everyurl_for('static', ...). This is the canonical Flask hook, so no template wastouched — and the pages that load assets outside
base.html(login.html,login_2fa.html) are covered for free.The lookup is memoized as
path -> (mtime_ns, size, hash): each call does onecheap
os.stat()and only re-reads and re-hashes the file when the stat changes.2.
Cache-Controlon the response.set_security_headersnow sends:?v=public, max-age=31536000, immutable?v=no-cacheThe HTML half is not optional — the HTML is what carries the stamped URLs. A page
sitting in the browser's heuristic cache keeps handing out yesterday's stamps,
which makes the stamping pure decoration.
Responses that set
Cache-Controlon purpose are left alone: the API-key revealendpoint (
routes/integrations.py:61) still returnsno-store, and there is aregression test for it. Note the static branch does overwrite deliberately —
werkzeug's
send_filestampsno-cacheon every static response, and thatdefault is exactly what we are replacing.
Why a content hash and not the app version
Both alternatives were considered and both are wrong here:
?v=1.38.7) would missstatic/brands/, which is generatedon the server by
deploy/seed_brands.py, lives outside git, and changes withno version bump at all.
the tree with
git archive | tar, so every file gets a fresh mtime whether ornot its bytes changed.
A content hash is correct in both directions — it changes when, and only when,
the file actually changes.
Fail-safe
_static_version()never raises. A missing file, a path that escapesstatic_folder(resolved withrealpathand confined), or anyOSErrorreturnsNone, and the URL is emitted without a stamp. A missing brand logo must not beable to take down the page it appears on.
Tests
tests/test_cache_busting.py, 9 tests: HTML isno-cache; the rendered pagecarries
spool.css?v=andspool.js?v=(matched by regex, since the hashmoves); a stamped asset is
immutable; an unstamped one is not; the no-storeregression; the fail-safes; and — the one that matters most — proof that the
stamp tracks content and not mtime, in both directions:
git archive | tardeploy case) → stamp holdsFull suite: 205 passed. Verified manually against a running server
(
flask --app app run):/login→no-cache,/static/spool.css?v=a67e3ef1→public, max-age=31536000, immutable,/static/spool.css→no-cache.Also documented as a 🔥 entry in
docs/ARMADILHAS.md(it already cost realtime), and released as v1.38.7.