Skip to content

retry + decompress: RetryController exposes read-only rawHeaders, causing TypeError #5726

Description

@jesusgalhub

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):

  1. Save the script as test/repro-retry-decompress-rawheaders.js.
  2. Install undici@8.10.0.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions