Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/handler/cache-revalidation-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
140 changes: 139 additions & 1 deletion test/interceptors/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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.
Expand Down
Loading