Skip to content

HTTP/2: concurrent in-flight requests reject with UND_ERR_INFO ("frameError" type 1 code 2) and are never requeued on a mid-burst GOAWAY (8.5.0 / main; regressed from 8.3.0) #5468

Description

@evk-soft

Bug Description

Since the HTTP/2 multiplexing change (#5362 / #5391, ~8.4.0), a GOAWAY received mid-burst while many requests are multiplexed on a single session makes a large fraction of the in-flight requests reject with:

InformationalError: HTTP/2: "frameError" received - type 1, code 2   (code: UND_ERR_INFO)

They are never requeued/retried — they do not even reach the server, although they are provably unprocessed (the server sent GOAWAY with lastStreamId = 0). On 8.3.0 the identical scenario fully recovers (undici re-dispatches the unprocessed streams via onHttp2SessionGoAway).

In the reproduction below the server only ever sees 10 of 40 streams on 8.5.0 — ~30 requests never leave the client. Raising maxRetries does not help (each retry re-hits the same path; the server stream count does not increase).

Reproducible By

  1. npm i undici@8.5.0
  2. Save the script below as repro.mjs and run node repro.mjs.
  3. Observe ok=9/40 on 8.5.0 (and main), vs ok=38/40 on 8.3.0.

Self-contained; only depends on undici (a throwaway localhost self-signed cert is embedded; rejectUnauthorized:false is set):

import { createSecureServer, constants } from 'node:http2'
import { Pool } from 'undici'

const KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAlcy7hGIX1PkIQoO/R405A4rOPHZC1xOEygCYtHIB3IAPeeuq
QooAMoQs8LjtW32mPTjvoVib5sdPsNys3uOXHwdGI90rWhVJedlvpSHSm8BlFQSi
0/tZSFXu73MOnpnx7eJVID/C/YvSgeXF8oW3B0hdIU480KFMZNFGzGMXNZqfKCQ0
qHpaGNmqxbnlae/3DpBWafwqdG3/Pmel+ldqVrlvkMwYxBuQls2VkX1ZX4oclx/R
ZoCukWVpnK0mZRplJnv8Qs89FuFXhF6GKGKbZRJxM/6m6WIxeYwaWtRx39xB20yQ
ckdswfmS9RhZDVaka1on+2OQCyKdjpem4V3FDQIDAQABAoIBABnacYONxXSvfTYz
lDvmWvBqUKE9PNhvSTPuyewcoAcc/+7IFkSxGgsd5j6uf+KSKzJ2RN85EoEQJYgN
8fTu3RpREFEovu5nbZyuwvbCQV25pQhtWifRQa27DQd8+Uk0ov0uGoh8EKxfKRfb
nt/2JWpHmjEhFlxgdjWRK9GjCXKp5xR4zW8TdXmBdmPxpUPA+HwEaWhuOCxOvtSu
Zi9EaRiHTg80LqDOcIRa3XJ14s+taf5HjdqWph2Jjn7f2XOufeA/7quDNNHB3fLR
40cAH2w4OqFGS9H7Z127E+L3fGMxZbKkJ3tMNon3GKM75+CM4oHjm90FkgQ4toho
CTFztLkCgYEAxZBnqW/FWdW1JVQuH6ptL+V8GKXHTVISWnyCkS0lrNhhWR8GUI/K
zRrZPxxLB2KmCy7OesuDFScFlCctRKdN89fEegTDYESLzswIeGYIpDAbjS+KNEqB
2QMrySO57yBaJUU2x5WcxYc/0qhroGJK4kFYAqmxczERQTsPKVCG8kUCgYEAwhuZ
2Vw1uKgoGveV357J/niQZHkW2BBQvhSByUPBwlxvmgpDzupXsYMnjkf9gUAfr52k
CwvlnsxMmybC/OgHLMBVuSbvUfG9Q33lGZIWjdLxEOYzkS+w0DZb717OKRUCsyRw
2NkNXGTWZxO74KUwdQSQhsCvew/oRn/ZAZ7ImCkCgYA7vgE64YvCMLfIg/42oSjH
dRPjuC3emdTm/APXttlPvVcixhj4v6LKBzZuuTWPFCu18f+KboRDHzu2o2oy+oy+
Eh9d7pbOQ6wratOc5O30CIAsX5XhXi6J20WgW2dcgmIAJz/7dqijqtSH9Vomc9LF
PJFKrNn2RX48RYm+KpXEKQKBgAjhJlqRI0v9NUh2hv6hzu+7KC/QckEUDf2EBqPk
W5q+i8RqL8vpYHWYYFjb5jbhbhGkjnqK4sgu5Fo8nbLgEmPzsHg11cDT5CqQOci/
rBR0z95MCZPcsKP1uKLsnVW3xlXXlun0k+3kFwLEHlYaeGsB+HKRofVpNuDyvESc
tORxAoGBAL/QuD8gukxtVRCxA5iWZyUPviNuqDBKmqK+Tbyj5/1N1Ld8mD1wdcaY
F6ain38FFboqhiU0Q37ZNs+GmT13BCZoeZzc5e6Jxhc7ABCHsa1gs+Xis5CzzTPd
Ine/r1XWGGcFmVBm1X8Ld6XFEjIKQc4AfefjygJG23WgLek4yENx
-----END RSA PRIVATE KEY-----`

const CERT = `-----BEGIN CERTIFICATE-----
MIIDOzCCAiOgAwIBAgIJCV+bUjHLtN37MA0GCSqGSIb3DQEBCwUAMDAxEjAQBgNV
BAMTCWxvY2FsaG9zdDELMAkGA1UEBhMCVVMxDTALBgNVBAoTBFRlc3QwHhcNMjUx
MTA2MTA0MzE4WhcNMjYxMTA2MTA0MzE4WjAwMRIwEAYDVQQDEwlsb2NhbGhvc3Qx
CzAJBgNVBAYTAlVTMQ0wCwYDVQQKEwRUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAlcy7hGIX1PkIQoO/R405A4rOPHZC1xOEygCYtHIB3IAPeeuq
QooAMoQs8LjtW32mPTjvoVib5sdPsNys3uOXHwdGI90rWhVJedlvpSHSm8BlFQSi
0/tZSFXu73MOnpnx7eJVID/C/YvSgeXF8oW3B0hdIU480KFMZNFGzGMXNZqfKCQ0
qHpaGNmqxbnlae/3DpBWafwqdG3/Pmel+ldqVrlvkMwYxBuQls2VkX1ZX4oclx/R
ZoCukWVpnK0mZRplJnv8Qs89FuFXhF6GKGKbZRJxM/6m6WIxeYwaWtRx39xB20yQ
ckdswfmS9RhZDVaka1on+2OQCyKdjpem4V3FDQIDAQABo1gwVjAMBgNVHRMEBTAD
AQH/MAsGA1UdDwQEAwIC9DAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIw
GgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQAk
8h0YC6ClMo8cAWC8TDj6Q10UOJSsvaygFVuGcPGhaTJtbSpcdAImTpP1yqbSuX1M
jx/VWk29cPYXB5CAdRQkYTotbJPB+d0sIX4Gr3SLoP4ZZvdVqbTczH+Dh7OCxaWO
OurZPlPnonhDQKS7iolql0qvMXgdU/Y21E+0zrjNKzVdq4Ut6y7aGXpAFgSYfPw/
0587yJUwKUi3K8JQ0bEwtnuZgiTQ99X4PUm2Kwgc/0PDA2urZWizg+Xwb8jRb46q
uMHaNAybMirDYs+8CpCQUWshNAgZO5r9zCAnKvyYFLl7bDKuYRwiUfmVn5OZDyi5
OrYmBecd9C050Wn4eb5f
-----END CERTIFICATE-----`

let firstStream = false, sockets = 0, streams = 0
const server = createSecureServer({ key: KEY, cert: CERT })
server.on('connection', () => { sockets++ })
server.on('stream', (stream) => {
  stream.on('error', () => {})
  streams++
  if (!firstStream) {                       // abrupt GOAWAY on the very first stream;
    firstStream = true                      // lastStreamId=0 => every in-flight stream is unprocessed
    stream.session?.goaway(constants.NGHTTP2_INTERNAL_ERROR, 0)
    return
  }
  stream.respond({ ':status': 200 })
  stream.end('ok')
})

await new Promise((r) => server.listen(0, '127.0.0.1', r))
const port = server.address().port
const pool = new Pool(`https://127.0.0.1:${port}`, {
  allowH2: true,
  connections: 10,
  connect: { rejectUnauthorized: false }
})

