Skip to content

Respect HTTP/2 stream capacity in connection pool#1088

Open
sarmientoF wants to merge 1 commit into
encode:masterfrom
sarmientoF:fix-http2-stream-cap-pooling
Open

Respect HTTP/2 stream capacity in connection pool#1088
sarmientoF wants to merge 1 commit into
encode:masterfrom
sarmientoF:fix-http2-stream-cap-pooling

Conversation

@sarmientoF

@sarmientoF sarmientoF commented Jun 13, 2026

Copy link
Copy Markdown

What changed

HTTP/2 connections can have spare TCP capacity but no spare stream capacity. Today the pool can still hand another request to that connection. With a low peer SETTINGS_MAX_CONCURRENT_STREAMS, that turns into a stall or TooManyStreamsError even when max_connections would allow another connection.

This patch makes the pool count active requests per connection and compare that with the connection's stream capacity. When all same-origin HTTP/2 connections are full, the pool either opens another connection or leaves the request queued until capacity comes back.

It also makes the HTTP/2 stream reservation fail fast. If there is a race between the pool check and the connection check, the connection raises ConnectionNotAvailable and the pool can retry or queue the request instead of blocking inside the semaphore.

Fixes #248.

Related E2B context: e2b-dev/E2B#1407 added a Python SDK HTTP/2 opt-out, but the underlying failure mode is in httpcore's H2 pool behavior. That is the behavior this PR fixes at the transport layer.

Local repro

I used this gist for the local repro and timing table:

https://gist.github.com/sarmientoF/32bc18a587d96590cece3ecf9f4719dd

The script starts a local h2c server with SETTINGS_MAX_CONCURRENT_STREAMS=2, opens two long-lived responses, then sends a third request.

Scenario master This PR What should happen
Sync third request, max_connections=3 times out after ~1.0s, 1 server connection 200 in ~1.7ms, 2 server connections open another H2 connection
Async third request, max_connections=3 times out after ~1.0s, 1 server connection 200 in ~4.1ms, 2 server connections open another H2 connection
Sync third request, max_connections=1 hangs until outer timeout, leaves 3 pool requests PoolTimeout at ~502ms, next request 200 in ~0.7ms queue, time out cleanly, recover
Async third request, max_connections=1 hangs until outer timeout PoolTimeout at ~501ms, next request 200 in ~1.2ms queue, time out cleanly, recover
Sync queued request, then one stream closes not queued, hits TooManyStreamsError or LocalProtocolError queued, wakes after close in ~0.4ms wait for stream capacity
Async queued request, then one stream closes not queued, hits TooManyStreamsError or LocalProtocolError queued, wakes after close in ~1.1ms wait for stream capacity
Async cancel queued request stuck in the harness CancelledError, next request 200 in ~1.0ms cancel cleanly, recover

The timings are from one local macOS/Python 3.12 run. I would not treat them as benchmark claims. They are mostly useful for showing the old failure mode and the new queue/open behavior.

How to run it

Set up two worktrees:

git clone https://github.com/encode/httpcore.git httpcore-master
git clone https://github.com/sarmientoF/httpcore.git httpcore-pr
(cd httpcore-pr && git checkout fix-http2-stream-cap-pooling)

Download the repro script:

curl -L -o /tmp/http2_stream_cap_e2e.py \
  https://gist.githubusercontent.com/sarmientoF/32bc18a587d96590cece3ecf9f4719dd/raw/http2_stream_cap_e2e.py

Run it against master:

PYTHONPATH=/path/to/httpcore-master \
  python /tmp/http2_stream_cap_e2e.py --timeout 1 --bench-runs 0

Run it against this branch:

PYTHONPATH=/path/to/httpcore-pr \
  python /tmp/http2_stream_cap_e2e.py --timeout 1 --bench-runs 0

There is also a small throughput loop:

PYTHONPATH=/path/to/httpcore-pr \
  python /tmp/http2_stream_cap_e2e.py --timeout 1 --bench-runs 5 --bench-requests 1000

That loop uses SETTINGS_MAX_CONCURRENT_STREAMS=100 and sequential requests. It is a smoke test for normal reuse overhead, not the main proof for this bug.

Checks

Check Result
python -m pytest 232 passed, 4 xfailed, 2 xpassed
python scripts/unasync.py --check pass
python -m ruff check httpcore tests pass
python -m ruff format httpcore tests --diff pass
python -m mypy httpcore tests/_async/test_http2.py tests/_sync/test_http2.py tests/_async/test_connection_pool.py tests/_sync/test_connection_pool.py tests/test_cancellations.py pass
git diff --check pass
GitHub Actions pass on Python 3.8 through 3.14

@sarmientoF sarmientoF force-pushed the fix-http2-stream-cap-pooling branch from 17c04e8 to 8e904f6 Compare June 13, 2026 06:27
Queue or open another connection when existing HTTP/2 connections are at the remote stream cap instead of assigning requests to full connections.
@sarmientoF sarmientoF force-pushed the fix-http2-stream-cap-pooling branch from 8e904f6 to bd2e06e Compare June 13, 2026 06:29
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.

Max outbound streams is 100, 100 open

1 participant