Skip to content

H2: a session Node has reset for memory (ENHANCE_YOUR_CALM) stays in the pool and fails every later request #6149

Description

@mishushakov

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:

  1. undici 7 considered an H2 client busy at pipelining (1) in-flight request, so a Pool({ connections: 10 }) spread load over 10 sessions × 1 stream. undici 8 multiplexes up to maxConcurrentStreams (100) on one session, so one 10 MB budget is shared by every request of the process.
  2. 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions