Add a wait_for_connection trace event to the connection pool - #1199
Draft
adriangb wants to merge 2 commits into
Draft
Add a wait_for_connection trace event to the connection pool#1199adriangb wants to merge 2 commits into
wait_for_connection trace event to the connection pool#1199adriangb wants to merge 2 commits into
Conversation
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
Merging this PR will not alter performance
Comparing Footnotes
|
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AsyncConnectionPoolbounds concurrency withmax_connections, so when the pool is saturated a request genuinely queues. That queue wait is currently unobservable:_async/connection_pool.pycontains no clock and emits no trace events, so an exceeded wait surfaces only as aPoolTimeout, and a wait that completes leaves no trace at all. A caller cannot tell a slow server from a saturated pool.httpcore2already has exactly the right mechanism for this, and the pool is the one component that does not use it._trace.pydefinesTrace, driven byrequest.extensions["trace"];_async/connection.pyuses it forconnect_tcp,connect_unix_socket,start_tls,retryandclose. This PR wraps the pool's wait for an assigned connection in the same way:connection_pool.pygains a module logger following the existing convention (logging.getLogger("httpcore2.connection_pool")), so the events come out asconnection_pool.wait_for_connection.started/.complete/.failed, and the same information is available through DEBUG logging on the newhttpcore2.connection_poollogger.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
traceextension and no DEBUG logging behaves exactly as before:Trace.should_traceis 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_connectioncall sits inside thewhile True:loop that retries when a connection turns out to be unavailable (ConnectionNotAvailable), and theTraceis placed inside that loop, so each attempt emits its ownstarted/completepair. 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 matchesconnection.py, which already emits aretryevent 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 thestartedinfo. 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 subsequentPoolTimeoutself-explanatory. Queue depth or the number of active connections would be more informative still, but reading them means taking_optional_thread_lockon the hot path for every request, traced or not, so they are deliberately left out.completecarries the acquired connection asreturn_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.pyinjectstimeoutinto extensions when absent, but there is no equivalent fortrace. So a consumer who wants always-on pool telemetry either passesextensions={"trace": cb}at every call site, or subclasses the transport and injects the extension before it builds thehttpcore2.Request. The subclass works and is about ten lines — it is the pattern this PR now documents under "Tracing every request" indocs/advanced/extensions.md. But the extension is a debugging-shaped API being pressed into service for production telemetry, and three frictions follow from that: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;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.mdwants 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
Traceand entering and leaving the block is not free even when nothing is traced. A micro-benchmark of the block alone, with notraceextension 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.pyandtests/test_benchmark_memory.py, and the single client-level case there,test_bench_client_post_json, goes throughhttpx2.MockTransport— so it never reachesAsyncHTTPTransportor the connection pool. No benchmark in that suite constructs thisTraceat all, and none exercises a contended pool, which is precisely the path where the span fires repeatedly. The repo's standalonebenchmark/harness does accept--max-connectionsand 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 earlyshould_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.mdasks 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
Notes on the change
_sync/connection_pool.pyandtests/httpcore2/_sync/test_connection_pool.pyare regenerated withscripts/unasync.py; only the_asyncsources were edited by hand.test_trace_queued_requestsaturates amax_connections=1pool, 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 betweenwait_for_connection.startedandwait_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 pollsrepr(pool)for the queued request, so an unwiredTracefails the assertion instead of hanging.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.pygainsEventandsleepaliases so the new test unasyncs cleanly fromtrio.Event/trio.sleep.httpx2-level only;src/httpcore2/docs/extensions.mdis left alone, sincehttpcore2has 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.src/httpcore2/CHANGELOG.mdhas 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