Skip to content

Commit f1d50a2

Browse files
authored
fix: MockAgent delayed response with AbortSignal (#4693) (#4772)
When a MockAgent had a delayed response and the request was aborted via AbortSignal, the delayed response callback would still fire and attempt to call handler methods after onError had been called. This violated state invariants expected by handlers like DecoratorHandler (used by decompress interceptor). The fix: - Tracks abort state in mockDispatch - Clears pending setTimeout timer when abort is called - Checks abort flag in handleReply before invoking handler callbacks - Properly calls handler.onConnect to register abort callback Fixes #4693
1 parent 9948d6f commit f1d50a2

2 files changed

Lines changed: 172 additions & 2 deletions

File tree

lib/mock/mock-utils.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,16 +312,45 @@ function mockDispatch (opts, handler) {
312312
return true
313313
}
314314

315+
// Track whether the request has been aborted
316+
let aborted = false
317+
let timer = null
318+
319+
function abort (err) {
320+
if (aborted) {
321+
return
322+
}
323+
aborted = true
324+
325+
// Clear the pending delayed response if any
326+
if (timer !== null) {
327+
clearTimeout(timer)
328+
timer = null
329+
}
330+
331+
// Notify the handler of the abort
332+
handler.onError(err)
333+
}
334+
335+
// Call onConnect to allow the handler to register the abort callback
336+
handler.onConnect?.(abort, null)
337+
315338
// Handle the request with a delay if necessary
316339
if (typeof delay === 'number' && delay > 0) {
317-
setTimeout(() => {
340+
timer = setTimeout(() => {
341+
timer = null
318342
handleReply(this[kDispatches])
319343
}, delay)
320344
} else {
321345
handleReply(this[kDispatches])
322346
}
323347

324348
function handleReply (mockDispatches, _data = data) {
349+
// Don't send response if the request was aborted
350+
if (aborted) {
351+
return
352+
}
353+
325354
// fetch's HeadersList is a 1D string array
326355
const optsHeaders = Array.isArray(opts.headers)
327356
? buildHeadersFromArray(opts.headers)
@@ -340,11 +369,15 @@ function mockDispatch (opts, handler) {
340369
return body.then((newData) => handleReply(mockDispatches, newData))
341370
}
342371

372+
// Check again if aborted after async body resolution
373+
if (aborted) {
374+
return
375+
}
376+
343377
const responseData = getResponseData(body)
344378
const responseHeaders = generateKeyValues(headers)
345379
const responseTrailers = generateKeyValues(trailers)
346380

347-
handler.onConnect?.(err => handler.onError(err), null)
348381
handler.onHeaders?.(statusCode, responseHeaders, resume, getStatusText(statusCode))
349382
handler.onData?.(Buffer.from(responseData))
350383
handler.onComplete?.(responseTrailers)

test/mock-delayed-abort.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use strict'
2+
3+
const { test } = require('node:test')
4+
const { MockAgent, interceptors } = require('..')
5+
const DecoratorHandler = require('../lib/handler/decorator-handler')
6+
const { tspl } = require('@matteo.collina/tspl')
7+
8+
test('MockAgent with delayed response and AbortSignal should not cause uncaught errors', async (t) => {
9+
const p = tspl(t, { plan: 2 })
10+
11+
const agent = new MockAgent()
12+
t.after(() => agent.close())
13+
14+
const mockPool = agent.get('https://example.com')
15+
mockPool.intercept({ path: '/test', method: 'GET' })
16+
.reply(200, { success: true }, { headers: { 'content-type': 'application/json' } })
17+
.delay(100)
18+
19+
const ac = new AbortController()
20+
21+
// Abort the request after 10ms
22+
setTimeout(() => {
23+
ac.abort(new Error('Request aborted'))
24+
}, 10)
25+
26+
try {
27+
await agent.request({
28+
origin: 'https://example.com',
29+
path: '/test',
30+
method: 'GET',
31+
signal: ac.signal
32+
})
33+
p.fail('Should have thrown an error')
34+
} catch (err) {
35+
p.ok(err.message === 'Request aborted' || err.name === 'AbortError', 'Error should be related to abort')
36+
}
37+
38+
// Wait for the delayed response to fire - should not cause any uncaught errors
39+
await new Promise(resolve => setTimeout(resolve, 150))
40+
41+
p.ok(true, 'No uncaught errors after delayed response')
42+
})
43+
44+
test('MockAgent with delayed response and composed interceptor (decompress) should not cause uncaught errors', async (t) => {
45+
const p = tspl(t, { plan: 2 })
46+
47+
// The decompress interceptor has assertions that fail if onResponseStart is called after onError
48+
const agent = new MockAgent().compose(interceptors.decompress())
49+
t.after(() => agent.close())
50+
51+
const mockPool = agent.get('https://example.com')
52+
mockPool.intercept({ path: '/test', method: 'GET' })
53+
.reply(200, { success: true }, { headers: { 'content-type': 'application/json' } })
54+
.delay(100)
55+
56+
const ac = new AbortController()
57+
58+
// Abort the request after 10ms
59+
setTimeout(() => {
60+
ac.abort(new Error('Request aborted'))
61+
}, 10)
62+
63+
try {
64+
await agent.request({
65+
origin: 'https://example.com',
66+
path: '/test',
67+
method: 'GET',
68+
signal: ac.signal
69+
})
70+
p.fail('Should have thrown an error')
71+
} catch (err) {
72+
p.ok(err.message === 'Request aborted' || err.name === 'AbortError', 'Error should be related to abort')
73+
}
74+
75+
// Wait for the delayed response to fire - should not cause any uncaught errors
76+
await new Promise(resolve => setTimeout(resolve, 150))
77+
78+
p.ok(true, 'No uncaught errors after delayed response')
79+
})
80+
81+
test('MockAgent with delayed response and DecoratorHandler should not call onResponseStart after onError', async (t) => {
82+
const p = tspl(t, { plan: 2 })
83+
84+
class TestDecoratorHandler extends DecoratorHandler {
85+
#onErrorCalled = false
86+
87+
onResponseStart (controller, statusCode, headers, statusMessage) {
88+
if (this.#onErrorCalled) {
89+
p.fail('onResponseStart should not be called after onError')
90+
}
91+
return super.onResponseStart(controller, statusCode, headers, statusMessage)
92+
}
93+
94+
onResponseError (controller, err) {
95+
this.#onErrorCalled = true
96+
return super.onResponseError(controller, err)
97+
}
98+
}
99+
100+
const agent = new MockAgent()
101+
t.after(() => agent.close())
102+
103+
const mockPool = agent.get('https://example.com')
104+
mockPool.intercept({ path: '/test', method: 'GET' })
105+
.reply(200, { success: true }, { headers: { 'content-type': 'application/json' } })
106+
.delay(100)
107+
108+
const ac = new AbortController()
109+
110+
// Abort the request after 10ms
111+
setTimeout(() => {
112+
ac.abort(new Error('Request aborted'))
113+
}, 10)
114+
115+
const originalDispatch = agent.dispatch.bind(agent)
116+
agent.dispatch = (opts, handler) => {
117+
const decoratedHandler = new TestDecoratorHandler(handler)
118+
return originalDispatch(opts, decoratedHandler)
119+
}
120+
121+
try {
122+
await agent.request({
123+
origin: 'https://example.com',
124+
path: '/test',
125+
method: 'GET',
126+
signal: ac.signal
127+
})
128+
p.fail('Should have thrown an error')
129+
} catch (err) {
130+
p.ok(err.message === 'Request aborted' || err.name === 'AbortError', 'Error should be related to abort')
131+
}
132+
133+
// Wait for the delayed response to fire
134+
await new Promise(resolve => setTimeout(resolve, 150))
135+
136+
p.ok(true, 'Decorator handler invariants maintained')
137+
})

0 commit comments

Comments
 (0)