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
npm i undici@8.5.0
- Save the script below as
repro.mjs and run node repro.mjs.
- 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 (onHttp2SessionGoAway → requeueUnsentRequest), 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 (onHttp2SessionGoAway → requeueUnsentRequest). 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.
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:
They are never requeued/retried — they do not even reach the server, although they are provably unprocessed (the server sent
GOAWAYwithlastStreamId = 0). On 8.3.0 the identical scenario fully recovers (undici re-dispatches the unprocessed streams viaonHttp2SessionGoAway).In the reproduction below the server only ever sees 10 of 40 streams on 8.5.0 — ~30 requests never leave the client. Raising
maxRetriesdoes not help (each retry re-hits the same path; the server stream count does not increase).Reproducible By
npm i undici@8.5.0repro.mjsand runnode repro.mjs.ok=9/40on 8.5.0 (andmain), vsok=38/40on 8.3.0.Self-contained; only depends on
undici(a throwaway localhost self-signed cert is embedded;rejectUnauthorized:falseis set):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 (onHttp2SessionGoAway→requeueUnsentRequest), 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):
ok=38/40 codes={"ERR_HTTP2_SESSION_ERROR":2}— recoversok=9/40 codes={"UND_ERR_INFO":29,"ERR_HTTP2_SESSION_ERROR":2}main(post-8.5.0)ok=9/40(same)The rejected requests on 8.5.0 each carry:
For reference, 8.4.0/8.4.1 instead crash the process (a different, already-fixed bug):
Environment
mainare 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:*-slim); Windows 11 — v22.16.0, v24.11.1, v24.18.0, v26.4.0. So the behavior is platform- and Node-version-independent.Additional context
Root cause (analysis). With the wider multiplexing (#5362 / #5391) many
HEADERSframes are in flight on one session. When theGOAWAYarrives mid-burst, Node fails those in-flightHEADERSsends at the connection level, surfacing as a session'frameError'withid === 0(type 1 = HEADERS, code 2 = INTERNAL_ERROR; cf. nodejs/node#55888).onHttp2FrameError(lib/dispatcher/client-h2.js) handles theid === 0case by wrapping it in anInformationalError(UND_ERR_INFO) and callingclient[kOnError](err), which errors every multiplexed in-flight request on the session at once — it does not route them through the GOAWAY requeue path (onHttp2SessionGoAway→requeueUnsentRequest). Since these streams are provably unprocessed (theHEADERSnever reached the server;GOAWAY lastStreamId = 0), they should be requeued on a fresh session like theonHttp2SessionGoAwaypath does, instead of surfacing as a terminalUND_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_SESSIONrequeue), which is a different path than this non-crashUND_ERR_INFOrecovery failure. Also related: nodejs/node#55888 (Node surfaces post-GOAWAY streams asINTERNAL_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.