Skip to content

HTTP/2 timeouts reject with UND_ERR_INFO; RetryAgent errorCodes for UND_ERR_BODY_TIMEOUT / UND_ERR_HEADERS_TIMEOUT no longer match #5087

@tbeseda

Description

@tbeseda

Bug Description

Request timeouts against HTTP/2 origins reject with InformationalError / UND_ERR_INFO instead of the documented BodyTimeoutError / UND_ERR_BODY_TIMEOUT and HeadersTimeoutError / UND_ERR_HEADERS_TIMEOUT. Also, headersTimeout is 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, bodyTimeout should reject with BodyTimeoutError / UND_ERR_BODY_TIMEOUT and headersTimeout should be honored and reject with HeadersTimeoutError / UND_ERR_HEADERS_TIMEOUT.

At minimum the Errors.md and Client.md docs should mention the differing behavior over http2.

Logs & Screenshots

➜ node repro.js
# Client-level timeout error codes

------------------------------------
| Client, HTTP/1.1, bodyTimeout=50 |
------------------------------------
  server hits   : 1
  elapsed       : 1009ms
  outcome       : rejected
  err.code      : UND_ERR_BODY_TIMEOUT
  err.name      : BodyTimeoutError
  err.message   : Body Timeout Error
  is BodyTimeoutError   : true
  is HeadersTimeoutError: false

------------------------------------
| Client, HTTP/2,   bodyTimeout=50 |
------------------------------------
  server hits   : 1
  elapsed       : 63ms
  outcome       : rejected
  err.code      : UND_ERR_INFO
  err.name      : InformationalError
  err.message   : HTTP/2: "stream timeout after 50"
  is BodyTimeoutError   : false
  is HeadersTimeoutError: false

----------------------------------------------------------
| Client, HTTP/2,   headersTimeout=50, bodyTimeout=60000 |
----------------------------------------------------------
  server hits   : 1
  elapsed       : 1006ms
  outcome       : rejected
  err.code      : UND_ERR_INFO
  err.name      : InformationalError
  err.message   : HTTP/2: stream half-closed (remote)
  is BodyTimeoutError   : false
  is HeadersTimeoutError: false

# RetryAgent behavior with default timeout error codes

------------------------------------------------------------
| RetryAgent, HTTP/1.1, errorCodes: [UND_ERR_BODY_TIMEOUT] |
------------------------------------------------------------
  server hits   : 2
  elapsed       : 1019ms
  outcome       : success
  body          : "ok after 2 attempt(s)"

------------------------------------------------------------
| RetryAgent, HTTP/2,   errorCodes: [UND_ERR_BODY_TIMEOUT] |
------------------------------------------------------------
  server hits   : 1
  elapsed       : 56ms
  outcome       : rejected
  err.code      : UND_ERR_INFO
  err.name      : InformationalError
  err.message   : HTTP/2: "stream timeout after 50"
  is BodyTimeoutError   : false
  is HeadersTimeoutError: false

-----------------------------------------------------------------------
| RetryAgent, HTTP/2,   errorCodes: [UND_ERR_INFO] (broad workaround) |
-----------------------------------------------------------------------
  server hits   : 2
  elapsed       : 67ms
  outcome       : success
  body          : "ok after 2 attempt(s)"

Environment

  • undici: 8.1.0 (main)
  • Node.js: v24.14.1
  • OS: macOS

Additional context

test/issue-3356.js shows the RetryAgent({ errorCodes: ['UND_ERR_BODY_TIMEOUT'] }) pattern that doesn't work for h2.
Existing tests in test/http2-timeout.js already assert on the 'HTTP/2: "stream timeout after 50"' message, but not on a code.
UND_ERR_INFO is 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
'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)
})

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