From 3704dfa306218f2d8054eef5ac60dd6f54b60615 Mon Sep 17 00:00:00 2001 From: Jesse Wright Date: Sat, 4 Jul 2026 16:34:26 +0000 Subject: [PATCH 1/2] fix: send stored last-modified as if-modified-since when revalidating When revalidating a stale cached response, the cache interceptor built the conditional request with 'if-modified-since': new Date(result.cachedAt).toUTCString() i.e. the local wall-clock time the response was inserted into the cache, rather than the stored response's Last-Modified value. RFC 9110 Section 13.1.3 says caches should generate If-Modified-Since from the stored Last-Modified field, falling back to the Date field or the local receipt time. Consequences of the old behavior: - Origins doing exact-match If-Modified-Since comparison (e.g. nginx default `if_modified_since exact`) never answer 304 to a Last-Modified-only revalidation, losing the 304 bandwidth saving. - If the client clock runs ahead of the origin, a resource modified between Last-Modified and (client-time) cachedAt can be masked and stale data served indefinitely. Now the revalidation request (both the synchronous path and the stale-while-revalidate background path) sends the stored last-modified value verbatim when present, then falls back to the stored date value, and only uses the local insertion time when the stored response has neither. Fixes #5506 Co-Authored-By: Claude Fable 5 --- lib/interceptor/cache.js | 24 ++- test/interceptors/cache-revalidate-stale.js | 170 ++++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index fe56baa82c9..81ea82a7a53 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -106,6 +106,26 @@ function withinStaleWhileRevalidateWindow (result) { return now <= staleWhileRevalidateExpiry } +/** + * Returns the if-modified-since value to use when revalidating a stored + * response: its last-modified value if available, otherwise its date value, + * otherwise the local time the response was cached at. + * https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.3 + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @returns {string} + */ +function getIfModifiedSinceValue (result) { + if (typeof result.headers['last-modified'] === 'string' && result.headers['last-modified'] !== '') { + return result.headers['last-modified'] + } + + if (typeof result.headers.date === 'string' && result.headers.date !== '') { + return result.headers.date + } + + return new Date(result.cachedAt).toUTCString() +} + /** * @param {DispatchFn} dispatch * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts @@ -307,7 +327,7 @@ function handleResult ( queueMicrotask(() => { const headers = { ...opts.headers, - 'if-modified-since': new Date(result.cachedAt).toUTCString() + 'if-modified-since': getIfModifiedSinceValue(result) } if (result.etag) { @@ -351,7 +371,7 @@ function handleResult ( const headers = { ...opts.headers, - 'if-modified-since': new Date(result.cachedAt).toUTCString() + 'if-modified-since': getIfModifiedSinceValue(result) } if (result.etag) { diff --git a/test/interceptors/cache-revalidate-stale.js b/test/interceptors/cache-revalidate-stale.js index 5abc58898a6..17103357014 100644 --- a/test/interceptors/cache-revalidate-stale.js +++ b/test/interceptors/cache-revalidate-stale.js @@ -157,3 +157,173 @@ describe('revalidates the request, handles 304s during stale-while-revalidate', test('using if-none-match', async () => await revalidateTest(true)) test('using if-modified-since', async () => await revalidateTest(false)) }) + +describe('if-modified-since value on revalidation requests (RFC 9110 ยง13.1.3)', () => { + const LAST_MODIFIED = 'Sat, 09 Oct 2010 14:28:02 GMT' + + test('sends the stored last-modified value verbatim', async () => { + const clock = FakeTimers.install({ + now: new Date('2028-03-15T12:00:00.000Z').getTime() + }) + after(() => clock.uninstall()) + + const revalidationRequests = [] + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.sendDate = false + res.setHeader('Date', new Date(clock.now).toUTCString()) + res.setHeader('Cache-Control', 'public, max-age=1') + res.setHeader('Last-Modified', LAST_MODIFIED) + + if (req.headers['if-modified-since']) { + revalidationRequests.push(req.headers['if-modified-since']) + res.statusCode = 304 + res.end() + } else { + res.end('hello world') + } + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await dispatcher.close() + }) + + const url = `http://localhost:${server.address().port}` + + // initial request, cache the response + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + // let the response go stale, forcing a revalidation request + clock.tick(1500) + + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + strictEqual(revalidationRequests.length, 1) + strictEqual(revalidationRequests[0], LAST_MODIFIED) + }) + + test('sends the stored last-modified value verbatim during stale-while-revalidate', async () => { + const clock = FakeTimers.install({ + now: new Date('2028-03-15T12:00:00.000Z').getTime() + }) + after(() => clock.uninstall()) + + const revalidationRequests = [] + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.sendDate = false + res.setHeader('Date', new Date(clock.now).toUTCString()) + res.setHeader('Cache-Control', 'public, max-age=1, stale-while-revalidate=3600') + res.setHeader('Last-Modified', LAST_MODIFIED) + + if (req.headers['if-modified-since']) { + revalidationRequests.push(req.headers['if-modified-since']) + res.statusCode = 304 + res.end() + } else { + res.end('hello world') + } + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await dispatcher.close() + }) + + const url = `http://localhost:${server.address().port}` + + // initial request, cache the response + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + // within the stale-while-revalidate window, revalidated in the background + clock.tick(2000) + + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + + // wait for the background revalidation to complete + await setTimeout(100) + clock.tick(10) + await setTimeout(100) + } + + strictEqual(revalidationRequests.length, 1) + strictEqual(revalidationRequests[0], LAST_MODIFIED) + }) + + test('falls back to the stored date value when there is no last-modified', async () => { + const clock = FakeTimers.install({ + now: new Date('2028-03-15T12:00:00.000Z').getTime() + }) + after(() => clock.uninstall()) + + // date header trailing the local clock, as with a skewed origin clock + const dateHeader = new Date(clock.now - 5000).toUTCString() + + const revalidationRequests = [] + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.sendDate = false + res.setHeader('Date', dateHeader) + res.setHeader('Cache-Control', 'public, max-age=10') + + if (req.headers['if-modified-since']) { + revalidationRequests.push(req.headers['if-modified-since']) + res.statusCode = 304 + res.end() + } else { + res.end('hello world') + } + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await dispatcher.close() + }) + + const url = `http://localhost:${server.address().port}` + + // initial request, cache the response + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + // let the response go stale (staleAt is based off of the date header) + clock.tick(6000) + + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + strictEqual(revalidationRequests.length, 1) + strictEqual(revalidationRequests[0], dateHeader) + }) +}) From efe48e37b805c22575765afeff68409ef8af3910 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:05:34 +0000 Subject: [PATCH 2/2] docs: shorten cache interceptor comments Trim the getIfModifiedSinceValue JSDoc to a single-line summary plus the RFC reference, addressing review feedback favouring terse interceptor comments. Co-Authored-By: Claude Fable 5 --- lib/interceptor/cache.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index 81ea82a7a53..5ad6e5f1565 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -107,9 +107,7 @@ function withinStaleWhileRevalidateWindow (result) { } /** - * Returns the if-modified-since value to use when revalidating a stored - * response: its last-modified value if available, otherwise its date value, - * otherwise the local time the response was cached at. + * Returns the if-modified-since value for revalidating a stored response. * https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.3 * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result * @returns {string}