Skip to content

Commit cac98ab

Browse files
authored
fix: 304 not modified reply upon revalidation did not update cache. (#4617)
1 parent 6d912de commit cac98ab

File tree

4 files changed

+205
-318
lines changed

4 files changed

+205
-318
lines changed

lib/handler/cache-handler.js

Lines changed: 84 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
1717

1818
// Status codes which semantic is not handled by the cache
1919
// https://datatracker.ietf.org/doc/html/rfc9111#section-3
20-
// This list should not grow beyond 206 and 304 unless the RFC is updated
20+
// This list should not grow beyond 206 unless the RFC is updated
2121
// by a newer one including more. Please introduce another list if
2222
// implementing caching of responses with the 'must-understand' directive.
2323
const NOT_UNDERSTOOD_STATUS_CODES = [
24-
206, 304
24+
206
2525
]
2626

2727
const MAX_RESPONSE_AGE = 2147483647000
@@ -104,6 +104,7 @@ class CacheHandler {
104104
resHeaders,
105105
statusMessage
106106
)
107+
const handler = this
107108

108109
if (
109110
!util.safeHTTPMethods.includes(this.#cacheKey.method) &&
@@ -189,36 +190,92 @@ class CacheHandler {
189190
deleteAt
190191
}
191192

192-
if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
193-
value.etag = resHeaders.etag
194-
}
193+
// Not modified, re-use the cached value
194+
// https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified
195+
if (statusCode === 304) {
196+
/**
197+
* @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
198+
*/
199+
const cachedValue = this.#store.get(this.#cacheKey)
200+
if (!cachedValue) {
201+
// Do not create a new cache entry, as a 304 won't have a body - so cannot be cached.
202+
return downstreamOnHeaders()
203+
}
195204

196-
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
197-
if (!this.#writeStream) {
198-
return downstreamOnHeaders()
199-
}
205+
// Re-use the cached value: statuscode, statusmessage, headers and body
206+
value.statusCode = cachedValue.statusCode
207+
value.statusMessage = cachedValue.statusMessage
208+
value.etag = cachedValue.etag
209+
value.headers = { ...cachedValue.headers, ...strippedHeaders }
200210

201-
const handler = this
202-
this.#writeStream
203-
.on('drain', () => controller.resume())
204-
.on('error', function () {
205-
// TODO (fix): Make error somehow observable?
206-
handler.#writeStream = undefined
207-
208-
// Delete the value in case the cache store is holding onto state from
209-
// the call to createWriteStream
210-
handler.#store.delete(handler.#cacheKey)
211-
})
212-
.on('close', function () {
213-
if (handler.#writeStream === this) {
214-
handler.#writeStream = undefined
211+
downstreamOnHeaders()
212+
213+
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
214+
215+
if (!this.#writeStream || !cachedValue?.body) {
216+
return
217+
}
218+
219+
const bodyIterator = cachedValue.body.values()
220+
221+
const streamCachedBody = () => {
222+
for (const chunk of bodyIterator) {
223+
const full = this.#writeStream.write(chunk) === false
224+
this.#handler.onResponseData?.(controller, chunk)
225+
// when stream is full stop writing until we get a 'drain' event
226+
if (full) {
227+
break
228+
}
215229
}
230+
}
216231

217-
// TODO (fix): Should we resume even if was paused downstream?
218-
controller.resume()
219-
})
232+
this.#writeStream
233+
.on('error', function () {
234+
handler.#writeStream = undefined
235+
handler.#store.delete(handler.#cacheKey)
236+
})
237+
.on('drain', () => {
238+
streamCachedBody()
239+
})
240+
.on('close', function () {
241+
if (handler.#writeStream === this) {
242+
handler.#writeStream = undefined
243+
}
244+
})
245+
246+
streamCachedBody()
247+
} else {
248+
if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
249+
value.etag = resHeaders.etag
250+
}
220251

221-
return downstreamOnHeaders()
252+
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
253+
254+
if (!this.#writeStream) {
255+
return downstreamOnHeaders()
256+
}
257+
258+
this.#writeStream
259+
.on('drain', () => controller.resume())
260+
.on('error', function () {
261+
// TODO (fix): Make error somehow observable?
262+
handler.#writeStream = undefined
263+
264+
// Delete the value in case the cache store is holding onto state from
265+
// the call to createWriteStream
266+
handler.#store.delete(handler.#cacheKey)
267+
})
268+
.on('close', function () {
269+
if (handler.#writeStream === this) {
270+
handler.#writeStream = undefined
271+
}
272+
273+
// TODO (fix): Should we resume even if was paused downstream?
274+
controller.resume()
275+
})
276+
277+
downstreamOnHeaders()
278+
}
222279
}
223280

224281
onResponseData (controller, chunk) {

lib/interceptor/cache.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,34 @@ const nop = () => {}
1818
/**
1919
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
2020
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
21+
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
2122
* @returns {boolean}
2223
*/
23-
function needsRevalidation (result, cacheControlDirectives) {
24+
function needsRevalidation (result, cacheControlDirectives, { headers = {} }) {
25+
// Always revalidate requests with the no-cache request directive.
2426
if (cacheControlDirectives?.['no-cache']) {
25-
// Always revalidate requests with the no-cache request directive
2627
return true
2728
}
2829

30+
// Always revalidate requests with unqualified no-cache response directive.
2931
if (result.cacheControlDirectives?.['no-cache'] && !Array.isArray(result.cacheControlDirectives['no-cache'])) {
30-
// Always revalidate requests with unqualified no-cache response directive
3132
return true
3233
}
3334

35+
// Always revalidate requests with conditional headers.
36+
if (headers['if-modified-since'] || headers['if-none-match']) {
37+
return true
38+
}
39+
40+
return false
41+
}
42+
43+
/**
44+
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
45+
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
46+
* @returns {boolean}
47+
*/
48+
function isStale (result, cacheControlDirectives) {
3449
const now = Date.now()
3550
if (now > result.staleAt) {
3651
// Response is stale
@@ -241,17 +256,20 @@ function handleResult (
241256
return dispatch(opts, handler)
242257
}
243258

259+
const stale = isStale(result, reqCacheControl)
260+
const revalidate = needsRevalidation(result, reqCacheControl, opts)
261+
244262
// Check if the response is stale
245-
if (needsRevalidation(result, reqCacheControl)) {
263+
if (stale || revalidate) {
246264
if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
247265
// If body is a stream we can't revalidate...
248266
// TODO (fix): This could be less strict...
249267
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
250268
}
251269

252270
// RFC 5861: If we're within stale-while-revalidate window, serve stale immediately
253-
// and revalidate in background
254-
if (withinStaleWhileRevalidateWindow(result)) {
271+
// and revalidate in background, unless immediate revalidation is necessary
272+
if (!revalidate && withinStaleWhileRevalidateWindow(result)) {
255273
// Serve stale response immediately
256274
sendCachedValue(handler, opts, result, age, null, true)
257275

@@ -325,7 +343,8 @@ function handleResult (
325343
new CacheRevalidationHandler(
326344
(success, context) => {
327345
if (success) {
328-
sendCachedValue(handler, opts, result, age, context, true)
346+
// TODO: successful revalidation should be considered fresh (not give stale warning).
347+
sendCachedValue(handler, opts, result, age, context, stale)
329348
} else if (util.isStream(result.body)) {
330349
result.body.on('error', nop).destroy()
331350
}

test/interceptors/cache-revalidate-stale.js

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use strict'
22

3-
const { test, after } = require('node:test')
3+
const { test, after, describe } = require('node:test')
44
const { strictEqual } = require('node:assert')
55
const { createServer } = require('node:http')
66
const { once } = require('node:events')
77
const { request, Client, interceptors } = require('../../index')
88
const FakeTimers = require('@sinonjs/fake-timers')
9+
const { setTimeout } = require('node:timers/promises')
910

1011
test('revalidates the request when the response is stale', async () => {
1112
const clock = FakeTimers.install({
@@ -67,3 +68,92 @@ test('revalidates the request when the response is stale', async () => {
6768
strictEqual(await res.body.text(), 'hello world 2')
6869
}
6970
})
71+
72+
describe('revalidates the request, handles 304s during stale-while-revalidate', async () => {
73+
function isStale (res) {
74+
return res.headers.warning === '110 - "response is stale"'
75+
}
76+
77+
async function revalidateTest (useEtag = false) {
78+
const clock = FakeTimers.install({
79+
now: 1
80+
})
81+
after(() => clock.uninstall())
82+
83+
let count200 = 0
84+
let count304 = 0
85+
86+
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
87+
res.sendDate = false
88+
res.setHeader('Date', new Date(clock.now).toUTCString())
89+
res.setHeader('Cache-Control', 'public, max-age=10, stale-while-revalidate=3600')
90+
if (useEtag) {
91+
res.setHeader('ETag', '"xxx"')
92+
}
93+
94+
// revalidation response.
95+
if (req.headers['if-none-match'] || req.headers['if-modified-since']) {
96+
count304++
97+
res.statusCode = 304
98+
res.end()
99+
} else {
100+
res.end('hello world ' + count200++)
101+
}
102+
})
103+
104+
server.listen(0)
105+
await once(server, 'listening')
106+
107+
const dispatcher = new Client(`http://localhost:${server.address().port}`)
108+
.compose(interceptors.cache())
109+
110+
after(async () => {
111+
server.close()
112+
await dispatcher.close()
113+
})
114+
115+
const url = `http://localhost:${server.address().port}`
116+
117+
// initial request, cache the response
118+
{
119+
const res = await request(url, { dispatcher })
120+
strictEqual(await res.body.text(), 'hello world 0')
121+
strictEqual(isStale(res), false)
122+
strictEqual(res.statusCode, 200)
123+
}
124+
125+
// wait nearly a second, still fresh
126+
{
127+
clock.tick(900)
128+
const res = await request(url, { dispatcher })
129+
strictEqual(await res.body.text(), 'hello world 0')
130+
strictEqual(isStale(res), false)
131+
strictEqual(res.statusCode, 200)
132+
}
133+
134+
// within stale-while-revalidate window, still return stale cached response, revalidate in background
135+
{
136+
clock.tick(12000)
137+
const res = await request(url, { dispatcher })
138+
strictEqual(await res.body.text(), 'hello world 0')
139+
strictEqual(isStale(res), true)
140+
strictEqual(res.statusCode, 200)
141+
await setTimeout(100) // wait for revalidation to be complete.
142+
}
143+
144+
// should get revalidated content, not stale.
145+
{
146+
clock.tick(10)
147+
const res = await request(url, { dispatcher })
148+
strictEqual(await res.body.text(), 'hello world 0')
149+
strictEqual(isStale(res), false)
150+
strictEqual(res.statusCode, 200)
151+
}
152+
153+
strictEqual(count200, 1)
154+
strictEqual(count304, 1)
155+
}
156+
157+
test('using if-none-match', async () => await revalidateTest(true))
158+
test('using if-modified-since', async () => await revalidateTest(false))
159+
})

0 commit comments

Comments
 (0)