Skip to content

DecoratorHandler and CacheHandler do not forward onBodySent/onRequestSent to the wrapped handler #5695

Description

@tjhiggins

Bug Description

onBodySent / onRequestSent are not forwarded to the wrapped handler by DecoratorHandler
or by the built-in CacheHandler. Because every built-in interceptor either extends
DecoratorHandler (RedirectHandler, RetryHandler, …) or hand-forwards the handler
methods (CacheHandler), composing any interceptor onto a dispatcher silently disables
these two hooks for the user's handler.

In lib/handler/decorator-handler.js (v8.10.0):

  • onBodySent () {} is declared as a no-op rather than forwarding to this.#handler.
    This is worse than omitting it: Request sees a truthy handler.onBodySent, calls it,
    and the chunk is swallowed with no error.
  • onRequestSent is not declared at all, so Request#onRequestSent() skips it.

In lib/handler/cache-handler.js (v8.10.0), CacheHandler forwards onRequestStart,
onRequestUpgrade, onResponseStart, onResponseData, onResponseEnd, and
onResponseError, but has no onBodySent / onRequestSent, so interceptors.cache()
drops them too.

These hooks were restored in 8.10.0 (including for MockAgent) by #5367, and they work
correctly when a handler is passed straight to Client#dispatch — the decorator/interceptor
path was just missed.

Reproduction

Standalone reproduction script:

'use strict'

const { test } = require('node:test')
const assert = require('node:assert')
const { createServer } = require('node:http')
const { once } = require('node:events')
const { Client, DecoratorHandler, interceptors } = require('undici')

const BODY = '{"hello":"world"}'

function dispatchAndTrack (dispatcher) {
  const seen = { bodySent: [], requestSent: 0 }
  return new Promise((resolve, reject) => {
    dispatcher.dispatch(
      {
        method: 'POST',
        path: '/',
        headers: { 'content-type': 'application/json' },
        body: BODY
      },
      {
        onRequestStart () {},
        onBodySent (chunk) { seen.bodySent.push(Buffer.from(chunk).toString()) },
        onRequestSent () { seen.requestSent++ },
        onResponseStart () {},
        onResponseData () {},
        onResponseEnd () { resolve(seen) },
        onResponseError (_controller, err) { reject(err) }
      }
    )
  })
}

test('onBodySent/onRequestSent are dropped by DecoratorHandler and cache interceptor', { timeout: 60000 }, async (t) => {
  const server = createServer((req, res) => {
    req.resume()
    req.on('end', () => res.end('ok'))
  })
  server.listen(0)
  await once(server, 'listening')
  t.after(() => server.close())

  const client = new Client(`http://localhost:${server.address().port}`)
  t.after(() => client.close())

  // 1. Baseline: handler passed straight to the client — hooks fire.
  const direct = await dispatchAndTrack(client)
  assert.deepStrictEqual(direct.bodySent, [BODY])
  assert.strictEqual(direct.requestSent, 1)

  // 2. Handler wrapped in DecoratorHandler (what every built-in interceptor does).
  const decorated = client.compose(
    (dispatch) => (opts, handler) => dispatch(opts, new DecoratorHandler(handler))
  )
  const throughDecorator = await dispatchAndTrack(decorated)
  assert.deepStrictEqual(throughDecorator.bodySent, [BODY]) // ❌ actual: []
  assert.strictEqual(throughDecorator.requestSent, 1)       // ❌ actual: 0

  // 3. Same thing via a public interceptor (RetryHandler extends DecoratorHandler).
  const retrying = client.compose(interceptors.retry())
  const throughRetry = await dispatchAndTrack(retrying)
  assert.deepStrictEqual(throughRetry.bodySent, [BODY])     // ❌ actual: []
  assert.strictEqual(throughRetry.requestSent, 1)           // ❌ actual: 0
})

Run with:

node --test repro.js

Expected Behavior

Wrapping a handler in DecoratorHandler (directly or via any built-in interceptor such as
interceptors.retry(), interceptors.redirect(), or interceptors.cache()) should be
transparent: onBodySent and onRequestSent should reach the wrapped handler exactly as
they do when the handler is passed straight to Client#dispatch.

Actual Behavior

The wrapped handler's onBodySent is never called (DecoratorHandler's no-op absorbs it)
and onRequestSent is never called (not implemented). No error or warning is emitted — the
hooks just silently stop firing as soon as any interceptor is composed onto the dispatcher.

Logs & Screenshots

Assertion output from step 2 of the script above:

AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected

+ []
- [
-   '{"hello":"world"}'
- ]

Environment

  • OS:
  • Node.js version:
  • undici version: 8.10.0

Additional context

We hit this with a request/response logging handler that buffers the outgoing body from
onBodySent. It worked against a bare Client, but request bodies came back empty for every
client that also composed interceptors.cache().

Note that onBodySent on DecoratorHandler is still marked @deprecated. If the intent is
to drop these hooks entirely, it would be useful to have a documented replacement for
observing the outgoing request body from a handler, since onRequestStart doesn't expose it.

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