Skip to content

fix(http)!: advertise the lifetimes ocache actually enforces - #86

Merged
pi0 merged 1 commit into
mainfrom
fix/cache-control-synthesis
Aug 13, 2026
Merged

pi0 merged 1 commit into
mainfrom
fix/cache-control-synthesis

Conversation

@pi0x

@pi0x pi0x commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Closes findings 10.2, 10.3 and 10.4 β€” all three live in the same serialize block, so they're one sitting.

⚠️ Conflicts with #84 in src/http/entry.ts (both edit serializeResponse). Whichever merges second I'll rebase and re-verify.

10.2 β€” getMaxAge never reached the header

serialize read the static opts.maxAge even though cache.ts had already resolved entry.maxAge / entry.staleMaxAge onto the very entry it was handed. ocache expired its own entry on the dynamic lifetime while telling every cache downstream the static one β€” most of a dynamic TTL's purpose, gone.

It now advertises entry.maxAge ?? opts.maxAge (same for staleMaxAge) β€” the per-field precedence cache.ts already applies to the freshness check, the storage TTL and expireCache's rewrite. This is sound because http/index.ts always installs its getMaxAge wrapper, so both fields are always resolved by the time serialize sees the entry: undefined there means "no override", never "not asked".

The wrapper's own must-revalidate β†’ staleMaxAge: 0 can never leak into the advertisement: it only exists when the handler set a Cache-Control, which is exactly when the has("cache-control") check has already suppressed synthesis. There's a test asserting that.

10.3 β€” max-age alongside s-maxage, carrying the same number

s-maxage is shared-cache-only, so a private cache had no freshness lifetime at all and fell back to heuristic freshness over Date βˆ’ Last-Modified (RFC 9111 Β§4.2.2). last-modified is stamped at fill time, so that's β‰ˆ0 β€” browsers revalidated on every navigation while the server happily held the entry for maxAge.

Same number as s-maxage, because that is the one ocache actually enforces for the entry: smaller would be unenforced fiction, larger an overclaim. s-maxage overrides max-age in a shared cache (Β§5.2.2.10), so CDN behavior is unchanged; the gain is entirely for private caches.

s-maxage is kept rather than folded away into a lone max-age (which would already govern shared caches): Β§3.5 makes it separately what authorizes a shared cache to store the response to an Authorization-carrying request β€” reachable under allowAuthorization.

10.4 β€” the bare stale-while-revalidate is gone, and nothing replaces it

RFC 5861 Β§3 requires the delta-seconds, so the bare token emitted for the ISR shape was unparseable and conforming caches had to drop the directive wholesale (RFC 9111 Β§5.2.3) β€” the intended window evaporated downstream while reading as though it hadn't.

Nothing replaces it. That shape's stale window is genuinely unbounded β€” the entry is retained until the backend evicts it by capacity β€” so there is no honest number, and any invented one (maxAge, a year, a constant) would advertise a stale window ocache cannot promise. That is precisely the finding-10 failure mode, in the permissive direction.

Silence under-promises, which is the safe direction: a downstream cache revalidates when max-age runs out, ocache answers that from its own retained stale copy (or a 304) and refreshes in the background. ISR still happens β€” one layer in, where it is actually enforced.

Directive strings, before β†’ after

Config Before After
{ maxAge: 60 } max-age=60 unchanged
{ maxAge: 60, swr: true } (ISR) s-maxage=60, stale-while-revalidate max-age=60, s-maxage=60
{ maxAge: 60, swr: true, staleMaxAge: 120 } s-maxage=60, stale-while-revalidate=120 max-age=60, s-maxage=60, stale-while-revalidate=120
{ maxAge: 0 } max-age=0 unchanged (still refused storage)
{ maxAge: 0, swr: true } s-maxage=0, stale-while-revalidate max-age=0, s-maxage=0 (still refused)
{ maxAge: 3600, getMaxAge: () => 2 } max-age=3600 max-age=2
{ maxAge: 3600, staleMaxAge: 7200, swr: true, getMaxAge: () => ({maxAge: 2, staleMaxAge: 30}) } s-maxage=3600, stale-while-revalidate=7200 max-age=2, s-maxage=2, stale-while-revalidate=30
handler-set cache-control untouched unchanged
sendCacheControl: false nothing unchanged

Also

Fixes the tracked docs bug in docs/1.guide/8.cache-control.md: "Empty body β€” nothing to replay" sat under "These checks always apply and cannot be turned off" and is false β€” validate accepts "" and rejects only undefined (measured: new Response("", {status:200}) β†’ HIT). Also removed two AGENTS.md claims that the synthesized header carries public, which it never has.

Breaking

