Skip to content

Commit c3f5591

Browse files
fredericDelaporteFrédéric Delaporte
andauthored
cache: fix excessive caching and some other lack of caching (#4335)
Co-authored-by: Frédéric Delaporte <fdelaporte@valap.com>
1 parent 431dfdc commit c3f5591

File tree

2 files changed

+197
-14
lines changed

2 files changed

+197
-14
lines changed

lib/handler/cache-handler.js

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
1515
200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
1616
]
1717

18+
// Status codes which semantic is not handled by the cache
19+
// https://datatracker.ietf.org/doc/html/rfc9111#section-3
20+
// This list should not grow beyond 206 and 304 unless the RFC is updated
21+
// by a newer one including more. Please introduce another list if
22+
// implementing caching of responses with the 'must-understand' directive.
23+
const NOT_UNDERSTOOD_STATUS_CODES = [
24+
206, 304
25+
]
26+
1827
const MAX_RESPONSE_AGE = 2147483647000
1928

2029
/**
@@ -241,10 +250,19 @@ class CacheHandler {
241250
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
242251
*/
243252
function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives) {
244-
// Allow caching for status codes 200 and 307 (original behavior)
245-
// Also allow caching for other status codes that are heuristically cacheable
246-
// when they have explicit cache directives
247-
if (statusCode !== 200 && statusCode !== 307 && !HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode)) {
253+
// Status code must be final and understood.
254+
if (statusCode < 200 || NOT_UNDERSTOOD_STATUS_CODES.includes(statusCode)) {
255+
return false
256+
}
257+
// Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching
258+
// directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3
259+
if (!HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) && !resHeaders['expires'] &&
260+
!cacheControlDirectives.public &&
261+
cacheControlDirectives['max-age'] === undefined &&
262+
// RFC 9111: a private response directive, if the cache is not shared
263+
!(cacheControlDirectives.private && cacheType === 'private') &&
264+
!(cacheControlDirectives['s-maxage'] !== undefined && cacheType === 'shared')
265+
) {
248266
return false
249267
}
250268

test/interceptors/cache.js

Lines changed: 175 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,8 +1437,11 @@ describe('Cache Interceptor', () => {
14371437
})
14381438
})
14391439

1440+
// Partial list.
14401441
const cacheableStatusCodes = [
14411442
{ code: 204, body: '' },
1443+
{ code: 302, body: 'Found' },
1444+
{ code: 307, body: 'Temporary Redirect' },
14421445
{ code: 404, body: 'Not Found' },
14431446
{ code: 410, body: 'Gone' }
14441447
]
@@ -1489,13 +1492,71 @@ describe('Cache Interceptor', () => {
14891492
})
14901493
}
14911494

1492-
test('does not cache non-heuristically cacheable error status codes', async () => {
1495+
// Partial list.
1496+
const nonHeuristicallyCacheableStatusCodes = [
1497+
{ code: 201, body: 'Created' },
1498+
{ code: 307, body: 'Temporary Redirect' },
1499+
{ code: 418, body: 'I am a teapot' }
1500+
]
1501+
1502+
for (const { code, body } of nonHeuristicallyCacheableStatusCodes) {
1503+
test(`does not cache non-heuristically cacheable status ${code} without explicit directive`, async () => {
1504+
let requestsToOrigin = 0
1505+
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
1506+
requestsToOrigin++
1507+
res.statusCode = code
1508+
// By default the response may have a date and last-modified header set to 'now',
1509+
// causing the cache to compute a 0 heuristic expiry, causing the test to not ascertain
1510+
// it is really not cached.
1511+
res.setHeader('date', '')
1512+
res.end(body)
1513+
}).listen(0)
1514+
1515+
const client = new Client(`http://localhost:${server.address().port}`)
1516+
.compose(interceptors.cache({ cacheByDefault: 60 }))
1517+
1518+
after(async () => {
1519+
server.close()
1520+
await client.close()
1521+
})
1522+
1523+
await once(server, 'listening')
1524+
1525+
equal(requestsToOrigin, 0)
1526+
1527+
const request = {
1528+
origin: 'localhost',
1529+
method: 'GET',
1530+
path: '/'
1531+
}
1532+
1533+
// First request should hit the origin
1534+
{
1535+
const res = await client.request(request)
1536+
equal(requestsToOrigin, 1)
1537+
equal(res.statusCode, code)
1538+
strictEqual(await res.body.text(), body)
1539+
}
1540+
1541+
// Second request should also hit the origin (not cached)
1542+
{
1543+
const res = await client.request(request)
1544+
equal(requestsToOrigin, 2) // Should be 2 (not cached)
1545+
equal(res.statusCode, code)
1546+
strictEqual(await res.body.text(), body)
1547+
}
1548+
})
1549+
}
1550+
1551+
test('discriminates caching of range requests, or does not cache them', async () => {
14931552
let requestsToOrigin = 0
1553+
const body = 'Fake range request response'
1554+
const code = 206
14941555
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
14951556
requestsToOrigin++
1496-
res.statusCode = 418 // I'm a teapot - not in heuristically cacheable list
1557+
res.statusCode = code
14971558
res.setHeader('cache-control', 'public, max-age=60')
1498-
res.end('I am a teapot')
1559+
res.end(body)
14991560
}).listen(0)
15001561