const N = 40
const codes = {}
let ok = 0
await Promise.all(Array.from({ length: N }, async () => {
  try {
    const res = await pool.request({ path: '/', method: 'GET' })
    await res.body.dump()
    if (res.statusCode === 200) ok++
  } catch (e) {
    const c = e?.code || e?.cause?.code || e?.name || 'unknown'
    codes[c] = (codes[c] || 0) + 1
  }
}))

console.log(`ok=${ok}/${N} sockets=${sockets} streams=${streams} codes=${JSON.stringify(codes)}`)
await pool.close(); server.close()

Expected Behavior

As in 8.3.0: in-flight streams on a session that receives GOAWAY(lastStreamId=0) are unprocessed and should be re-dispatched on a fresh connection (onHttp2SessionGoAwayrequeueUnsentRequest), so all 40 requests succeed.

Actual on 8.5.0/main: ~30/40 reject with UND_ERR_INFO (HTTP/2: "frameError" received - type 1, code 2) and are never retried.

Logs & Screenshots

Output of the script above (same script across versions):

undici output server saw
8.3.0 ok=38/40 codes={"ERR_HTTP2_SESSION_ERROR":2} — recovers 39 streams
8.5.0 ok=9/40 codes={"UND_ERR_INFO":29,"ERR_HTTP2_SESSION_ERROR":2} only 10 streams
main (post-8.5.0) ok=9/40 (same) only 10 streams
8.4.0 / 8.4.1 uncaught crash (separate bug, fixed in 8.5.0):

