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:
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.
Bug Description
onBodySent/onRequestSentare not forwarded to the wrapped handler byDecoratorHandleror by the built-in
CacheHandler. Because every built-in interceptor either extendsDecoratorHandler(RedirectHandler,RetryHandler, …) or hand-forwards the handlermethods (
CacheHandler), composing any interceptor onto a dispatcher silently disablesthese 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 tothis.#handler.This is worse than omitting it:
Requestsees a truthyhandler.onBodySent, calls it,and the chunk is swallowed with no error.
onRequestSentis not declared at all, soRequest#onRequestSent()skips it.In
lib/handler/cache-handler.js(v8.10.0),CacheHandlerforwardsonRequestStart,onRequestUpgrade,onResponseStart,onResponseData,onResponseEnd, andonResponseError, but has noonBodySent/onRequestSent, sointerceptors.cache()drops them too.
These hooks were restored in 8.10.0 (including for
MockAgent) by #5367, and they workcorrectly when a handler is passed straight to
Client#dispatch— the decorator/interceptorpath was just missed.
Reproduction
Standalone reproduction script:
Run with:
Expected Behavior
Wrapping a handler in
DecoratorHandler(directly or via any built-in interceptor such asinterceptors.retry(),interceptors.redirect(), orinterceptors.cache()) should betransparent:
onBodySentandonRequestSentshould reach the wrapped handler exactly asthey do when the handler is passed straight to
Client#dispatch.Actual Behavior
The wrapped handler's
onBodySentis never called (DecoratorHandler's no-op absorbs it)and
onRequestSentis never called (not implemented). No error or warning is emitted — thehooks 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:
Environment
Additional context
We hit this with a request/response logging handler that buffers the outgoing body from
onBodySent. It worked against a bareClient, but request bodies came back empty for everyclient that also composed
interceptors.cache().Note that
onBodySentonDecoratorHandleris still marked@deprecated. If the intent isto drop these hooks entirely, it would be useful to have a documented replacement for
observing the outgoing request body from a handler, since
onRequestStartdoesn't expose it.