Skip to content

Feature: expose SETTINGS_MAX_CONCURRENT_STREAMS for incoming HTTP/2 connections #9377

Description

@theJC

Today the router uses hyper-util's default of 200 for the HTTP/2 server SETTINGS_MAX_CONCURRENT_STREAMS setting, and there is no way to override it via configuration. For deployments that funnel large numbers of concurrent multiplexed requests over a small number of h2 connections, 200 may be too low — and there is no router metric that surfaces when the limit is binding, so operators can hit it silently.
 
A new configuration knob under limits.router would let operators tune this without a code change.
 

Current behavior

The router's configure_connection() only sets max_header_list_size on the h2 builder — max_concurrent_streams is never called.
 
apollo-router/src/axum_factory/listeners.rs:493-499

let mut builder = conn_builder.http2();
if let Some(max_header_list_size) = opt_max_http2_headers_list_bytes {
let max_header_list_size = u32::try_from(max_header_list_size.as_u64())
.inspect_err(\|e\| tracing::warn!(...))
.unwrap_or(u32::MAX);
builder.max_header_list_size(max_header_list_size);
}
// no max_concurrent_streams call

 
The 200 default comes from hyper-util's auto Builder:
 
hyper-util-0.1.20/src/server/conn/auto/mod.rs:1006-1015

/// Sets the SETTINGS_MAX_CONCURRENT_STREAMS option for HTTP2 connections.
///
/// Default is 200. Passing `None` will remove any limit.
pub fn max_concurrent_streams(&mut self, max: impl Into<Option<u32>>) -> &mut Self

 

Motivation / use cases

  1. High-multiplexing clients. When a small number of upstream proxies or clients open long-lived h2 connections to the router and stream many concurrent requests over each one, peak streams-per-connection can climb quickly. With only 200 streams allowed per connection, a single hot connection can saturate well before the host's request capacity is reached.
     
  2. Lack of observability when the cap is approached. When all 200 stream slots on a connection are in use, additional requests stall waiting for a slot. There is no router-emitted metric exposing per-connection active-stream count, so operators relying solely on router metrics can't tell when this is binding.
     
  3. Consistency with sibling settings. The router already exposes header-size and HTTP/1 connection knobs under limits.router.*. Exposing this completes the set:
     
  • limits.router.http1_max_request_headers
  • limits.router.http1_max_request_buf_size
  • limits.router.http2_max_headers_list_bytes
  • (new) limits.router.http2_max_concurrent_streams
     

Proposed configuration

limits:
  router:
    # Maximum number of concurrent HTTP/2 streams the router will accept on a
    # single inbound connection (advertised via SETTINGS_MAX_CONCURRENT_STREAMS).
    # Default: 200 (hyper-util default). Increase for deployments with heavy
    # h2 multiplexing on a small number of long-lived connections.
    http2_max_concurrent_streams: 200

 
Schema notes:

  • Type: Option<u32> (consistent with http2_max_headers_list_bytes being optional)
  • None → leave hyper's default (200) in place. To allow explicit removal of the limit, consider an explicit sentinel (e.g. unlimited or accepting null) since hyper-util's API takes Into<Option<u32>> where None removes the cap.
  • Document the trade-off: higher limits permit more multiplexed concurrency but also let one client consume more router worker capacity.
     

Implementation sketch

In apollo-router/src/plugins/limits/mod.rs (RouterLimitsConfig):
 

/// Maximum number of concurrent HTTP/2 streams accepted per inbound connection.
/// Default: 200.
pub(crate) http2_max_concurrent_streams: Option<u32>,

 
In apollo-router/src/axum_factory/listeners.rs configure_connection():
 

let mut builder = conn_builder.http2();
if let Some(max_header_list_size) = opt_max_http2_headers_list_bytes {
// existing
}
if let Some(max_concurrent_streams) = opt_max_http2_concurrent_streams {
builder.max_concurrent_streams(max_concurrent_streams);
}

 

Acceptance criteria

  • New optional limits.router.http2_max_concurrent_streams setting accepted in YAML and validated against the router's JSON Schema.
  • Value plumbed through serve_router_on_listen_addr() to configure_connection() and applied via builder.max_concurrent_streams(...) for TCP, TLS, and Unix listeners.
  • Documentation entry in docs/source/routing/security/request-limits.mdx alongside the existing http1/http2 knobs.
  • Unit test asserting the value reaches the h2 builder (mirroring tests for http2_max_headers_list_bytes).
  • (Stretch) An apollo.router.http.server.connection.h2.streams.active gauge so operators can detect when the limit is being approached. Today there is no per-connection stream visibility from the router.
     

Additional context

  • Hyper enforces this setting at the h2 codec layer (advertised via the SETTINGS frame); compliant clients respect it by waiting before opening additional streams. Non-compliant clients receive REFUSED_STREAM resets.
  • Today, even with this fix, operators have no built-in metric to know when the limit is binding — adding the gauge in the stretch goal would close that gap.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions