diff --git a/lib/handler/cache-revalidation-handler.js b/lib/handler/cache-revalidation-handler.js index 393d16d52c6..8bd8e7a502d 100644 --- a/lib/handler/cache-revalidation-handler.js +++ b/lib/handler/cache-revalidation-handler.js @@ -109,6 +109,16 @@ class CacheRevalidationHandler { } if (this.#callback) { + // Serve the stale cached response on a connection error, per stale-if-error: + // RFC 5861 counts an unreachable origin (a would-be 5xx) as an error. + // https://datatracker.ietf.org/doc/html/rfc5861#section-4 + if (this.#allowErrorStatusCodes) { + this.#successful = true + this.#callback(true, this.#context) + this.#callback = null + return + } + this.#callback(false) this.#callback = null } diff --git a/test/interceptors/cache.js b/test/interceptors/cache.js index d429f8004de..39a659fd36d 100644 --- a/test/interceptors/cache.js +++ b/test/interceptors/cache.js @@ -3,7 +3,7 @@ const { createServer } = require('node:http') const { describe, test, after } = require('node:test') const { once } = require('node:events') -const { equal, strictEqual, notEqual, fail } = require('node:assert') +const { equal, strictEqual, notEqual, fail, rejects } = require('node:assert') const { setTimeout: sleep } = require('node:timers/promises') const FakeTimers = require('@sinonjs/fake-timers') const { Client, interceptors, cacheStores: { MemoryCacheStore, SqliteCacheStore } } = require('../../index') @@ -810,6 +810,73 @@ describe('Cache Interceptor', () => { } }) + test('stale-if-error (response) on connection error', async () => { + const clock = FakeTimers.install({ + toFake: ['Date'] + }) + + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('date', 0) + res.setHeader('cache-control', 'public, s-maxage=10, stale-if-error=20') + res.end('asd') + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + clock.uninstall() + if (server.listening) { + server.close() + } + await client.close() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + /** + * @type {import('../../types/dispatcher').default.RequestOptions} + */ + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // Send first request. This will hit the origin and succeed + { + const response = await client.request(request) + equal(requestsToOrigin, 1) + equal(response.statusCode, 200) + equal(await response.body.text(), 'asd') + } + + // Take the origin down so revalidation attempts hit a connection error + server.close() + server.closeAllConnections() + await once(server, 'close') + + clock.tick(15000) + + // Stale response, origin unreachable, but within stale-if-error: still served. + { + const response = await client.request(request) + equal(requestsToOrigin, 1) + equal(response.statusCode, 200) + equal(await response.body.text(), 'asd') + } + + clock.tick(25000) + + // Send third request. We're now outside the stale-if-error threshold so + // the connection error should be propagated. + await rejects(client.request(request)) + }) + describe('Client-side directives', () => { test('max-age', async () => { const clock = FakeTimers.install({ @@ -1443,6 +1510,77 @@ describe('Cache Interceptor', () => { equal(response.statusCode, 500) } }) + + test('stale-if-error on connection error', async () => { + const clock = FakeTimers.install({ + toFake: ['Date'] + }) + + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('date', 0) + res.setHeader('cache-control', 'public, s-maxage=10') + res.end('asd') + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + clock.uninstall() + if (server.listening) { + server.close() + } + await client.close() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + // Send first request. This will hit the origin and succeed + { + const response = await client.request({ + origin: 'localhost', + path: '/', + method: 'GET' + }) + equal(requestsToOrigin, 1) + equal(response.statusCode, 200) + equal(await response.body.text(), 'asd') + } + + // Take the origin down so revalidation attempts hit a connection error + server.close() + server.closeAllConnections() + await once(server, 'close') + + clock.tick(15000) + + // Stale response, origin unreachable, but request allows stale-if-error: still served. + { + const response = await client.request({ + origin: 'localhost', + path: '/', + method: 'GET', + headers: { + 'cache-control': 'stale-if-error=20' + } + }) + equal(requestsToOrigin, 1) + equal(response.statusCode, 200) + equal(await response.body.text(), 'asd') + } + + // Send third request without stale-if-error. The connection error + // should be propagated. + await rejects(client.request({ + origin: 'localhost', + path: '/', + method: 'GET' + })) + }) }) // Partial list.