Skip to content

perf: cut per-request overhead in the client hot path - #182

Merged
lesnik512 merged 2 commits into
mainfrom
perf/client-hot-path
Oct 17, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
perf/client-hot-path

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Two changes to Client and AsyncClient, one commit each. Behaviour does not change.

Skip the URL query merge when there is nothing to merge

_build_request parsed every URL with httpx2.URL before handing it to httpx2, which then parsed it again. The extra parse is only needed when a URL query has to survive params (the httpx2 workaround from #146). It now returns client.build_request(...) directly in two cases:

  • url is a str with no ?. httpx2 only takes a query from a literal ?, so such a URL has no query to keep.
  • No per-request params (absent or None) and the httpx2 client has no params. httpx2 then leaves the URL query alone.

It still takes the merge path for an httpx2.URL instance with params, params={} (httpx2 drops the URL query for any non-None params, including an empty dict), client-level params, and a ? that only appears in the fragment (that last one gives the same result either way).

tests/test_url_query_merge.py is unchanged and passes. The new tests/test_url_query_fast_path.py covers both skip cases and each case where the merge must still run, against build_request and against get() with a base_url, in both clients. Those tests also pass against main.

Map httpx2 exceptions inline

_terminal wrapped every send in _httpx2_exception_mapper / _httpx2_exception_mapper_sync, which are generator-based context managers. Entering and leaving one costs more than a try block. _terminal and both stream() methods now catch inline: MAPPED_HTTPX2_ERRORS (HTTPError, InvalidURL, CookieConflict) goes through map_httpx2_exception(exc) with from exc, and a RuntimeError becomes TransportError only when the httpx2 client is closed, otherwise it propagates unchanged. Nothing else used the two context managers, so they are gone. stream() is not a hot path; I inlined it too so the context managers could be deleted rather than kept for one caller each.

Two new tests in tests/test_error_mapping_terminal.py check that a RuntimeError from the transport on an open client reaches the caller as the same object, sync and async. Before this change only the shared context manager needed that branch covered, and the stream() tests did it.

Numbers

MockTransport, base_url set, cap off. Each change was measured in one process, swapping in the main version of the patched function and alternating with the new one, 15 to 21 rounds of 3 x 500 calls, best round reported. The machine had a load average of 5 to 13 during the runs, so absolute times are 2 to 4 times the quiet-machine audit figures (35 us for a sync get). The deltas were stable across runs; the ranges below cover them.

URL fast path (us per call, negative is faster):

case sync async
get("/x") -9 -7 to -9
get("/x", params=...) -7 to -8 -8
get("/x?q=1") -26 -23 to -24
get("/x?q=1", params=...) (merge path) about 0 about 0
client-level params -8 -8
post(json=...) -10 -8 to -15
post(json=..., params=...) -8 0 to -10
404 -6 to -7 -7

Inline exception mapping:

case sync async
get (any URL shape) -0.2 to -1.6 -1.5 to -6
post(json=...), with or without params -1.2 to -1.6 -3.6 to -9
404 about 0 -3 to -5

Both together on get("/x"): -12 to -13 us sync and async under the same load. A plain before/after across separate processes was too noisy on this machine to separate the changes, so I'm not quoting it.

Release notes (0.22.0)

None. No behaviour change.

@lesnik512
lesnik512 merged commit c97fc1d into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the perf/client-hot-path branch October 17, 2026 10:25
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.

1 participant