Skip to content

Commit 593530f

Browse files
authored
test(retry): add regression test for RetryAgent + HTTP/2 stream timeout (#5137) (#5176)
Regression test for #5137. Verifies that RetryAgent with a custom retry callback on an HTTP/2 connection correctly retries after a stream timeout and rejects after exhausting all retries, instead of hanging forever. The hang was caused by double-decrementing kOpenStreams on stream timeout (fixed in commit 79b7ebd).
1 parent 688748a commit 593530f

1 file changed

Lines changed: 64 additions & 0 deletions

File tree

test/issue-5137.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict'
2+
3+
const { tspl } = require('@matteo.collina/tspl')
4+
const { test, after } = require('node:test')
5+
const { createSecureServer } = require('node:http2')
6+
const { once } = require('node:events')
7+
8+
const pem = require('@metcoder95/https-pem')
9+
10+
const { RetryAgent, Client } = require('..')
11+
12+
// Regression test for https://github.com/nodejs/undici/issues/5137
13+
// RetryAgent with a custom retry callback on an HTTP/2 connection: after a
14+
// stream timeout (UND_ERR_INFO), calling callback(null) to retry caused
15+
// client.request() to hang forever. The root cause was that the stream
16+
// timeout handler double-decremented kOpenStreams (both the 'timeout' and
17+
// 'close' paths decremented), so after the first timeout the counter went
18+
// negative and the follow-up retry request was never dispatched.
19+
test('RetryAgent rejects after exhausting retries on HTTP/2 stream timeout', async t => {
20+
t = tspl(t, { plan: 2 })
21+
22+
const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
23+
24+
let streamCount = 0
25+
server.on('stream', (stream) => {
26+
streamCount++
27+
// Never respond — simulates a perpetual stream timeout
28+
})
29+
30+
after(() => server.close())
31+
await once(server.listen(0), 'listening')
32+
33+
const client = new RetryAgent(
34+
new Client(`https://localhost:${server.address().port}`, {
35+
connect: { rejectUnauthorized: false },
36+
allowH2: true,
37+
bodyTimeout: 50
38+
}),
39+
{
40+
maxRetries: 3,
41+
retryAfter: true,
42+
retry: (err, { state }, callback) => {
43+
// Exhaust all retries, then reject with the last error
44+
if (state.counter >= 3) {
45+
callback(err)
46+
} else {
47+
callback(null)
48+
}
49+
}
50+
}
51+
)
52+
after(() => client.close())
53+
54+
// The request itself should reject after exhausting retries
55+
await t.rejects(client.request({ path: '/', method: 'GET' }), {
56+
code: 'UND_ERR_INFO',
57+
message: /stream timeout/
58+
})
59+
60+
// Verify that all 3 retries actually reached the server
61+
t.equal(streamCount, 3, 'server should have received all 3 request attempts')
62+
63+
await t.completed
64+
})

0 commit comments

Comments
 (0)