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
- 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.
- 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.
- 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
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.
Today the router uses hyper-util's default of 200 for the HTTP/2 server
SETTINGS_MAX_CONCURRENT_STREAMSsetting, 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.routerwould let operators tune this without a code change.Current behavior
The router's
configure_connection()only setsmax_header_list_sizeon the h2 builder —max_concurrent_streamsis never called.apollo-router/src/axum_factory/listeners.rs:493-499The 200 default comes from hyper-util's auto Builder:
hyper-util-0.1.20/src/server/conn/auto/mod.rs:1006-1015Motivation / use cases
limits.router.*. Exposing this completes the set:limits.router.http1_max_request_headerslimits.router.http1_max_request_buf_sizelimits.router.http2_max_headers_list_byteslimits.router.http2_max_concurrent_streamsProposed configuration
Schema notes:
Option<u32>(consistent withhttp2_max_headers_list_bytesbeing optional)None→ leave hyper's default (200) in place. To allow explicit removal of the limit, consider an explicit sentinel (e.g.unlimitedor acceptingnull) since hyper-util's API takesInto<Option<u32>>whereNoneremoves the cap.Implementation sketch
In
apollo-router/src/plugins/limits/mod.rs(RouterLimitsConfig):In
apollo-router/src/axum_factory/listeners.rsconfigure_connection():Acceptance criteria
limits.router.http2_max_concurrent_streamssetting accepted in YAML and validated against the router's JSON Schema.serve_router_on_listen_addr()toconfigure_connection()and applied viabuilder.max_concurrent_streams(...)for TCP, TLS, and Unix listeners.docs/source/routing/security/request-limits.mdxalongside the existing http1/http2 knobs.http2_max_headers_list_bytes).apollo.router.http.server.connection.h2.streams.activegauge so operators can detect when the limit is being approached. Today there is no per-connection stream visibility from the router.Additional context
REFUSED_STREAMresets.