cache interceptor ignores request "Cache-Control: max-age=0" (falsy check) — breaks fetch(url, {cache:'no-cache'}) through composed cache dispatchers
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 7.7k
- Forks
- 880
- Avg merge
- 2d 16h
- Merged PRs (30d)
- 68
Description
Bug Description
lib/interceptor/cache.js checks the request max-age directive with
if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) {
(currently interceptor/cache.js:283). For max-age=0 the parsed value 0 is falsy, so the
directive is ignored entirely and a cached response is served with no origin contact.
RFC 9111 §5.2.1.1: the client has indicated it is unwilling to accept any response whose age
exceeds 0s — the cache MUST NOT reuse the stored response without successful validation.
This also breaks fetch(url, { cache: 'no-cache' }) when the dispatcher is composed with the
cache interceptor: per the fetch spec, that mode appends Cache-Control: max-age=0
(lib/web/fetch/index.js, "cache mode is no-cache" step), which the interceptor then drops on the
floor — so the one documented way to force revalidation through fetch does nothing.
(Related umbrella: #3847.)
Secondary issue in the same block: when a nonzero request max-age is exceeded, the bypass
dispatches without a CacheHandler (return dispatch(opts, handler), interceptor/cache.js:286),
so the fresh 200 fetched because of the bypass is never stored — the very next plain request has
to revalidate or refetch again.
Reproducible By
const { Agent, interceptors, cacheStores, request } = require('undici')
const http = require('node:http')
let hits = 0
const server = http.createServer((req, res) => {
hits++
res.writeHead(200, { 'cache-control': 'max-age=60', etag: '"v1"' })
res.end('hello')
}).listen(0, async () => {
const origin = `http://localhost:${server.address().port}`
const d = new Agent().compose(interceptors.cache({ store: new cacheStores.MemoryCacheStore() }))
await (await request(origin, { dispatcher: d })).body.text() // hits=1, stored
await (await request(origin, { dispatcher: d, headers: { 'cache-control': 'max-age=0' } })).body.text()
console.log(hits) // observed: 1 — expected: 2 (validation required by RFC 9111 §5.2.1.1)
server.close()
})
Observed on undici 8.7.0 (and current main), Node 22.
Expected Behavior
max-age=0 (and generally age >= max-age) should force the revalidation path (send
If-None-Match/If-Modified-Since, serve cached body on 304), same as the request no-cache
directive already does — not a silent cache hit, and not an un-stored full bypass.
Suggested fix sketch: treat request-max-age exceedance as staleness (feed it into the existing
isStale/revalidation flow) instead of the current falsy-guarded bypass, and wrap the bypass
dispatch in new CacheHandler(...) so the response updates the store.
Found during an agent-assisted HTTP-caching review for @jeswr; every claim reproduced on undici 8.6.0 (repo) and 8.7.0 (npm) on Node 22.23.1. Fix PR to follow.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in lib/interceptor/cache.js around the request max-age check and inspect lib/web/fetch/index.js for the no-cache mode behavior. Run the provided composed-dispatcher reproduction against the cache interceptor. Done means max-age=0 performs validation, including conditional 304 handling, and a refreshed response from an exceeded nonzero max-age is stored for the next request.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs
- Domain
- api, backend, networking
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100