'use strict'
// Reproduction for undici HTTP/2 timeout error-code behavior.
//
// Six scenarios, same server shape (first attempt stalls past the client
// timeout, subsequent attempts succeed), varying wire protocol and client.
const { createSecureServer } = require('node:http2')
const { createServer } = require('node:http')
const { once } = require('node:events')
const pem = require('@metcoder95/https-pem')
const { Client, Agent, RetryAgent, errors, request } = require('./index.js')
// -- servers ---------------------------------------------------------------
async function startH1Server ({ firstAttemptStallsMs }) {
let hits = 0
const server = createServer((req, res) => {
hits += 1
if (hits === 1) {
res.writeHead(200, { 'content-type': 'text/plain' })
res.flushHeaders()
// No body bytes yet - avoids RetryHandler's partial-body / range path.
setTimeout(() => { try { res.end('late') } catch {} }, firstAttemptStallsMs)
} else {
res.writeHead(200, { 'content-type': 'text/plain' })
res.end(`ok after ${hits} attempt(s)`)
}
})
server.listen(0)
await once(server, 'listening')
return { server, port: server.address().port, getHits: () => hits }
}
async function startH2Server ({ firstAttemptStallsMs, neverRespond = false }) {
let hits = 0
const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
server.on('stream', (stream) => {
hits += 1
if (neverRespond) {
// Force-close the stream well after the client's headersTimeout window
// so we can measure whether the client timed out on its own.
setTimeout(() => { try { stream.close() } catch {} }, firstAttemptStallsMs)
return
}
if (hits === 1) {
stream.respond({ ':status': 200, 'content-type': 'text/plain' })
setTimeout(() => { try { stream.end('late') } catch {} }, firstAttemptStallsMs)
} else {
stream.respond({ ':status': 200, 'content-type': 'text/plain' })
stream.end(`ok after ${hits} attempt(s)`)
}
})
server.listen(0)
await once(server, 'listening')
return { server, port: server.address().port, getHits: () => hits }
}
async function stop ({ server }) {
server.close()
await once(server, 'close')
}
// -- scenarios -------------------------------------------------------------
async function clientH1BodyTimeout () {
const s = await startH1Server({ firstAttemptStallsMs: 2_000 })
const client = new Client(`http://localhost:${s.port}`, {
bodyTimeout: 50, headersTimeout: 50
})
const start = Date.now()
let err = null
try {
const res = await client.request({ path: '/', method: 'GET' })
await res.body.text()
} catch (e) { err = e }
await client.close()
await stop(s)
return {
label: 'Client, HTTP/1.1, bodyTimeout=50',
hits: s.getHits(),
elapsed: Date.now() - start,
err
}
}
async function clientH2BodyTimeout () {
const s = await startH2Server({ firstAttemptStallsMs: 2_000 })
const client = new Client(`https://localhost:${s.port}`, {
connect: { rejectUnauthorized: false },
bodyTimeout: 50, headersTimeout: 50
})
const start = Date.now()
let err = null
try {
const res = await client.request({ path: '/', method: 'GET' })
await res.body.text()
} catch (e) { err = e }
await client.close()
await stop(s)
return {
label: 'Client, HTTP/2, bodyTimeout=50',
hits: s.getHits(),
elapsed: Date.now() - start,
err
}
}
async function clientH2HeadersTimeout () {
const s = await startH2Server({ firstAttemptStallsMs: 1_000, neverRespond: true })
const client = new Client(`https://localhost:${s.port}`, {
connect: { rejectUnauthorized: false },
bodyTimeout: 60_000, // long - we want headersTimeout to win
headersTimeout: 50
})
const start = Date.now()
let err = null
try {
const res = await client.request({ path: '/', method: 'GET' })
await res.body.text()
} catch (e) { err = e }
await client.close()
await stop(s)
return {
label: 'Client, HTTP/2, headersTimeout=50, bodyTimeout=60000',
hits: s.getHits(),
elapsed: Date.now() - start,
err
}
}
async function retryH1BodyTimeout () {
const s = await startH1Server({ firstAttemptStallsMs: 2_000 })
const agent = new RetryAgent(
new Agent({ bodyTimeout: 50, headersTimeout: 50 }),
{ maxRetries: 3, minTimeout: 10, errorCodes: ['UND_ERR_BODY_TIMEOUT'] }
)
const start = Date.now()
let err = null, body = null
try {
const res = await request(`http://localhost:${s.port}/`, {
dispatcher: agent, method: 'GET'
})
body = await res.body.text()
} catch (e) { err = e }
await agent.close()
await stop(s)
return {
label: 'RetryAgent, HTTP/1.1, errorCodes: [UND_ERR_BODY_TIMEOUT]',
hits: s.getHits(),
elapsed: Date.now() - start,
err,
body
}
}
async function retryH2BodyTimeout () {
const s = await startH2Server({ firstAttemptStallsMs: 2_000 })
const agent = new RetryAgent(
new Agent({
bodyTimeout: 50, headersTimeout: 50,
connect: { rejectUnauthorized: false }
}),
{ maxRetries: 3, minTimeout: 10, errorCodes: ['UND_ERR_BODY_TIMEOUT'] }
)
const start = Date.now()
let err = null, body = null
try {
const res = await request(`https://localhost:${s.port}/`, {
dispatcher: agent, method: 'GET'
})
body = await res.body.text()
} catch (e) { err = e }
await agent.close()
await stop(s)
return {
label: 'RetryAgent, HTTP/2, errorCodes: [UND_ERR_BODY_TIMEOUT]',
hits: s.getHits(),
elapsed: Date.now() - start,
err,
body
}
}
async function retryH2InfoWorkaround () {
const s = await startH2Server({ firstAttemptStallsMs: 2_000 })
const agent = new RetryAgent(
new Agent({
bodyTimeout: 50, headersTimeout: 50,
connect: { rejectUnauthorized: false }
}),
{ maxRetries: 3, minTimeout: 10, errorCodes: ['UND_ERR_INFO'] }
)
const start = Date.now()
let err = null, body = null
try {
const res = await request(`https://localhost:${s.port}/`, {
dispatcher: agent, method: 'GET'
})
body = await res.body.text()
} catch (e) { err = e }
await agent.close()
await stop(s)
return {
label: 'RetryAgent, HTTP/2, errorCodes: [UND_ERR_INFO] (broad workaround)',
hits: s.getHits(),
elapsed: Date.now() - start,
err,
body
}
}
// -- reporter --------------------------------------------------------------
function report (r) {
const bar = '-'.repeat(r.label.length + 4)
console.log(bar)
console.log(`| ${r.label} |`)
console.log(bar)
console.log(` server hits : ${r.hits}`)
console.log(` elapsed : ${r.elapsed}ms`)
if (r.err) {
console.log(` outcome : rejected`)
console.log(` err.code : ${r.err.code}`)
console.log(` err.name : ${r.err.name}`)
console.log(` err.message : ${r.err.message}`)
console.log(` is BodyTimeoutError : ${r.err instanceof errors.BodyTimeoutError}`)
console.log(` is HeadersTimeoutError: ${r.err instanceof errors.HeadersTimeoutError}`)
if (r.err.cause) {
console.log(` cause.code : ${r.err.cause.code}`)
console.log(` cause.message : ${r.err.cause.message}`)
}
} else {
console.log(` outcome : success`)
console.log(` body : ${JSON.stringify(r.body)}`)
}
console.log()
}
;(async () => {
console.log('# Client-level timeout error codes\n')
report(await clientH1BodyTimeout())
report(await clientH2BodyTimeout())
report(await clientH2HeadersTimeout())
console.log('# RetryAgent behavior with default timeout error codes\n')
report(await retryH1BodyTimeout())
report(await retryH2BodyTimeout())
report(await retryH2InfoWorkaround())
})().catch((err) => {
console.error('repro failed:', err)
process.exit(1)
})
Bug Description
Request timeouts against HTTP/2 origins reject with
InformationalError/UND_ERR_INFOinstead of the documentedBodyTimeoutError/UND_ERR_BODY_TIMEOUTandHeadersTimeoutError/UND_ERR_HEADERS_TIMEOUT. Also,headersTimeoutis not honored on HTTP/2.RetryAgent({ errorCodes: ['UND_ERR_BODY_TIMEOUT'] })does not work as expected if the connection is over h2.I was able to eject from the RetryAgent and use custom retry logic that looks at err.code and err.message, but that's probably not the desired outcome for users?
Reproducible By
[see script at the bottom]
Lengthy, but it shows 6 cases: 3 direct Client requests showing the raw error codes, 3 with RetryAgent, against both http1 and http2. (Opus 4.7 got a little fancy with the reporter.)
repro.js
Expected Behavior
Ideally, even over http2,
bodyTimeoutshould reject withBodyTimeoutError/UND_ERR_BODY_TIMEOUTandheadersTimeoutshould be honored and reject withHeadersTimeoutError/UND_ERR_HEADERS_TIMEOUT.At minimum the
Errors.mdandClient.mddocs should mention the differing behavior over http2.Logs & Screenshots
Environment
main)Additional context
test/issue-3356.jsshows theRetryAgent({ errorCodes: ['UND_ERR_BODY_TIMEOUT'] })pattern that doesn't work for h2.Existing tests in
test/http2-timeout.jsalready assert on the 'HTTP/2: "stream timeout after 50"' message, but not on a code.UND_ERR_INFOis also used widely for non-timeout "expected errors" (e.g.client.js:404,client-h1.js:928), so matching on it is not a safe workaround.repro.js