Skip to content
Closed
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
16 changes: 10 additions & 6 deletions lib/util/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,8 @@ function assertCacheMethods (methods, name = 'CacheMethods') {
* @returns {string}
*/
function makeDeduplicationKey (cacheKey, excludeHeaders) {
// Create a deterministic string key from the cache key
// Include origin, method, path, and sorted headers
let key = `${cacheKey.origin}:${cacheKey.method}:${cacheKey.path}`
/** @type {[string, string | string[]][]} */
const headers = []

if (cacheKey.headers) {
const sortedHeaders = Object.keys(cacheKey.headers).sort()
Expand All @@ -385,12 +384,17 @@ function makeDeduplicationKey (cacheKey, excludeHeaders) {
if (excludeHeaders?.has(header.toLowerCase())) {
continue
}
const value = cacheKey.headers[header]
key += `:${header}=${Array.isArray(value) ? value.join(',') : value}`

headers.push([header, cacheKey.headers[header]])
}
}

return key
return JSON.stringify([
cacheKey.origin,
cacheKey.method,
cacheKey.path,
headers
])
}

module.exports = {
Expand Down
27 changes: 26 additions & 1 deletion test/cache-interceptor/cache-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const { tspl } = require('@matteo.collina/tspl')
const { test } = require('node:test')
const { normalizeHeaders } = require('../../lib/util/cache')
const { makeDeduplicationKey, normalizeHeaders } = require('../../lib/util/cache')

test('normalizeHeaders handles plain object headers with polluted Object.prototype[Symbol.iterator]', (t) => {
const { strictEqual } = tspl(t, { plan: 2 })
Expand Down Expand Up @@ -42,3 +42,28 @@ test('normalizeHeaders handles headers from Map', (t) => {

strictEqual(headers['x-test'], 'ok')
})

test('makeDeduplicationKey does not collide when headers contain delimiters', (t) => {
const { strictEqual } = tspl(t, { plan: 1 })

const keyWithDelimitedValue = makeDeduplicationKey({
origin: 'https://example.com',
method: 'GET',
path: '/',
headers: {
a: 'x:b=y'
}
})

const keyWithExtraHeader = makeDeduplicationKey({
origin: 'https://example.com',
method: 'GET',
path: '/',
headers: {
a: 'x',
b: 'y'
}
})

strictEqual(keyWithDelimitedValue === keyWithExtraHeader, false)
})
49 changes: 49 additions & 0 deletions test/interceptors/deduplicate.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,55 @@ describe('Deduplicate Interceptor', () => {
strictEqual(body2, 'response for br')
})

test('does not deduplicate requests when header delimiters would previously collide', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
requestsToOrigin++
await sleep(100)
res.end(`a=${req.headers.a};b=${req.headers.b ?? ''}`)
}).listen(0)

const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.deduplicate())

after(async () => {
server.close()
await client.close()
})

await once(server, 'listening')

const [res1, res2] = await Promise.all([
client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
a: 'x:b=y'
}
}),
client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
a: 'x',
b: 'y'
}
})
])

strictEqual(requestsToOrigin, 2)

const [body1, body2] = await Promise.all([
res1.body.text(),
res2.body.text()
])

strictEqual(body1, 'a=x:b=y;b=')
strictEqual(body2, 'a=x;b=y')
})

test('does not deduplicate requests with different paths', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
Expand Down
Loading