The synthesized Cache-Control changes shape: private caches now get a real max-age where they previously revalidated every time, and the ISR shape emits no stale-while-revalidate token at all.

Flagged for a possible follow-up

{ swr: true, maxAge: 0, staleMaxAge: 600 } still ships stale-while-revalidate=600 next to a zero lifetime β€” ocache stores nothing, so downstream is told it may serve stale for 600 s that ocache never keeps. Pre-existing and unchanged by this diff, and it is the literal reading of what the caller configured; suppressing it would be a further narrowing that didn't belong in this PR silently.

Verification

  • pnpm vitest run test/ β€” 357 passed (346 on main; 11 new in a describe("synthesized Cache-Control lifetimes") block, 4 pre-existing assertions updated to the new strings)
  • pnpm typecheck, pnpm lint β€” clean
  • pnpm bundle β€” cheaper on two of three axes, no budget touched: raw 49,632 β†’ 49,449 (βˆ’183), min 20,161 β†’ 20,132 (βˆ’29), minGzip 8,057 β†’ 8,071 (+14). The removed else branch paid for the new one.

πŸ€– Generated with Claude Code

Three findings, all in the one `serialize` block.

10.2 -- `serialize` read the *static* `opts.maxAge` even though `cache.ts` had
already resolved `entry.maxAge`/`entry.staleMaxAge` onto the very entry it was
handed. ocache expired its own entry on the dynamic lifetime while telling
every cache downstream the static one, which is most of a dynamic TTL's purpose
gone. It now advertises `entry.maxAge ?? opts.maxAge` (and the same for
`staleMaxAge`) -- the per-field precedence `cache.ts` already applies to the
freshness check, the storage TTL and `expireCache`'s rewrite. `http/index.ts`
always installs its `getMaxAge` wrapper, so both fields are always resolved
here: `undefined` means "no override", never "not asked".

10.3 -- `s-maxage` is shared-cache-only, so a private cache had no freshness
lifetime at all and fell back to heuristic freshness over `Date -
Last-Modified` (RFC 9111 4.2.2). `last-modified` is stamped at fill time, so
that is about zero and browsers revalidated on every navigation while the
server held the entry for `maxAge`. `max-age` is now emitted alongside
`s-maxage`, carrying the same number -- the one ocache itself enforces; smaller
would be unenforced fiction, larger an overclaim. `s-maxage` overrides
`max-age` in a shared cache (5.2.2.10) so CDNs are unaffected, and it stays
rather than being folded away because it is what authorizes a shared cache to
store the response to an `Authorization`-carrying request at all (3.5,
reachable under `allowAuthorization`).

10.4 -- the bare `stale-while-revalidate` emitted for the ISR shape (`swr`
with no `staleMaxAge`) is invalid syntax: RFC 5861 3 requires the
delta-seconds, so conforming caches dropped the directive wholesale and the
intended window evaporated downstream while reading as if it hadn't. Nothing
replaces it. That shape's stale window is genuinely unbounded -- the entry is
retained until the backend evicts it by capacity -- so there is no honest
number, and any invented one would advertise a window ocache cannot promise,
which is the finding-10 failure mode in the permissive direction. Silence
under-promises: downstream revalidates when `max-age` runs out and ocache
answers that from its own stale entry, so ISR still happens one layer in,
where it is actually enforced.

Directive strings, before -> after:

  { maxAge: 60 }                       max-age=60            (unchanged)
  { maxAge: 60, swr }                  s-maxage=60, swr      -> max-age=60, s-maxage=60
  { maxAge: 60, swr, staleMaxAge:120 } s-maxage=60, swr=120  -> max-age=60, s-maxage=60, swr=120
  { maxAge: 3600, getMaxAge: ()=>2 }   max-age=3600          -> max-age=2

Also fixes the docs claim that an empty body is never cached: `validate`
accepts `""` and rejects only `undefined`, so a zero-byte 200 caches normally.

BREAKING CHANGE: the synthesized `Cache-Control` changes shape. Private caches
now get a real `max-age` where they previously revalidated every time, and the
ISR shape no longer emits a `stale-while-revalidate` token at all.

Closes findings 10.2, 10.3 and 10.4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@pi0x, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 05df6ef0-a563-440d-8817-5d7767136620

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between f7fb1e5 and 6f92359.

πŸ“’ Files selected for processing (8)
  • AGENTS.md
  • docs/1.guide/7.cookies.md
  • docs/1.guide/8.cache-control.md
  • docs/1.guide/9.isr.md
  • src/http/entry.ts
  • src/http/index.ts
  • src/types.ts
  • test/index.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@pi0
pi0 merged commit 025a1e9 into main Aug 13, 2026
5 checks passed
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