Skip to content

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

Description

@jeswr

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions