Skip to content

feat(h2): honor maxRequestsPerClient - #5802

Open
johnlyonms wants to merge 2 commits into
nodejs:mainfrom
johnlyonms:feat/max-requests-per-client-h2-5787
Open

feat(h2): honor maxRequestsPerClient#5802
johnlyonms wants to merge 2 commits into
nodejs:mainfrom
johnlyonms:feat/max-requests-per-client-h2-5787

Conversation

@johnlyonms

@johnlyonms johnlyonms commented Sep 11, 2026

Copy link
Copy Markdown

This relates to...

Closes #5787

Rationale

maxRequestsPerClient was only honored by the HTTP/1.1 dispatcher. A Client created with allowH2: true, or an H2CClient, ignored the option and kept a single HTTP/2 session alive indefinitely. The option is important for rotating
connections so load balancers can rebalance and clients do not remain pinned to one backend.

HTTP/2 also requires bounded retirement. Once a client has reached its request limit and we mark it as such, we must wait for existing streams to close. Waiting indefinitely for accepted streams to close lets a long-lived SSE, CONNECT, WebSocket, long-polling, gRPC, or stalled upload would block all queued work if we didn't implement a bound.

Changes

  • Count successfully opened HTTP/2 streams against maxRequestsPerClient.
  • Retire a session synchronously when it reaches the limit so no additional stream can slip through the same resume loop.
  • Allow accepted streams to drain without truncating ordinary responses or request uploads.
  • Bound retirement draining with headersTimeout. If accepted streams remain open at the deadline, reset the retired session with an informational error so queued requests remain replayable on a fresh connection.
  • Cancel the retirement timer during normal drain, GOAWAY, reset, and session close paths.
  • Preserve the existing 0, null, and undefined disabled behavior.
  • Document the HTTP/1.1 message-counting and HTTP/2 stream-counting behavior for Client, H2CClient, and TypeScript consumers.

The implementation tracks physical stream closure separately from kOpenStreams. A response may finish while its request body is still uploading, so response completion alone is not sufficient to safely retire the session.

Tests

test/http2-max-requests-per-client.js contains 17 tests covering:

  • sequential and concurrent session rotation
  • limits of 1 and disabled limits
  • Client, H2CClient, and Pool
  • graceful drain and complete request-body uploads
  • failed, aborted, upgrade, and WebSocket streams
  • GOAWAY and abrupt peer disconnect races
  • client destruction during retirement
  • exactly one disconnect notification
  • an endless SSE stream being terminated after headersTimeout while queued work reconnects successfully

Validation:

  • Focused HTTP/2 suite: 17/17 passed
  • ESLint passed for the changed JavaScript files
  • Syntax and whitespace checks passed

Commits

  • 12ac5172 feat(h2): honor maxRequestsPerClient
  • 41ba964e fix(h2): bound retired session draining

Breaking Changes and Deprecations

None. HTTP/1.1 behavior is unchanged. HTTP/2 users who do not configure maxRequestsPerClient, or set it to 0, see no change. Setting headersTimeout: 0 explicitly disables the retirement drain deadline.

Status

`maxRequestsPerClient` was only honored over HTTP/1.1, so a `Client` with
`allowH2: true` (or an `H2CClient`) kept a single session alive forever no
matter how the option was configured.

Count every stream that `session.request()` successfully opened against the
limit, and retire the session once it is reached. A retired session is marked
busy, so the dispatch loop stops putting new streams on it and queues them for
the next connection instead. Retirement happens synchronously, before the write
that reached the limit returns, so a resume pass dispatching several requests at
once cannot overshoot.

Tearing the session down is deferred until it has drained. Doing it in the same
tick as `session.request()` makes nghttp2 refuse the very stream that triggered
retirement, and a locally initiated `session.close()` can leave the socket
half-open indefinitely when the peer keeps its side open. Drainage is tracked
with a dedicated counter driven by each stream's physical `close` event rather
than by `kOpenStreams`, which is released when the response completes and would
therefore truncate a request body the server had already responded to.

Closes nodejs#5787

Signed-off-by: John Lyon <264892913+johnlyonms@users.noreply.github.com>
@johnlyonms
johnlyonms marked this pull request as ready for review September 11, 2026 00:37
@johnlyonms
johnlyonms marked this pull request as draft September 11, 2026 01:03
Use headersTimeout to reset retired HTTP/2 sessions whose accepted streams do not close, allowing queued requests to reconnect instead of stalling indefinitely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: John Lyon <264892913+johnlyonms@users.noreply.github.com>
@johnlyonms
johnlyonms marked this pull request as ready for review September 11, 2026 04:17
Comment thread docs/docs/api/Client.md
Comment on lines +80 to +85
Over HTTP/1.1 a request is counted per message and the socket is reset once
the limit is reached. Over HTTP/2 a request is counted per successfully
opened stream: the session is retired once the limit is reached, meaning it
accepts no further streams. Streams it already accepted are allowed to
complete for up to `headersTimeout` before the connection is reset. Queued
and subsequent requests are then dispatched on a new session.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a bit confusing for users, especially given that we have the maxConcurrentStreams; let's mention how they differ and how they interact with each other

@codecov-commenter

codecov-commenter commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.00000% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.54%. Comparing base (cb7373a) to head (41ba964).

Files with missing lines Patch % Lines
lib/dispatcher/client-h2.js 97.00% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5802      +/-   ##
==========================================
+ Coverage   93.52%   93.54%   +0.01%     
==========================================
  Files         110      110              
  Lines       39415    39604     +189     
==========================================
+ Hits        36864    37046     +182     
- Misses       2551     2558       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support maxRequestsPerClient for HTTP/2 sessions

3 participants