edge-s-maxage
Closes the ask in GitHub issue #403 (tito PRO-4758/PRO-4759).
The problem
parseMaxAge switched on max-age alone, which left an origin one number for two caches: whatever it asked the edge to hold, every visitor's browser held too — and the purge channel reaches only the first of them. A long lifetime bought edge caching at the price of a stale client-side tail after each purge; a short one freshened browsers by giving up the traffic the cache exists to absorb.
RFC 9111 §5.2.2.10 has the directive for exactly this: s-maxage overrides max-age for a shared cache, and a private cache ignores it. So Cache-Control: s-maxage=120, max-age=0 is an origin saying "edge holds it two minutes, browser revalidates", and a purge becomes effective end to end. Sending that today read as max-age=0 and made maybeStore refuse the response — so the origins trying hardest to stay purgeable were the ones silently losing edge caching altogether.
The change
parseMaxAge prefers s-maxage over max-age when both are present.
- The whole-header scan is unchanged, so
s-maxage=120, max-age=0andmax-age=0, s-maxage=120mean the same thing — and so does the pair split across two header lines, since everything still reads throughjoinHeader. no-store/no-cache/privaterefusals and theDefaultCacheMaxTTLclamp are untouched.- A malformed argument on EITHER directive refuses the response rather than falling back to the other, in either order: an origin that cannot state its lifetime is not asking for a guess.
max-agealone means exactly what it did. Nothing can have depended ons-maxagebeing ignored, because ignoring it is what made it unusable.
Deliberately not done
s-maxageis NOT read as an override of theAuthorizationrefusal inmaybeStore, though RFC 9111 §3.5 permits it. That refusal stays gated on an explicitpublic: declining to cache is the safe direction, and the origin already has the word for it.- No rewriting of
Cache-Controlon the way downstream, which the reporter explicitly did not want — the origin states what it means and the edge reads it.
Tests
TestServeHTTPPrefersSMaxageOverMaxAge drives 16 cases end to end through ServeHTTP: both directive orders, the pair split across header lines in both orders, mixed case, odd spacing, s-maxage alone, s-maxage SHORTER than max-age, the TTL clamp, s-maxage=0, private / no-store / no-cache alongside it, and a malformed argument on each side. TestServeHTTPCacheHitReplaysOriginCacheControl pins the other half: the origin's own header survives to the browser on a cache hit, which is what makes the pair work end to end.
Both were watched to fail against the old parser first — every s-maxage case reported "was not cached", and s-maxage=30, max-age=600 cached at 5m instead of 30s.
Local gate green: go build ./..., go vet ./..., make test (all packages, gofmt -l . clean).
🤖 Generated with Claude Code
https://claude.ai/code/session_01KYwNnDxVDcsy1oyqGuSz9Y
Discussions
- No discussions on this branch yet.
Tasks
- No tasks yet.
Activity
- No activity yet.