Skip to content

Commit c97fc1d

Browse files
authored
perf: cut per-request overhead in the client hot path (#182)
* perf: skip the URL query merge when there is no query or no params * perf: map httpx2 exceptions with an inline try/except instead of a context manager
1 parent 96e9a02 commit c97fc1d

4 files changed

Lines changed: 193 additions & 69 deletions

File tree

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1-
"""httpx2 -> httpware exception mapping + context-manager wrappers (shared).
1+
"""httpx2 -> httpware exception mapping (shared).
22
3-
map_httpx2_exception is a pure function used by both Client._terminal and
4-
AsyncClient._terminal, and by both stream() methods. Clause ordering:
3+
map_httpx2_exception is a pure function. Client._terminal, AsyncClient._terminal
4+
and both stream() methods call it from an inline `except MAPPED_HTTPX2_ERRORS`, and
5+
next to that clause turn the RuntimeError httpx2 raises on a closed client into
6+
TransportError. Clause ordering:
57
TimeoutException -> InvalidURL/CookieConflict -> NetworkError/RemoteProtocolError
6-
-> HTTPError (subclass before parent so the right type wins). The two context managers
7-
below wrap it for use as `with`/`async with` blocks around the httpx2 call,
8-
and also turn the RuntimeError httpx2 raises on a closed client into TransportError.
8+
-> HTTPError (subclass before parent so the right type wins).
99
"""
1010

11-
import contextlib
12-
from collections.abc import AsyncGenerator, Generator
13-
1411
import httpx2
1512

1613
from httpware.errors import NetworkError, TimeoutError, TransportError # noqa: A004
1714

1815

16+
MAPPED_HTTPX2_ERRORS = (httpx2.HTTPError, httpx2.InvalidURL, httpx2.CookieConflict)
17+
"""The httpx2 exceptions map_httpx2_exception translates."""
18+
19+
1920
def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | TransportError:
2021
"""Map an httpx2 exception to its httpware equivalent.
2122
@@ -31,36 +32,3 @@ def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | Tr
3132
if isinstance(exc, httpx2.HTTPError):
3233
return TransportError(str(exc))
3334
return TransportError(str(exc)) # pragma: no cover — defensive default; httpx2.HTTPError is the root
34-
35-
36-
@contextlib.asynccontextmanager
37-
async def _httpx2_exception_mapper(client: httpx2.AsyncClient) -> AsyncGenerator[None]:
38-
"""Map httpx2 exceptions, and use of a closed `client`, to httpware exceptions.
39-
40-
Shared by AsyncClient._terminal and stream().
41-
"""
42-
try:
43-
yield
44-
except httpx2.HTTPError as exc:
45-
raise map_httpx2_exception(exc) from exc
46-
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
47-
raise map_httpx2_exception(exc) from exc
48-
except RuntimeError as exc:
49-
if client.is_closed:
50-
raise TransportError(str(exc)) from exc
51-
raise
52-
53-
54-
@contextlib.contextmanager
55-
def _httpx2_exception_mapper_sync(client: httpx2.Client) -> Generator[None]:
56-
"""Sync sibling of _httpx2_exception_mapper."""
57-
try:
58-
yield
59-
except httpx2.HTTPError as exc:
60-
raise map_httpx2_exception(exc) from exc
61-
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
62-
raise map_httpx2_exception(exc) from exc
63-
except RuntimeError as exc:
64-
if client.is_closed:
65-
raise TransportError(str(exc)) from exc
66-
raise

‎src/httpware/client.py‎

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@
1818
_read_capped_async,
1919
_validate_max_response_body_bytes,
2020
)
21-
from httpware._internal.exception_mapping import (
22-
_httpx2_exception_mapper,
23-
_httpx2_exception_mapper_sync,
24-
map_httpx2_exception,
25-
)
21+
from httpware._internal.exception_mapping import MAPPED_HTTPX2_ERRORS, map_httpx2_exception
2622
from httpware._internal.status import (
2723
STREAMING_BODY_MARKER,
2824
_is_streaming_body_async,
@@ -32,6 +28,7 @@
3228
)
3329
from httpware.decoders import ResponseDecoder
3430
from httpware.decoders._resolver import _DecoderResolver
31+
from httpware.errors import TransportError
3532
from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next
3633
from httpware.middleware.chain import compose, compose_async
3734

