Skip to content

Stop a wrong asset response from outliving the deploy that caused it - #1244

Merged
TaprootFreak merged 5 commits into
developfrom
fix/purge-edge-cache-on-deploy
Aug 2, 2026
Merged

Stop a wrong asset response from outliving the deploy that caused it#1244
TaprootFreak merged 5 commits into
developfrom
fix/purge-edge-cache-on-deploy

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

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 inherited public, max-age=31536000, immutable. Since immutable forbids 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: ChunkLoadError on /buy was 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/html from cache, 33 and 37 hours old, with a year still to run, while the same URLs with a cache-busting query returned 404. The application was answering correctly; only the stored copies were wrong.

What this changes

  • Drop immutable from /static/*. The long max-age stays — filename hashes change with content, so a conditional request is answered 304 in the normal case. What changes is that a user-triggered reload can revalidate at all, which immutable prevents. Shared/edge caches keep the year-long lifetime either way; the comment says so plainly rather than implying this fixes them.
  • Route every other non-fingerprinted path through the content check, not just the obvious ones: /favicon.ico, /logo.png, /robots.txt, /manifest.json, /asset-manifest.json and /version.json. no-cache still stores a 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 on purpose: they are the fallback.
  • Fail closed on an absent or empty content-type. An ok response carrying neither is treated as the fallback instead of being passed through, and the media type is compared before its parameters so a type that merely mentions text/html in a parameter is not mistaken for one.
  • Verify after every production deploy that an absent asset really answers a bare 404 with exactly the headers the function emits. A run-specific marker (run id and attempt) is polled first so the check runs against a live deploy. The step states what it can and cannot prove: marker and asset are separate requests and could, during propagation, be served by different edge nodes — it is a smoke test that reliably catches a widespread regression, not a per-node proof.
  • Pin the route list in a test — exact include and exclude, so an entry can neither be dropped nor added unnoticed.

Verification

  • npm run lint clean; npm test green (63 suites, 684 tests)
  • Deploy step order checked by hand: the marker is written after every build step and before the upload, so it cannot be overwritten
  • Seven review passes against the complete diff. Findings resolved along the way included two that would have made the new gate ineffective — one where it would have re-proved the previous deploy, one where a transport failure could have counted as a pass — plus a test that passed while testing something other than its name, and several statements in comments and failure messages that promised more than the code establishes.

Related: #1226 (root cause), #1230 (the 404 answer this builds on).

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.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Seven review passes against the complete develop...HEAD diff, one on conformity and one on logic each round, iterating until neither returned a defect. All checks are green on ff1a36d.

The passes were worth it — several findings would have shipped a fix that only looked like one:

  • The deploy gate proved nothing. The randomised asset URL varied the path, not the version answering it. Because the previous deploy already answered missing assets correctly, the check went green without ever exercising the build it was meant to guard. A run-specific marker is now polled first.
  • A transport failure could count as a pass. || true on the probe turned an aborted request into a success once headers had already arrived.
  • The fail-closed claim only held half the time. An absent content-type was caught, an empty one was passed through as a valid asset.
  • Two more paths carried the same assumption. /robots.txt, then /version.json — the latter is written by the build and expected by an e2e test, so a missing file would have been served as the app shell under that URL. The pinned route test would have locked that gap in as correct.
  • A test passed while testing something else. The empty-content-type case cannot be built through the Response constructor: the platform discards '' and substitutes text/plain. The header has to be emptied after construction.

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 unknown if git metadata is missing.

The last pass returned a single wording defect, fixed in ff1a36d; that one-line comment change was not re-reviewed, since the logic dimension had already returned zero findings and everything after pass five was wording without behavioural effect.

@TaprootFreak
TaprootFreak marked this pull request as ready for review August 2, 2026 19:54
@TaprootFreak
TaprootFreak merged commit c92ed35 into develop Aug 2, 2026
6 checks passed
@TaprootFreak
TaprootFreak deleted the fix/purge-edge-cache-on-deploy branch August 2, 2026 20:23
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