Stop a wrong asset response from outliving the deploy that caused it - #1244
Conversation
A missing /static/* asset used to be answered by the SPA fallback with the app shell, and the path-based header rule stamped that HTML with the lifetime meant for fingerprinted assets: public, max-age=31536000, immutable. Because immutable forbids revalidation, the wrong answer was pinned for a year in every cache that had fetched it. Customers hit an error screen instead of the buy page; one case stayed stuck for roughly 40 minutes on a fixed-line connection, and the edge had to be purged by hand to clear it. Missing assets already answer 404. This adds the parts that keep a single mistake from becoming a year-long outage: - drop immutable from /static/*, so a user-triggered reload can revalidate and correct a poisoned entry. The long max-age stays; shared caches are unaffected and still need purging, which the comment now says plainly. - route /favicon.ico and /logo.png through the content check as well. They carried the same path-not-content assumption with a 24h lifetime and never reached the function. - treat a missing content-type on an ok response as the fallback case instead of defaulting it away, and compare case-insensitively. - verify after every production deploy that an absent asset really answers a bare 404 with the exact headers the function emits. A run-specific marker is polled first so the check runs against a live deploy; the step states plainly what it can and cannot prove, since marker and asset are separate requests. - pin the route list in a test, so dropping an entry cannot pass silently.
…ten the checks Review follow-ups on the same failure class: - robots.txt, manifest.json and asset-manifest.json go through the content check too. no-cache still stores the response — it only forces revalidation before reuse — so a later 304 can confirm a wrongly stored app shell under those URLs. / and /index.html stay out: they are the fallback. - the route-list test now pins include and exclude exactly, so an entry cannot be added or dropped unnoticed, and reads the file as a typed import like the rest of the suite. - compare the media type before the parameters instead of scanning the whole header, so a content type that merely mentions text/html in a parameter is not mistaken for the fallback. - separate the transport-failure conclusion from the wrong-answer one in the verify step, and qualify three comment claims that promised more than HTTP caching guarantees.
- version.json goes through the content check as well. It is written by the build and an e2e test expects real JSON from it, so a missing file would have been served as the app shell under that URL — the same failure class the rest of this change closes. It gets the same revalidation rule as the other manifests. - an empty content-type is now treated like a missing one. The claim was that the check fails closed; it only did so for an absent header. - the verify step tracks whether any response was received at all, so an aborted final attempt is no longer reported as 'never got a response' after eleven wrong-but-received ones, and a non-matching response is no longer asserted to be the incident's failure mode when it could be an unrelated error. The empty-content-type test sets the header after construction on purpose: passing '' in the init is discarded and the platform fills in text/plain, so the constructor route cannot produce the case the test is named after.
Two diagnostic texts promised more than the code observes: - the marker loop reported 'new deploy did not become active' whenever no matching marker body came back, though all it establishes is that no marker matching this run was seen — the deploy may well be live with the marker endpoint misbehaving. - the version.json comment claimed a new commit hash on every build. The hash only moves when HEAD does; a rebuild of the same commit reuses it, and only the build time is always fresh. It is also written by the main-app build, not by the widget build.
generate-version.js falls back to the literal 'unknown' when git metadata is unavailable, so the comment describing what version.json holds should not promise a commit hash unconditionally.
|
Seven review passes against the complete The passes were worth it — several findings would have shipped a fix that only looked like one:
The recurring theme, and the reason the later passes kept finding things, was not broken logic but statements that promised more than the code establishes — in comments, in failure messages, once in a test name. Examples corrected here: a cached wrong answer described as removable "only by purging" (eviction and expiry also remove it), a marker loop reporting "deploy did not become active" when all it observed was an unmatched marker, and a comment promising a commit hash where the build writes The last pass returned a single wording defect, fixed in |
What happened
A request for a missing
/static/*asset was answered by the SPA fallback with the app shell — HTML, status 200. The header rule for/static/*matches on the path, not on what was actually served, so that HTML inheritedpublic, max-age=31536000, immutable. Sinceimmutableforbids revalidation, the wrong answer stayed servable for a year in every cache that had fetched it.Customers landed on the error screen instead of the buy page. One reported case sat there for roughly 40 minutes on a fixed-line connection — not a flaky mobile link, a cached wrong answer. Clearing it required purging the edge by hand.
Measured before that purge:
ChunkLoadErroron/buywas the largest single error class in the backend log at 20–42 per hour. Afterwards it fell to 1 per hour, then to 0.Why the existing 404 fix was not enough
Missing assets already answer a real 404 (#1230). That stops new poisoning — it does not remove a single existing one. Two URLs demonstrated the persistence directly: both returned
200 text/htmlfrom cache, 33 and 37 hours old, with a year still to run, while the same URLs with a cache-busting query returned404. The application was answering correctly; only the stored copies were wrong.What this changes
immutablefrom/static/*. The longmax-agestays — filename hashes change with content, so a conditional request is answered304in the normal case. What changes is that a user-triggered reload can revalidate at all, whichimmutableprevents. Shared/edge caches keep the year-long lifetime either way; the comment says so plainly rather than implying this fixes them./favicon.ico,/logo.png,/robots.txt,/manifest.json,/asset-manifest.jsonand/version.json.no-cachestill stores a response — it only forces revalidation before reuse — so a later304can confirm a wrongly stored app shell under those URLs./and/index.htmlstay out on purpose: they are the fallback.text/htmlin a parameter is not mistaken for one.includeandexclude, so an entry can neither be dropped nor added unnoticed.Verification
npm run lintclean;npm testgreen (63 suites, 684 tests)Related: #1226 (root cause), #1230 (the 404 answer this builds on).