carlos / platform Public

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=0 and max-age=0, s-maxage=120 mean the same thing — and so does the pair split across two header lines, since everything still reads through joinHeader.
  • no-store / no-cache / private refusals and the DefaultCacheMaxTTL clamp 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-age alone means exactly what it did. Nothing can have depended on s-maxage being ignored, because ignoring it is what made it unusable.

Deliberately not done

  • s-maxage is NOT read as an override of the Authorization refusal in maybeStore, though RFC 9111 §3.5 permits it. That refusal stays gated on an explicit public: declining to cache is the safe direction, and the origin already has the word for it.
  • No rewriting of Cache-Control on 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

Commits 0
Discussions 0 0 open
Tasks 0/0 done

Discussions

  • No discussions on this branch yet.

Tasks

  • No tasks yet.

Activity

  • No activity yet.