@@ -390,6 +387,8 @@ def _build_request(
390387
client: httpx2.Client | httpx2.AsyncClient, method: str, url: httpx2.URL | str, kwargs: dict[str, typing.Any]
391388
) -> httpx2.Request:
392389
"""Build via `client`, keeping the URL's own query bytes ahead of any params, which httpx2 would replace."""
390+
if (isinstance(url, str) and "?" not in url) or (kwargs.get("params") is None and not client.params):
391+
return client.build_request(method, url, **kwargs)
393392
parsed = httpx2.URL(url)
394393
if not parsed.query:
395394
return client.build_request(method, url, **kwargs)
@@ -442,12 +441,18 @@ def __init__(
442441

443442
async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
444443
cap = self._max_response_body_bytes
445-
async with _httpx2_exception_mapper(self._httpx2_client):
444+
try:
446445
if cap is None:
447446
response = await self._httpx2_client.send(request)
448447
else:
449448
streaming = await _send_capped_async(self._httpx2_client, request, cap)
450449
response = await _read_capped_and_close_async(streaming, cap)
450+
except MAPPED_HTTPX2_ERRORS as exc:
451+
raise map_httpx2_exception(exc) from exc
452+
except RuntimeError as exc:
453+
if self._httpx2_client.is_closed:
454+
raise TransportError(str(exc)) from exc
455+
raise
451456
_raise_on_status_error(response)
452457
return response
453458

@@ -1292,7 +1297,7 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa
12921297
accessible. You lose the streaming property on errors; rare in practice.
12931298
12941299
Maps httpx2 exceptions raised during the request OR body consumption to
1295-
httpware exceptions via _httpx2_exception_mapper.
1300+
httpware exceptions.
12961301
"""
12971302
kwargs = _assemble_request_kwargs(
12981303
params=params,
@@ -1312,15 +1317,22 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa
13121317
if cap is None
13131318
else _stream_capped_async(self._httpx2_client, method, url, kwargs, cap)
13141319
)
1315-
async with _httpx2_exception_mapper(self._httpx2_client), opened as response:
1316-
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
1317-
if cap is None:
1318-
await response.aread() # pre-read body so exc.response.content works
1319-
_raise_on_status_error(response)
1320-
else:
1321-
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
1322-
_raise_on_status_error(await _read_capped_async(response, cap, response.request))
1323-
yield response
1320+
try:
1321+
async with opened as response:
1322+
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
1323+
if cap is None:
1324+
await response.aread() # pre-read body so exc.response.content works
1325+
_raise_on_status_error(response)
1326+
else:
1327+
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
1328+
_raise_on_status_error(await _read_capped_async(response, cap, response.request))
1329+
yield response
1330+
except MAPPED_HTTPX2_ERRORS as exc:
1331+
raise map_httpx2_exception(exc) from exc
1332+
except RuntimeError as exc:
1333+
if self._httpx2_client.is_closed:
1334+
raise TransportError(str(exc)) from exc
1335+
raise
13241336

13251337
async def __aenter__(self) -> typing.Self:
13261338
"""Enter the async context manager; return self."""
@@ -1391,12 +1403,18 @@ def __init__(
13911403

13921404
def _terminal(self, request: httpx2.Request) -> httpx2.Response:
13931405
cap = self._max_response_body_bytes
1394-
with _httpx2_exception_mapper_sync(self._httpx2_client):
1406+
try:
13951407
if cap is None:
13961408
response = self._httpx2_client.send(request)
13971409
else:
13981410
streaming = _send_capped(self._httpx2_client, request, cap)
13991411
response = _read_capped_and_close(streaming, cap)
1412+
except MAPPED_HTTPX2_ERRORS as exc:
1413+
raise map_httpx2_exception(exc) from exc
1414+
except RuntimeError as exc:
1415+
if self._httpx2_client.is_closed:
1416+
raise TransportError(str(exc)) from exc
1417+
raise
14001418
_raise_on_status_error(response)
14011419
return response
14021420

@@ -2258,7 +2276,7 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo
22582276
body is pre-read so exc.response.content is accessible.
22592277
22602278
Maps httpx2 exceptions raised during the request OR body consumption to
2261-
httpware exceptions via _httpx2_exception_mapper_sync.
2279+
httpware exceptions.
22622280
"""
22632281
kwargs = _assemble_request_kwargs(
22642282
params=params,
@@ -2278,12 +2296,19 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo
22782296
if cap is None
22792297
else _stream_capped(self._httpx2_client, method, url, kwargs, cap)
22802298
)
2281-
with _httpx2_exception_mapper_sync(self._httpx2_client), opened as response:
2282-
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
2283-
if cap is None:
2284-
response.read() # pre-read body so exc.response.content works
2285-
_raise_on_status_error(response)
2286-
else:
2287-
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
2288-
_raise_on_status_error(_read_capped(response, cap, response.request))
2289-
yield response
2299+
try:
2300+
with opened as response:
2301+
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
2302+
if cap is None:
2303+
response.read() # pre-read body so exc.response.content works
2304+
_raise_on_status_error(response)
2305+
else:
2306+
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
2307+
_raise_on_status_error(_read_capped(response, cap, response.request))
2308+
yield response
2309+
except MAPPED_HTTPX2_ERRORS as exc:
2310+
raise map_httpx2_exception(exc) from exc
2311+
except RuntimeError as exc:
2312+
if self._httpx2_client.is_closed:
2313+
raise TransportError(str(exc)) from exc
2314+
raise

‎tests/test_error_mapping_terminal.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ async def test_send_on_closed_client_raises_transport_error() -> None:
118118
await client.send(httpx2.Request("GET", "https://example.test/x"))
119119

120120

121+
async def test_runtime_error_on_open_client_propagates() -> None:
122+
raised = RuntimeError("synthetic runtime error")
123+
124+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
125+
raise raised
126+
127+
client = _client_with_handler(handler)
128+
with pytest.raises(RuntimeError) as info:
129+
await client.send(httpx2.Request("GET", "https://example.test/x"))
130+
assert info.value is raised
131+
132+
121133
async def test_httpx2_decoding_error_maps_to_transport_error() -> None:
122134
"""Non-transient HTTPError (e.g. DecodingError) maps to bare TransportError, not NetworkError."""
123135

@@ -235,6 +247,18 @@ def test_sync_send_on_closed_client_raises_transport_error() -> None:
235247
client.send(httpx2.Request("GET", "https://example.test/x"))
236248

237249

250+
def test_sync_runtime_error_on_open_client_propagates() -> None:
251+
raised = RuntimeError("synthetic runtime error")
252+
253+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
254+
raise raised
255+
256+
client = _sync_client_with_handler(handler)
257+
with pytest.raises(RuntimeError) as info:
258+
client.send(httpx2.Request("GET", "https://example.test/x"))
259+
assert info.value is raised
260+
261+
238262
def test_sync_httpx2_decoding_error_maps_to_transport_error() -> None:
239263
"""Non-transient HTTPError (e.g. DecodingError) maps to bare TransportError, not NetworkError."""
240264

‎tests/test_url_query_fast_path.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""URLs that skip the query merge, and boundary URLs that must not skip it, build the expected request."""
2+
3+
from http import HTTPStatus
4+
5+
import httpx2
6+
import pytest
7+
8+
from httpware import AsyncClient, Client
9+
10+
11+
def _ok(request: httpx2.Request) -> httpx2.Response:
12+
return httpx2.Response(HTTPStatus.OK, request=request)
13+
14+
15+
_CASES = [
16+
pytest.param("https://example.test/x", None, {}, "https://example.test/x", id="no-query-no-params"),
17+
pytest.param("https://example.test/x", {"b": "2"}, {"c": "3"}, "https://example.test/x?c=3&b=2", id="no-query"),
18+
pytest.param("https://example.test/x?a=1", None, {}, "https://example.test/x?a=1", id="query-without-params"),
19+
pytest.param(
20+
"https://example.test/x?a=1", {}, {}, "https://example.test/x?a=1", id="query-with-empty-params-not-skipped"
21+
),
22+
pytest.param(
23+
"https://example.test/x?a=1", None, {"c": "3"}, "https://example.test/x?a=1&c=3", id="client-params-not-skipped"
24+
),
25+
pytest.param(
26+
"https://example.test/x#f?a=1",
27+
{"b": "2"},
28+
{},
29+
"https://example.test/x?b=2#f?a=1",
30+
id="question-mark-in-fragment",
31+
),
32+
pytest.param(
33+
"https://example.test/x?a=1#f", {"b": "2"}, {}, "https://example.test/x?a=1&b=2#f", id="query-and-fragment"
34+
),
35+
pytest.param("https://example.test/x?", {"b": "2"}, {}, "https://example.test/x?b=2", id="empty-query"),
36+
pytest.param(
37+
httpx2.URL("https://example.test/x?a=1"),
38+
{"b": "2"},
39+
{},
40+
"https://example.test/x?a=1&b=2",
41+
id="url-instance-with-query-not-skipped",
42+
),
43+
pytest.param(
44+
httpx2.URL("https://example.test/x?a=1"), None, {}, "https://example.test/x?a=1", id="url-instance-no-params"
45+
),
46+
pytest.param(
47+
httpx2.URL("https://example.test/x"), {"b": "2"}, {}, "https://example.test/x?b=2", id="url-instance-no-query"
48+
),
49+
]
50+
51+
52+
@pytest.mark.parametrize(("url", "params", "client_params", "expected"), _CASES)
53+
def test_async_build_request_url(
54+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected: str
55+
) -> None:
56+
client = AsyncClient(httpx2_client=httpx2.AsyncClient(params=client_params))
57+
assert str(client.build_request("GET", url, params=params).url) == expected
58+
59+
60+
@pytest.mark.parametrize(("url", "params", "client_params", "expected"), _CASES)
61+
def test_sync_build_request_url(
62+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected: str
63+
) -> None:
64+
client = Client(httpx2_client=httpx2.Client(params=client_params))
65+
assert str(client.build_request("GET", url, params=params).url) == expected
66+
67+
68+
_BASE_URL_CASES = [
69+
pytest.param("items", None, {}, "https://example.test/api/items", id="relative-no-query"),
70+
pytest.param("items?a=1", None, {}, "https://example.test/api/items?a=1", id="relative-query-without-params"),
71+
pytest.param("/items?a=1", {"b": "2"}, {}, "https://example.test/api/items?a=1&b=2", id="relative-query-params"),
72+
pytest.param(
73+
"items?a=1", None, {"c": "3"}, "https://example.test/api/items?a=1&c=3", id="relative-query-client-params"
74+
),
75+
]
76+
77+
78+
@pytest.mark.parametrize(("url", "params", "client_params", "expected"), _BASE_URL_CASES)
79+
async def test_async_get_joins_base_url(
80+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected: str
81+
) -> None:
82+
captured: list[httpx2.Request] = []
83+
84+
def handler(request: httpx2.Request) -> httpx2.Response:
85+
captured.append(request)
86+
return _ok(request)
87+
88+
client = AsyncClient(
89+
base_url="https://example.test/api", params=client_params, transport=httpx2.MockTransport(handler)
90+
)
91+
await client.get(url, params=params)
92+
assert str(captured[0].url) == expected
93+
94+
95+
@pytest.mark.parametrize(("url", "params", "client_params", "expected"), _BASE_URL_CASES)
96+
def test_sync_get_joins_base_url(
97+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected: str
98+
) -> None:
99+
captured: list[httpx2.Request] = []
100+
101+
def handler(request: httpx2.Request) -> httpx2.Response:
102+
captured.append(request)
103+
return _ok(request)
104+
105+
client = Client(base_url="https://example.test/api", params=client_params, transport=httpx2.MockTransport(handler))
106+
client.get(url, params=params)
107+
assert str(captured[0].url) == expected

0 commit comments

Comments
 (0)