Skip to content

fix(cache): bust static asset URLs by content hash (v1.38.7) - #72

Merged
iscarelli merged 1 commit into
mainfrom
feat/static-cache-busting
Aug 7, 2026
Merged

fix(cache): bust static asset URLs by content hash (v1.38.7)#72
iscarelli merged 1 commit into
mainfrom
feat/static-cache-busting

Conversation

@iscarelli

Copy link
Copy Markdown
Owner

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.css
it 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_defaults hook adds
?v=<first 8 hex of the SHA-256 of the file contents> to every
url_for('static', ...). This is the canonical Flask hook, so no template was
touched
— 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 one
cheap os.stat() and only re-reads and re-hashes the file when the stat changes.

2. Cache-Control on the response. set_security_headers now sends:

Response Header
static with ?v= public, max-age=31536000, immutable
static without ?v= untouched (Flask's ETag / conditional revalidation)
HTML no-cache

The 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-Control on purpose are left alone: the API-key reveal
endpoint (routes/integrations.py:61) still returns no-store, and there is a
regression test for it. Note the static branch does overwrite deliberately —
werkzeug's send_file stamps no-cache on every static response, and that
default is exactly what we are replacing.

Why a content hash and not the app version

Both alternatives were considered and both are wrong here:

  • App version (?v=1.38.7) would miss static/brands/, which is generated
    on the server by deploy/seed_brands.py, lives outside git, and changes with
    no version bump at all.
  • mtime would invalidate everything on every release: the deploy applies
    the tree with git archive | tar, so every file gets a fresh mtime whether or
    not 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 escapes
static_folder (resolved with realpath and confined), or any OSError returns
None, and the URL is emitted without a stamp. A missing brand logo must not be
able to take down the page it appears on.

Tests

tests/test_cache_busting.py, 9 tests: HTML is no-cache; the rendered page
carries spool.css?v= and spool.js?v= (matched by regex, since the hash
moves); a stamped asset is immutable; an unstamped one is not; the no-store
regression; the fail-safes; and — the one that matters most — proof that the
stamp tracks content and not mtime, in both directions:

  • same size, different bytes, different mtime → stamp changes
  • same bytes, new mtime (the git archive | tar deploy case) → stamp holds

Full suite: 205 passed. Verified manually against a running server
(flask --app app run): /loginno-cache, /static/spool.css?v=a67e3ef1
public, max-age=31536000, immutable, /static/spool.cssno-cache.

Also documented as a 🔥 entry in docs/ARMADILHAS.md (it already cost real
time), and released as v1.38.7.

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.
@iscarelli
iscarelli merged commit 22a0829 into main Aug 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant