Bug Description
Composing the built-in retry interceptor before the built-in decompress
interceptor causes compressed responses to fail:
new Agent().compose([
interceptors.retry(),
interceptors.decompress()
])
RetryHandler passes a RetryController proxy to downstream handlers. That
proxy exposes rawHeaders through a getter, but it has no corresponding
setter. When DecompressHandler receives a compressed response, it removes
content-encoding and content-length from the raw header list by assigning a
new value to controller.rawHeaders. Because the proxy property is
getter-only, this assignment throws in strict mode:
TypeError: Cannot set property rawHeaders of #<RetryController> which has only a getter
The failure occurs even with maxRetries: 0, so no retry attempt is required
to trigger it.
Reproduction
Standalone reproduction script:
'use strict'
const assert = require('node:assert/strict')
const { once } = require('node:events')
const { createServer } = require('node:http')
const { test } = require('node:test')
const { gzipSync } = require('node:zlib')
const { Agent, interceptors, request } = require('undici')
test('retry can be composed before decompress', { timeout: 5000 }, async (t) => {
const compressedBody = gzipSync('ok')
const server = createServer((_req, res) => {
res.writeHead(200, {
'content-encoding': 'gzip',
'content-length': String(compressedBody.length)
})
res.end(compressedBody)
})
t.after(() => {
server.closeAllConnections?.()
server.close()
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const dispatcher = new Agent().compose([
interceptors.retry({ maxRetries: 0 }),
interceptors.decompress()
])
t.after(() => dispatcher.close())
const { statusCode, body } = await request(
`http://127.0.0.1:${server.address().port}`,
{
dispatcher,
headers: { 'accept-encoding': 'gzip' }
}
)
assert.equal(statusCode, 200)
assert.equal(await body.text(), 'ok')
})
Steps to reproduce (if not using the script above):
- Save the script as
test/repro-retry-decompress-rawheaders.js.
- Install
undici@8.10.0.
- Run:
node --test test/repro-retry-decompress-rawheaders.js
Expected Behavior
The request should resolve with status code 200, and the decompressed response
body should equal "ok". The documented built-in interceptors should compose
without throwing.
Actual Behavior
The request fails during response header processing, before the response is
delivered to the caller:
TypeError: Cannot set property rawHeaders of #<RetryController> which has only a getter
In a non-test process, cleanup can also emit the same error from the already
created Gunzip stream as an unhandled stream error.
Logs & Screenshots
✖ retry can be composed before decompress
TypeError: Cannot set property rawHeaders of #<RetryController> which has only a getter
at DecompressHandler.onResponseStart (undici/lib/interceptor/decompress.js:201:31)
at RetryHandler.onResponseStart (undici/lib/handler/retry-handler.js:364:37)
at Request.onResponseStart (undici/lib/core/request.js:345:39)
tests 1
pass 0
fail 1
Environment
- OS: macOS 26.2 (arm64)
- Node.js version: v24.11.1
- undici version: 8.10.0
Additional context
Bug Description
Composing the built-in
retryinterceptor before the built-indecompressinterceptor causes compressed responses to fail:
RetryHandlerpasses aRetryControllerproxy to downstream handlers. Thatproxy exposes
rawHeadersthrough a getter, but it has no correspondingsetter. When
DecompressHandlerreceives a compressed response, it removescontent-encodingandcontent-lengthfrom the raw header list by assigning anew value to
controller.rawHeaders. Because the proxy property isgetter-only, this assignment throws in strict mode:
The failure occurs even with
maxRetries: 0, so no retry attempt is requiredto trigger it.
Reproduction
Standalone reproduction script:
Steps to reproduce (if not using the script above):
test/repro-retry-decompress-rawheaders.js.undici@8.10.0.Expected Behavior
The request should resolve with status code
200, and the decompressed responsebody should equal
"ok". The documented built-in interceptors should composewithout throwing.
Actual Behavior
The request fails during response header processing, before the response is
delivered to the caller:
In a non-test process, cleanup can also emit the same error from the already
created Gunzip stream as an unhandled stream
error.Logs & Screenshots
Environment
Additional context
retrybeforedecompressinits composition examples:
https://github.com/nodejs/undici/blob/main/docs/docs/api/Interceptors.md#composing-multiple-interceptors
decompressbeforeretryavoids the exception,because
DecompressHandlerthen receives the nativeRequestController,whose
rawHeadersfield is writable.RetryControllerwas introduced infix(retry): keep flow-control wired to the active connection across resumes #5405. It forwards reads of
rawHeadersandrawTrailers, but not writes.controller.rawHeaders = filteredHeadersassignment are still present on the current
mainbranch.rawHeadersandrawTrailerssetters to the active target appearsto be one possible fix, together with a regression test for this documented
interceptor order.