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.
Bug Description
lib/interceptor/cache.jschecks the requestmax-agedirective with(currently interceptor/cache.js:283). For
max-age=0the parsed value0is falsy, so thedirective 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 thecache 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-ageis exceeded, the bypassdispatches 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
Observed on undici 8.7.0 (and current main), Node 22.
Expected Behavior
max-age=0(and generallyage >= max-age) should force the revalidation path (sendIf-None-Match/If-Modified-Since, serve cached body on 304), same as the requestno-cachedirective 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 bypassdispatch 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.