Skip to content

Add a wait_for_connection trace event to the connection pool - #1199

Draft
adriangb wants to merge 2 commits into
mainfrom
claude/pool-wait-trace-36s0un
Draft

Add a wait_for_connection trace event to the connection pool#1199
adriangb wants to merge 2 commits into
mainfrom
claude/pool-wait-trace-36s0un

Conversation

@adriangb

@adriangb adriangb commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary

AsyncConnectionPool bounds concurrency with max_connections, so when the pool is saturated a request genuinely queues. That queue wait is currently unobservable: _async/connection_pool.py contains no clock and emits no trace events, so an exceeded wait surfaces only as a PoolTimeout, and a wait that completes leaves no trace at all. A caller cannot tell a slow server from a saturated pool.

httpcore2 already has exactly the right mechanism for this, and the pool is the one component that does not use it. _trace.py defines Trace, driven by request.extensions["trace"]; _async/connection.py uses it for connect_tcp, connect_unix_socket, start_tls, retry and close. This PR wraps the pool's wait for an assigned connection in the same way:

async with Trace("wait_for_connection", logger, request, {"timeout": timeout}) as trace:
    connection = await pool_request.wait_for_connection(timeout=timeout)
    trace.return_value = connection

connection_pool.py gains a module logger following the existing convention (logging.getLogger("httpcore2.connection_pool")), so the events come out as connection_pool.wait_for_connection.started / .complete / .failed, and the same information is available through DEBUG logging on the new httpcore2.connection_pool logger.

That is the whole change: no clock, no metric, no new public API. The trace mechanism is the hook, and consumers can time the span themselves.

Non-breaking

A pool with no trace extension and no DEBUG logging behaves exactly as before: Trace.should_trace is false, so __aenter__ / __aexit__ do no work. The only visible change for existing users is two extra events at the front of a traced request, and two extra DEBUG lines.

Judgement calls

Two decisions worth calling out explicitly, since both could reasonably go the other way:

One span per attempt, not one per request. The wait_for_connection call sits inside the while True: loop that retries when a connection turns out to be unavailable (ConnectionNotAvailable), and the Trace is placed inside that loop, so each attempt emits its own started/complete pair. This is the honest reporting: each pass is a real, separately-timed wait, and collapsing several waits into one span would misreport where the time went. It also matches connection.py, which already emits a retry event per retry rather than hiding them. The cost is that a consumer summing wait time must sum the spans of a request rather than assuming one.

{"timeout": timeout} and nothing else in the started info. The pool timeout is the one field that is both cheap and genuinely useful — it is the bound the wait is measured against, and it makes a subsequent PoolTimeout self-explanatory. Queue depth or the number of active connections would be more informative still, but reading them means taking _optional_thread_lock on the hot path for every request, traced or not, so they are deliberately left out. complete carries the acquired connection as return_value, matching the shape used everywhere else.

Open question for maintainers: is a per-request extension the right consumer API here?

A question, not a proposal — nothing in this PR implements it — but worth asking while the design is in view, because the answer may change what you want merged.

request.extensions["trace"] is strictly per-request, and the client has no client-level default extensions parameter: _client.py injects timeout into extensions when absent, but there is no equivalent for trace. So a consumer who wants always-on pool telemetry either passes extensions={"trace": cb} at every call site, or subclasses the transport and injects the extension before it builds the httpcore2.Request. The subclass works and is about ten lines — it is the pattern this PR now documents under "Tracing every request" in docs/advanced/extensions.md. But the extension is a debugging-shaped API being pressed into service for production telemetry, and three frictions follow from that:

  • the consumer must string-match event names such as connection_pool.wait_for_connection.started, which quietly turns event names into a public contract — the docs presently reserve the right to change the event set between versions;
  • in async the callback must be a coroutine, so the recording is awaited on the hot path;
  • one span per attempt (above) means the consumer has to correlate and sum rather than read a single number.

A callback registered at pool construction would avoid all three. That is a real public API addition, though, and exactly the kind of change docs/contributing.md wants discussed before code is written — so it is a question here rather than a commit. The event is the primitive either API would carry, so this PR stands on its own whichever way you answer.

Overhead when tracing is off

Constructing the Trace and entering and leaving the block is not free even when nothing is traced. A micro-benchmark of the block alone, with no trace extension and DEBUG disabled, costs roughly 460 ns per attempt on my machine — noisy shared hardware, so read that as an order of magnitude rather than a figure. That is one allocation and a handful of attribute lookups set against the cost of an HTTP request, and it is the same price every other traced operation in the codebase already pays.