15011562
const client = new Client(`http://localhost:${server.address().port}`)
@@ -1513,23 +1574,127 @@ describe('Cache Interceptor', () => {
15131574
const request = {
15141575
origin: 'localhost',
15151576
method: 'GET',
1516-
path: '/'
1577+
path: '/',
1578+
headers: {
1579+
range: 'bytes=10-'
1580+
}
15171581
}
15181582

15191583
// First request should hit the origin
15201584
{
15211585
const res = await client.request(request)
15221586
equal(requestsToOrigin, 1)
1523-
equal(res.statusCode, 418)
1524-
strictEqual(await res.body.text(), 'I am a teapot')
1587+
equal(res.statusCode, code)
1588+
strictEqual(await res.body.text(), body)
15251589
}
15261590

1527-
// Second request should also hit the origin (not cached)
1591+
// Second request with different range should hit the origin too
1592+
request.headers.range = 'bytes=5-'
15281593
{
15291594
const res = await client.request(request)
1530-
equal(requestsToOrigin, 2) // Should be 2 (not cached)
1531-
equal(res.statusCode, 418)
1532-
strictEqual(await res.body.text(), 'I am a teapot')
1595+
equal(requestsToOrigin, 2)
1596+
equal(res.statusCode, code)
1597+
strictEqual(await res.body.text(), body)
1598+
}
1599+
})
1600+
1601+
test('discriminates caching of conditionnal requests (if-none-match), or does not cache them', async () => {
1602+
let requestsToOrigin = 0
1603+
const body = ''
1604+
const code = 304
1605+
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
1606+
requestsToOrigin++
1607+
res.statusCode = code
1608+
res.setHeader('cache-control', 'public, max-age=60')
1609+
res.end(body)
1610+
}).listen(0)
1611+
1612+
const client = new Client(`http://localhost:${server.address().port}`)
1613+
.compose(interceptors.cache())
1614+
1615+
after(async () => {
1616+
server.close()
1617+
await client.close()
1618+
})
1619+
1620+
await once(server, 'listening')
1621+
1622+
equal(requestsToOrigin, 0)
1623+
1624+
const request = {
1625+
origin: 'localhost',
1626+
method: 'GET',
1627+
path: '/',
1628+
headers: {
1629+
'if-none-match': 'some-etag'
1630+
}
1631+
}
1632+
1633+
// First request should hit the origin
1634+
{
1635+
const res = await client.request(request)
1636+
equal(requestsToOrigin, 1)
1637+
equal(res.statusCode, code)
1638+
strictEqual(await res.body.text(), body)
1639+
}
1640+
1641+
// Second request with different etag should hit the origin too
1642+
request.headers['if-none-match'] = 'another-etag'
1643+
{
1644+
const res = await client.request(request)
1645+
equal(requestsToOrigin, 2)
1646+
equal(res.statusCode, code)
1647+
strictEqual(await res.body.text(), body)
1648+
}
1649+
})
1650+
1651+
test('discriminates caching of conditionnal requests (if-modified-since), or does not cache them', async () => {
1652+
let requestsToOrigin = 0
1653+
const body = ''
1654+
const code = 304
1655+
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
1656+
requestsToOrigin++
1657+
res.statusCode = code
1658+
res.setHeader('cache-control', 'public, max-age=60')
1659+
res.end(body)
1660+
}).listen(0)
1661+
1662+
const client = new Client(`http://localhost:${server.address().port}`)
1663+
.compose(interceptors.cache())
1664+
1665+
after(async () => {
1666+
server.close()
1667+
await client.close()
1668+
})
1669+
1670+
await once(server, 'listening')
1671+
1672+
equal(requestsToOrigin, 0)
1673+
1674+
const request = {
1675+
origin: 'localhost',
1676+
method: 'GET',
1677+
path: '/',
1678+
headers: {
1679+
'if-modified-since': new Date().toUTCString()
1680+
}
1681+
}
1682+
1683+
// First request should hit the origin
1684+
{
1685+
const res = await client.request(request)
1686+
equal(requestsToOrigin, 1)
1687+
equal(res.statusCode, code)
1688+
strictEqual(await res.body.text(), body)
1689+
}
1690+
1691+
// Second request with different since should hit the origin too
1692+
request.headers['if-modified-since'] = new Date(0).toUTCString()
1693+
{
1694+
const res = await client.request(request)
1695+
equal(requestsToOrigin, 2)
1696+
equal(res.statusCode, code)
1697+
strictEqual(await res.body.text(), body)
15331698
}
15341699
})
15351700
})

0 commit comments

Comments
 (0)