Skip to content

Commit 9077500

Browse files
metalix2claude
andauthored
fix(cache): only apply 1-year deleteAt for immutable responses (#4913)
* fix(cache): only apply 1-year deleteAt for immutable responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(cache): add tests for determineDeleteAt behavior Verify that: - max-age responses get deleteAt proportional to freshness lifetime (not 1 year) - immutable responses get deleteAt of ~1 year - stale-while-revalidate correctly extends deleteAt beyond staleAt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1c5dc1a commit 9077500

2 files changed

Lines changed: 116 additions & 1 deletion

File tree

lib/handler/cache-handler.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,10 +493,18 @@ function determineDeleteAt (now, cacheControlDirectives, staleAt) {
493493
staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000)
494494
}
495495

496-
if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
496+
if (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
497497
immutable = now + 31536000000
498498
}
499499

500+
// When no stale directives or immutable flag, add a revalidation buffer
501+
// equal to the freshness lifetime so the entry survives past staleAt long
502+
// enough to be revalidated instead of silently disappearing.
503+
if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) {
504+
const freshnessLifetime = staleAt - now
505+
return staleAt + freshnessLifetime
506+
}
507+
500508
return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable)
501509
}
502510

test/interceptors/cache.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { equal, strictEqual, notEqual, fail } = require('node:assert')
77
const { setTimeout: sleep } = require('node:timers/promises')
88
const FakeTimers = require('@sinonjs/fake-timers')
99
const { Client, interceptors, cacheStores: { MemoryCacheStore } } = require('../../index')
10+
const { makeCacheKey } = require('../../lib/util/cache.js')
1011

1112
describe('Cache Interceptor', () => {
1213
test('caches request', async () => {
@@ -1984,4 +1985,110 @@ describe('Cache Interceptor', () => {
19841985
}
19851986
})
19861987
})
1988+
1989+
describe('determineDeleteAt', () => {
1990+
test('max-age response has deleteAt proportional to freshness lifetime, not 1 year', async () => {
1991+
const clock = FakeTimers.install({ now: 1000 })
1992+
after(() => clock.uninstall())
1993+
1994+
const store = new MemoryCacheStore()
1995+
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
1996+
res.setHeader('cache-control', 'public, max-age=60')
1997+
res.setHeader('date', new Date(clock.now).toUTCString())
1998+
res.sendDate = false
1999+
res.end('short-lived')
2000+
}).listen(0)
2001+
2002+
after(async () => {
2003+
server.close()
2004+
await client.close()
2005+
})
2006+
2007+
await once(server, 'listening')
2008+
2009+
const origin = `http://localhost:${server.address().port}`
2010+
const client = new Client(origin)
2011+
.compose(interceptors.cache({ store }))
2012+
2013+
const res = await client.request({ origin, method: 'GET', path: '/delete-at-maxage' })
2014+
strictEqual(await res.body.text(), 'short-lived')
2015+
2016+
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/delete-at-maxage', headers: {} }))
2017+
2018+
notEqual(cached, undefined)
2019+
// deleteAt should be approximately 2x max-age (staleAt + freshnessLifetime),
2020+
// not 1 year out
2021+
const maxExpected = clock.now + (60 * 1000 * 3) // generous upper bound
2022+
equal(cached.deleteAt < maxExpected, true, `deleteAt (${cached.deleteAt}) should be well under 3x max-age (${maxExpected})`)
2023+
equal(cached.deleteAt > cached.staleAt, true, 'deleteAt should be greater than staleAt to allow revalidation')
2024+
})
2025+
2026+
test('immutable response has deleteAt of ~1 year', async () => {
2027+
const clock = FakeTimers.install({ now: 1000 })
2028+
after(() => clock.uninstall())
2029+
2030+
const store = new MemoryCacheStore()
2031+
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
2032+
res.setHeader('cache-control', 'public, immutable')
2033+
res.setHeader('date', new Date(clock.now).toUTCString())
2034+
res.sendDate = false
2035+
res.end('immutable-content')
2036+
}).listen(0)
2037+
2038+
after(async () => {
2039+
server.close()
2040+
await client.close()
2041+
})
2042+
2043+
await once(server, 'listening')
2044+
2045+
const origin = `http://localhost:${server.address().port}`
2046+
const client = new Client(origin)
2047+
.compose(interceptors.cache({ store }))
2048+
2049+
const res = await client.request({ origin, method: 'GET', path: '/delete-at-immutable' })
2050+
strictEqual(await res.body.text(), 'immutable-content')
2051+
2052+
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/delete-at-immutable', headers: {} }))
2053+
2054+
notEqual(cached, undefined)
2055+
const oneYear = 31536000000
2056+
// deleteAt should be approximately 1 year out
2057+
equal(cached.deleteAt >= clock.now + oneYear - 1000, true, `deleteAt (${cached.deleteAt}) should be ~1 year out`)
2058+
})
2059+
2060+
test('stale-while-revalidate extends deleteAt beyond staleAt', async () => {
2061+
const clock = FakeTimers.install({ now: 1000 })
2062+
after(() => clock.uninstall())
2063+
2064+
const store = new MemoryCacheStore()
2065+
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
2066+
res.setHeader('cache-control', 'public, max-age=60, stale-while-revalidate=300')
2067+
res.setHeader('date', new Date(clock.now).toUTCString())
2068+
res.sendDate = false
2069+
res.end('swr-content')
2070+
}).listen(0)
2071+
2072+
after(async () => {
2073+
server.close()
2074+
await client.close()
2075+
})
2076+
2077+
await once(server, 'listening')
2078+
2079+
const origin = `http://localhost:${server.address().port}`
2080+
const client = new Client(origin)
2081+
.compose(interceptors.cache({ store }))
2082+
2083+
const res = await client.request({ origin, method: 'GET', path: '/delete-at-swr' })
2084+
strictEqual(await res.body.text(), 'swr-content')
2085+
2086+
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/delete-at-swr', headers: {} }))
2087+
2088+
notEqual(cached, undefined)
2089+
// deleteAt should be staleAt + stale-while-revalidate (300s)
2090+
const expectedDeleteAt = cached.staleAt + (300 * 1000)
2091+
equal(cached.deleteAt, expectedDeleteAt, `deleteAt (${cached.deleteAt}) should be staleAt + 300s (${expectedDeleteAt})`)
2092+
})
2093+
})
19872094
})

0 commit comments

Comments
 (0)