CodSpeed reports no regression on this PR, but that result should not be read as covering this change. The CI job runs only tests/test_benchmark.py and tests/test_benchmark_memory.py, and the single client-level case there, test_bench_client_post_json, goes through httpx2.MockTransport — so it never reaches AsyncHTTPTransport or the connection pool. No benchmark in that suite constructs this Trace at all, and none exercises a contended pool, which is precisely the path where the span fires repeatedly. The repo's standalone benchmark/ harness does accept --max-connections and could be pointed at a saturated pool, but it is not what the CodSpeed job runs and I have not run it here. If the overhead is a concern, an early should_trace-style guard around the block is the obvious mitigation and I am happy to add it.

Downstream motivation

Pydantic Platform runs two long-lived bounded clients (httpx2.Limits(max_connections=worker_max_jobs) in its worker) and is adding fleet-wide connection-pool acquisition metrics for its Redis and Postgres pools. Those HTTP pools cannot be included today purely because the wait cannot be read; with this event they can be, using the same callback plumbing already used for the other events.

On process

docs/contributing.md asks that contributions generally start with a discussion, and this PR skips that step. It is opened as a draft for that reason. Happy to close it and reopen the conversation as an "Ideas" discussion first if you would prefer — the diff is small enough to stand as a concrete proposal for the discussion either way.

Checklist

  • I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
  • I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • I've updated the documentation accordingly.

Notes on the change

  • _sync/connection_pool.py and tests/httpcore2/_sync/test_connection_pool.py are regenerated with scripts/unasync.py; only the _async sources were edited by hand.
  • New test test_trace_queued_request saturates a max_connections=1 pool, holds the connection until a second request has queued, and asserts the queued request's events in order — the marker for the release of the held connection falls strictly between wait_for_connection.started and wait_for_connection.complete, so the test fails if the span does not actually cover the wait. The synchronisation in the test deliberately does not depend on the new events: it polls repr(pool) for the queued request, so an unwired Trace fails the assertion instead of hanging.
  • Five existing tests that assert full event lists (test_trace_request, test_debug_request, test_connection_pool_with_http_exception, test_connection_pool_with_connect_exception, test_http11_upgrade_connection) are updated with the two new events.
  • tests/httpcore2/concurrency.py gains Event and sleep aliases so the new test unasyncs cleanly from trio.Event / trio.sleep.
  • The "Tracing every request" docs section is httpx2-level only; src/httpcore2/docs/extensions.md is left alone, since httpcore2 has no transport layer to hang the pattern on. Both the async and sync forms of that snippet were run against a local server before committing, including the claim that a per-request "trace" overrides the transport default.
  • No changelog entry: src/httpcore2/CHANGELOG.md has no unreleased section and its entries carry PR numbers. Happy to add one under whichever heading you prefer.

🤖 Generated with Claude Code

https://claude.ai/code/session_012h2Ro3eiUVkSs3A2XtSw8u

The pool bounds concurrency with `max_connections`, so when it is
saturated a request genuinely queues. That wait is currently
unobservable: an exceeded wait surfaces only as `PoolTimeout`, and a wait
that completes leaves no trace at all, so a caller cannot tell a slow
server from a saturated pool.

`httpcore2` already has the right mechanism for this, and the pool is the
only component that does not use it. Wrap the pool's wait for an assigned
connection in a `Trace`, emitting
`connection_pool.wait_for_connection.started` / `.complete` / `.failed`
via the `trace` request extension and the new
`httpcore2.connection_pool` DEBUG logger.

No new public API, no clock and no metric: a pool with no `trace`
extension and no DEBUG logging is unaffected, since `Trace.should_trace`
short-circuits.

Claude-Session: https://claude.ai/code/session_012h2Ro3eiUVkSs3A2XtSw8u
@codspeed-hq

codspeed-hq Bot commented Sep 7, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 17 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing claude/pool-wait-trace-36s0un (377815c) with main (81c523f)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

`request.extensions["trace"]` is per-request, and the client has no
client-level default extensions parameter, so the new
`connection_pool.wait_for_connection` event is not reachable for always-on
telemetry without either passing `extensions={"trace": ...}` at every call
site or installing the callback from a transport subclass.

Document the transport subclass, since that is the form a production
consumer of this event will actually use. Both the async and sync variants
of the snippet were run against a local server before committing.

Claude-Session: https://claude.ai/code/session_012h2Ro3eiUVkSs3A2XtSw8u
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.

2 participants