Repository navigation
Description
Bug Description
Node's HTTP/2 client charges queued request-body bytes against the session's maxSessionMemory (10 MB). When the budget is exhausted and a response HEADERS frame arrives on any stream, Http2Stream::AddHeader fails and Node resets that stream locally with NGHTTP2_ENHANCE_YOUR_CALM (NGHTTP2_INTERNAL_ERROR on current main, where the reset is deferred). The session is not closed.
undici treats this as a per-request failure (TypeError: fetch failed, cause ERR_HTTP2_STREAM_ERROR: Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM) and keeps the session. Because Node also leaks the queued bytes of streams destroyed before they were flushed (nodejs/node#67113, fixed on main, not released), the budget never comes back while the session is in use, so every subsequent request on that Client fails the same way until the session goes idle — which a busy process never allows. The Pool keeps handing requests to the poisoned client because it is not "busy".
Two things in undici 8 make this much more visible than in undici 7:
- undici 7 considered an H2 client busy at
pipelining(1) in-flight request, so aPool({ connections: 10 })spread load over 10 sessions × 1 stream. undici 8 multiplexes up tomaxConcurrentStreams(100) on one session, so one 10 MB budget is shared by every request of the process. - Bodies are queued in full: with 10 parallel 2 MB uploads on one session, 16/50 requests failed with ENHANCE_YOUR_CALM on Node 24.22.2 with no aborts at all (10 × 1 MB: 0/50, the budget is exactly 10 MB).
Reproducible By
// node 24.22.2, undici 8.12.3; maxSessionMemory lowered via a monkeypatch so the repro is fast
const http2 = require('node:http2')
const connect = http2.connect
http2.connect = (url, opts, cb) => connect(url, { ...opts, maxSessionMemory: 1 }, cb)
const { Agent, fetch } = require('undici')
const dispatcher = new Agent({ allowH2: true, connections: 10 })
const url = 'https://<any-h2-origin-accepting-POST-bodies>/upload'
async function burst () {
const r = await Promise.allSettled(Array.from({ length: 10 }, () =>
fetch(url, { method: 'POST', body: 'x'.repeat(4096), dispatcher })))
console.log(r.map(x => x.status === 'fulfilled' ? x.value.status : x.reason.cause?.message))
}
;(async () => {
await burst() // 10 × 2xx
await fetch(url, { method: 'POST', body: Buffer.alloc(16 << 20), dispatcher,
signal: AbortSignal.timeout(300) }).catch(() => {}) // aborted while queued
await burst() // 10 × "Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM"
await new Promise(r => setTimeout(r, 2000))
await burst() // still 10 × ENHANCE_YOUR_CALM — the session is never replaced
})()Expected Behavior
A stream reset that Node generates itself because the session is out of memory is a session-level failure, not a request-level one. undici should treat ERR_HTTP2_STREAM_ERROR with NGHTTP2_ENHANCE_YOUR_CALM (and the deferred-reset NGHTTP2_INTERNAL_ERROR) like a fatal session error: close/destroy the Http2Session so the client reconnects for the next request (not retry the failed request — its body may have been received). Today only NGHTTP2_REFUSED_STREAM gets special handling in client-h2.js.
Optionally, expose maxSessionMemory through h2/sessionOptions so callers whose workloads queue large bodies can raise it.
Environment
Node v24.22.2, undici 8.12.3 (also 8.x generally), Linux. Server: any HTTP/2 origin; seen against GFE-fronted and nginx-fronted upstreams.
Additional context
Downstream report: e2b-dev/e2b SDK-425 (sandbox.files.write() failing with ENHANCE_YOUR_CALM after upgrading from undici 7 to 8). Node side: nodejs/node#66739, nodejs/node#67113 (leak fix, merged on main), nodejs/node#67114 (proposal to stop gating client response headers on session memory).
Activity
fix(js-sdk): send envd file uploads over HTTP/1.1, retire poisoned HT…