The rejected requests on 8.5.0 each carry:

NetworkError ...
  cause: InformationalError: HTTP/2: "frameError" received - type 1, code 2
    at ClientHttp2Stream.onFrameError (node_modules/undici/lib/dispatcher/client-h2.js)
  code: 'UND_ERR_INFO'

For reference, 8.4.0/8.4.1 instead crash the process (a different, already-fixed bug):

TypeError: Cannot read properties of undefined (reading 'servername')
    at _resume (node_modules/undici/lib/dispatcher/client.js:654:79)

Environment

  • undici: 8.5.0 and main are affected; 8.3.0 is not (recovers, 38/40). 8.4.0/8.4.1 crash with a separate, since-fixed error (see above).
  • Node.js (all reproduce identically): Linux/Debian — v22.23.1, v24.16.0, v26.4.0 (Docker node:*-slim); Windows 11 — v22.16.0, v24.11.1, v24.18.0, v26.4.0. So the behavior is platform- and Node-version-independent.
  • OS: Linux (Debian) and Windows 11, x64.

Additional context

Root cause (analysis). With the wider multiplexing (#5362 / #5391) many HEADERS frames are in flight on one session. When the GOAWAY arrives mid-burst, Node fails those in-flight HEADERS sends at the connection level, surfacing as a session 'frameError' with id === 0 (type 1 = HEADERS, code 2 = INTERNAL_ERROR; cf. nodejs/node#55888).

onHttp2FrameError (lib/dispatcher/client-h2.js) handles the id === 0 case by wrapping it in an InformationalError (UND_ERR_INFO) and calling client[kOnError](err), which errors every multiplexed in-flight request on the session at once — it does not route them through the GOAWAY requeue path (onHttp2SessionGoAwayrequeueUnsentRequest). Since these streams are provably unprocessed (the HEADERS never reached the server; GOAWAY lastStreamId = 0), they should be requeued on a fresh session like the onHttp2SessionGoAway path does, instead of surfacing as a terminal UND_ERR_INFO.

Related work that does NOT fix this (verified by running the repro against both PR branches — still ok=9/40): #5440 and #5453 both address process crashes (onResponse-after-completion assert / ERR_HTTP2_GOAWAY_SESSION requeue), which is a different path than this non-crash UND_ERR_INFO recovery failure. Also related: nodejs/node#55888 (Node surfaces post-GOAWAY streams as INTERNAL_ERROR, closed as not-planned) — but undici recovered on 8.3.0, so the change in behavior is on the undici side via the new multiplexing.

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