Skip to content

fix: stop 404s/redirects from being publicly CDN-cached#2526

Open
agalin920 wants to merge 1 commit into
stagefrom
fix/no-cache-404-catchall
Open

fix: stop 404s/redirects from being publicly CDN-cached#2526
agalin920 wants to merge 1 commit into
stagefrom
fix/no-cache-404-catchall

Conversation

@agalin920

@agalin920 agalin920 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

https://www.zesty.io/instances/ returned a 404 served from the Fastly edge to everyone and only a manual "refresh the marketing instance CDN" cleared it.

Root cause: a Surrogate-Control header on a non-200 response

The main header responsible for this is Surrogate-Control, not Cache-Control. On Fastly, Surrogate-Control controls the shared edge cache and overrides Cache-Control

In the catch-all src/pages/[[...zesty]].js, getServerSideProps set all three caching headers at the top of the function, before knowing the outcome

When /instances/ briefly 404'd through this catch-all, the 404 went out carrying Surrogate-Control: max-age=3600.

Next.js technically overrides Cache-Control to no-store, must-revalidate on that notFound response but that did nothing, because Next only touches Cache-Control (browser). It has no idea Surrogate-Control/Surrogate-Key exist, so it left them on the response.

Fastly saw Surrogate-Control: max-age=3600, ignored the no-store, and cached the 404 at the edge.

Surrogate-Key: <instance_zuid> tagged it under the marketing instance's key, so it took a full instance purge to clear (and was served to everyone until then).

I want to note that (Cache-Control) was a red herring here for me. The bug was entirely Surrogate-Control being present on a response that turned out to be a 404.

The catch-all getServerSideProps set 'public, max-age=3600' plus
Surrogate-Control and the instance Surrogate-Key at the top of the
function, before knowing the outcome. Non-200 exits (notFound,
redirects) inherited those headers, so a transient 404 on a valid
route (e.g. /instances/ during a deploy window) could be pinned at
the Fastly edge and served to everyone -- including logged-in users --
until a manual instance purge.

Default every response to 'private, no-store' and apply the cacheable
headers only on the confirmed 200 page render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/pages/[[...zesty]].js
);
// Default to non-cacheable; cache headers are set only on the 200 path below
// so 404s/redirects can't be pinned at the CDN edge and served to everyone.
res.setHeader('Cache-Control', 'private, no-store');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small behavior change worth calling out explicitly: previously Surrogate-Key was set unconditionally (outside the isProd && guards), so dev/stage responses also carried it. With this refactor, Surrogate-Key is only emitted on the successful isProd 200 path. That's very likely intentional (surrogate keys are only meaningful behind Fastly), but if any stage-facing tooling or purge script currently relies on the header being present in stage, it will silently stop working. Worth a quick confirm.

Also, per your own verification table, Next.js overrides the header on the notFound path to no-store, must-revalidate — so the private, no-store default here is really only observed on the redirect path and any thrown-500 path. Still correct, just noting the default set here isn't what actually goes out for 404s.

@agalin920 agalin920 Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct and intentional. As I mentiond in the PR desc: Surrogate-Control/Surrogate-Key are the operative headers here. On Fastly, Surrogate-Control controls the shared edge cache and overrides Cache-Control. Next already forced Cache-Control: no-store on the 404, but it doesn't touch the surrogate headers, so Fastly obeyed Surrogate-Control and edge-cached the 404 anyway. The fix's key effect is dropping the surrogate headers on non-200 paths, not the Cache-Control default.

On stage it's inert: isProd already gated the public Cache-Control, so stage responses were never shared-cacheable regardless of the key

Comment thread src/pages/[[...zesty]].js
}

// Real 200 page: safe to cache and tag with the instance Surrogate-Key.
if (isProd) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice side effect worth noting: the two auth-driven redirects above (//dashboard/ for logged-in users, and /login// for logged-in users) had the same bug as the 404 case described in the PR — under the old code those 307s went out public, max-age=3600 and tagged with the instance Surrogate-Key. In principle an anonymous user hitting / right after an authenticated user could have gotten the cached 307 to /dashboard/, or vice versa. Since they now sit above this isProd block, they inherit the private, no-store default and are non-cacheable. Might be worth a one-liner in the PR description so reviewers/on-call know the fix is broader than the /instances/ 404 symptom.

@agalin920 agalin920 Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, same bug class as the 404. these 307s carried Surrogate-Control + the instance Surrogate-Key, so Fastly could edge-cache an auth-dependent redirect and serve it to the wrong user.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review summary

Correct diagnosis, minimal fix, and I like the inversion of the default (private, no-store up front, opt in to cacheability on the confirmed 200). Structurally this is exactly the right shape for this kind of edge-cache bug: the cacheable state has to be a positive assertion, not an inherited default.

Things I checked

  • Grepped for Surrogate-Key / Surrogate-Control — only this file uses them, so no other consumer breaks when they move out of the top-of-function block.
  • process.env.zesty.instance_zuid is legitimate (populated via next.config.js env: { zesty: zestyConfig }), unchanged behavior.
  • All four early-return paths (data.error, //dashboard/, /login//, plus any thrown 500 above line 158) now exit with private, no-store. ✅
  • The isProd block is correctly placed after the auth-based redirects, so a redirect for a logged-in user never gets tagged with the instance Surrogate-Key.
  • The dropped trailing space in 'public, max-age=3600, stale-while-revalidate=7200' is a nice cleanup.

Two small notes (left inline):

  1. Surrogate-Key was previously set unconditionally — now it only ships on isProd + 200. Almost certainly intentional, but worth confirming no stage tooling depends on the header being present.
  2. The redirect-caching fix (analogous to the 404 one) is a real, welcome side effect — consider mentioning it in the PR description so on-call knows the blast-radius fix is broader than /instances/.

Out of scope but adjacent (worth a follow-up ticket, not this PR):

  • The successful / render is still public, max-age=3600 under the instance Surrogate-Key. If an anonymous visitor primes the cache and then an authenticated user hits /, Fastly serves them the anonymous homepage and the //dashboard/ redirect never runs. This is pre-existing behavior, but it's the same class of "auth-dependent response served from a shared cache" issue the PR is fixing. Your PR's follow-up bullet about Fastly rules for authenticated prefixes would cover this too.
  • The PRODUCTION=… cookie on line 77 and the console.log('gh auth', …) on lines 123/125/133 are pre-existing and unrelated, but if you're touching this file soon they're both worth a look — the log leaks whether GITHUB_AUTH is set, and the cookie exposes an internal env flag to every client.

LGTM to ship as-is.

@agalin920 agalin920 requested review from finnar-bin and shrunyan July 1, 2026 20:30
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.

2 participants