Skip to content

http2: large queued request body prevents receiving an early response #66739

Open

Description

@CoffeeFlux

Version

v26.10.0

Platform

Darwin miki.local 25.6.0 Darwin Kernel Version 25.6.0: Sat Jul 11 15:27:04 PDT 2026; root:xnu-12377.161.13~4/RELEASE_ARM64_T6050 arm64

Subsystem

http2

What steps will reproduce the bug?

Save the following code into a file and run it. For the 16MB request with the default session limit, node reports ERR_HTTP2_STREAM_ERROR: Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM rather than the 413.

// A client that queues a large request body cannot read an early response.
// Cleartext HTTP/2 (h2c), no dependencies: `node repro.mjs`.
import http2 from 'node:http2';

const server = http2.createServer();
const sessions = new Set();
server.on('session', (session) => sessions.add(session));
server.on('stream', (stream) => {
  stream.on('error', () => {}); // the client may reset the stream
  // Reject without reading the request body, as a server does for a 401 or 413.
  stream.respond({ ':status': 413 });
  stream.end('payload too large');
});
await new Promise((resolve) => server.listen(0, resolve));
const origin = `http://localhost:${server.address().port}`;

const post = (bodyBytes, sessionOptions = {}) =>
  new Promise((resolve) => {
    const session = http2.connect(origin, sessionOptions);
    const req = session.request({ ':method': 'POST', ':path': '/' });
    req.on('response', (headers) => resolve(`status ${headers[':status']}`));
    req.on('error', (err) => resolve(`${err.code}: ${err.message}`));
    req.on('close', () => session.destroy());
    req.end(Buffer.alloc(bodyBytes)); // the whole body in one write
  });

const MiB = 1024 * 1024;
console.log(`node ${process.version}`);
console.log(' 8 MiB body:                   ', await post(8 * MiB));
console.log('16 MiB body:                   ', await post(16 * MiB));
console.log('16 MiB, maxSessionMemory 64 MB:', await post(16 * MiB, { maxSessionMemory: 64 }));
for (const session of sessions) session.destroy();
server.close();

How often does it reproduce? Is there a required condition?

To reproduce, more than maxSessionMemory worth of request-body data must be queued when the response arrives.

What is the expected behavior? Why is that the expected behavior?

I'd expect consistent propagation of the server's 413 responses

What do you see instead?

Opaque stream error (ERR_HTTP2_STREAM_ERROR: Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM) and no response

Additional information

No response

Activity

  1. inoway46 commented on Oct 15, 2026

    @inoway46
    Contributor

    Reproduced with a local main build (macOS x64, 38aa1f3), which reports NGHTTP2_INTERNAL_ERROR.

    Queued outbound data exceeds the session memory limit, so AddHeader() rejects response headers. The reset is deferred during receive processing, and nghttp2 queues INTERNAL_ERROR for the callback failure.

    On this build, increasing maxSessionMemory to 64 or streaming with backpressure allows the 413 response through without errors.

    @nodejs/http2 Could you clarify whether this limit should prevent receiving response headers on an existing stream?

  2. added
    http2Issues and PRs related to the http2 subsystem.
    on Oct 15, 2026
  3. GBra-4-669 commented on Oct 18, 2026

    @GBra-4-669

    It looks intentional rather than a regression. The IsAvailableSessionMemory check in Http2Stream::AddHeader came in with maxSessionMemory itself (#17967, 882e7ef354). The description says that existing streams still receiving headers get terminated with ENHANCE_YOUR_CALM.

    But the docs only say that existing streams may cause the limit to be exceeded and new ones are rejected.
    There is no mention that header receipt on existing streams gets cut off.
    So either the docs need updating or the behavior does.

    FWIW, on current main (e2d55227) the same repro gives rstCode 2 (INTERNAL_ERROR) instead of 11 so it won't match the report.

  4. pimterry commented on Oct 22, 2026

    @pimterry
    Member

    nodejs/http2 Could you clarify whether this limit should prevent receiving response headers on an existing stream?

    I don't think it should in this specific case. The docs description doesn't match this behaviour (it says the limit just affects new streams, as @GBra-4-669 mentioned). In this scenario, the block isn't really providing any useful protection imo - i.e. I don't think there'd be a way to abuse this to retain significant memory unexpectedly.

    The memory limit is here primarily for servers, to stop a client using up unlimited & disproportionate memory with inbound streams & requests. On the client side, there are some related risks, but they're much more limited - for these response headers specifically, memory usage is tightly bounded by the requests you send intentionally. I can't see a good argument for blocking this.

    That said, this is a delicate and security-sensitive code, so I might've missed something, it will need some careful discussion, and I think everybody related to both security & HTTP/2 is swamped at the moment! I agree it would be good to improve this though.

    I think there's probably a safe route through by disabling blocking on memory usage here, exclusively for client-side + locally initiated streams (i.e. request streams, not push streams opened by the server). 1xx response headers (which the server can send unlimited times) need a little thought, but I don't think we actually retain them so that shouldn't be a problem.

    Notably, I think this wouldn't happen if the request body was sent in a backpressure-sensitive way. I think request.end(data) is reasonable code and we do need to make that work, but in any scenario where you are piping data, or doing incremental write(data) calls, if you correctly observe backpressure you won't hit this - you should automatically stop writing and wait for more window before reaching the buffer limits, and then everything will work as expected. That's not a perfect solution, and as I say I do think you should expect the above code to work, but it might be a workaround in the meantime, and it does mean this won't hit in practice in many existing cases.

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

    http2Issues and PRs related to the http2 subsystem.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions