Skip to content

fix: honor request cache-control max-age=0 in cache interceptor#5510

Open
jeswr wants to merge 2 commits into
nodejs:mainfrom
jeswr:fix/cache-request-max-age-zero
Open

fix: honor request cache-control max-age=0 in cache interceptor#5510
jeswr wants to merge 2 commits into
nodejs:mainfrom
jeswr:fix/cache-request-max-age-zero

Conversation

@jeswr

@jeswr jeswr commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #5504

The bug

lib/interceptor/cache.js checked the request max-age directive with

if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) {

For max-age=0 the parsed value 0 is falsy, so the directive was ignored entirely and a cached response was served with no origin contact. RFC 9111 §5.2.1.1 requires validation in that case. This also broke fetch(url, { cache: 'no-cache' }) through a composed cache dispatcher, since the fetch spec implements that mode by appending Cache-Control: max-age=0 (related umbrella: #3847).

Secondary issue in the same block: when a nonzero request max-age was exceeded, the bypass dispatched without a CacheHandler (return dispatch(opts, handler)), so the fresh 200 fetched because of the bypass was never stored — the very next plain request had to contact the origin again.

The fix

Treat request max-age exceedance as a revalidation trigger in needsRevalidation() (alongside the existing request no-cache handling), feeding it into the existing revalidation flow: a conditional request is sent, the cached body is served on 304, and a fresh 200 is stored via the CacheHandler already wrapped by CacheRevalidationHandler. The old falsy-guarded bypass block is removed.

Evidence

Repro from the issue (fresh cached response, then a request with Cache-Control: max-age=0):

  • before: origin hits stay at 1 — silent cache hit, no validation
  • after: origin receives a conditional request (If-None-Match), hits go to 2, cached body served on 304

Same for fetch(url, { cache: 'no-cache' }) through new Agent().compose(interceptors.cache(...)): before, 1 origin hit (revalidation never sent); after, the validation request is sent.

mnot cache-tests conformance runner (node test/cache-interceptor/cache-tests.mjs):

  • ccreq-ma0 ("Does HTTP cache honor request Cache-Control: max-age=0 when it holds a fresh response?"): "N no" → "Y yes"
  • Totals unchanged and green: 283 total — 220 passed, 0 failed, 58 failed-optional, 5 setup-failed (identical to main baseline; the ccreq-* checks are behavioral yes/no entries that don't count toward pass/fail totals). Nothing in the runner's skip-list relates to request max-age, so no un-skips were needed.

New tests in test/interceptors/cache.js (all fail on main, pass with this change):

  1. max-age=0 on a fresh cached response triggers a validation request and serves the cached body on 304
  2. a fresh 200 fetched because the response's age exceeds the request's max-age is stored, so the next plain request is a cache hit with the new body
  3. fetch(url, { cache: 'no-cache' }) through a composed cache dispatcher sends a validation request

Full cache test files (test/interceptors/cache*.js, test/cache-interceptor/*): 124 tests / 12 suites, 124 pass, 0 fail (baseline on main is 121/121; the +3 are the new tests).

Notes

The request max-age check in lib/interceptor/cache.js used

    if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) {

which is falsy for max-age=0, so the directive was silently ignored and a
cached response was served with no origin contact. RFC 9111 section 5.2.1.1
requires validation in that case. This also broke
fetch(url, { cache: 'no-cache' }) through a composed cache dispatcher, since
the fetch spec implements that mode by appending Cache-Control: max-age=0.

Additionally, when a nonzero request max-age was exceeded, the old code
bypassed the cache with a plain dispatch(opts, handler) (no CacheHandler),
so the fresh response fetched because of the bypass was never stored and the
next plain request had to contact the origin again.

Fix: treat request max-age exceedance as a revalidation trigger in
needsRevalidation(), feeding it into the existing revalidation flow (which
sends the conditional request, serves the cached body on 304, and stores a
fresh 200 via CacheHandler).

Before (mnot cache-tests): ccreq-ma0 "N no"; after: "Y yes".

Fixes nodejs#5504

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 4, 2026 16:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov-commenter

codecov-commenter commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.44%. Comparing base (cb4c2f1) to head (852ce74).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #5510   +/-   ##
=======================================
  Coverage   93.44%   93.44%           
=======================================
  Files         110      110           
  Lines       37328    37331    +3     
=======================================
+ Hits        34881    34884    +3     
  Misses       2447     2447           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread lib/interceptor/cache.js Outdated
return true
}

// Always revalidate requests whose age exceeds the max-age request

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This comment is incredibly long. Shorten it

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.

Shortened in a10a00d — kept the max-age=0/falsy caveat and the RFC 9111 §5.2.1.1 ref. Thanks.

jeswr pushed a commit to jeswr/undici that referenced this pull request Jul 4, 2026
Addresses review feedback from mcollina on nodejs#5510: the
max-age revalidation comment was too long. Trimmed to one sentence of
rationale while keeping the max-age=0/falsy caveat and RFC 9111 ref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses review feedback from mcollina on nodejs#5510: the
max-age revalidation comment was too long. Trimmed to one sentence of
rationale while keeping the max-age=0/falsy caveat and RFC 9111 ref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeswr jeswr force-pushed the fix/cache-request-max-age-zero branch from a10a00d to 852ce74 Compare July 4, 2026 21:15
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.

cache interceptor ignores request "Cache-Control: max-age=0" (falsy check) — breaks fetch(url, {cache:'no-cache'}) through composed cache dispatchers

4 participants