Skip to content

Commit 838b95c

Browse files
authored
feat: forward every httpx2 client option to the owned client (#148)
* feat: forward every httpx2 client option to the owned client AsyncClient and Client forwarded only seven httpx2 client keywords, so setting verify (or proxy, http2, transport, ...) meant building the httpx2 client by hand and closing it yourself. Every option is now forwarded, typed via Unpack[TypedDict]; cert and event_hooks are refused, and follow_redirects=True with max_response_body_bytes raises ValueError. * docs: drop the ADR for httpx2 client option forwarding
1 parent 05056f5 commit 838b95c

9 files changed

Lines changed: 265 additions & 264 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Every module under `src/httpware/` is named for what it does; read it. What a si
5959

6060
### Testing patterns
6161

62-
Transport mocking is `httpx2.MockTransport` passed as `httpx2_client=`, never `respx` — `respx`
62+
Transport mocking is `httpx2.MockTransport` passed as `transport=`, never `respx` — `respx`
6363
targets `httpx`, not `httpx2`, and patches its internals. Concurrency-sensitive components carry
6464
Hypothesis property tests in `test_*_props.py`, and `stress`-marked tests drive real thread
6565
parallelism: they run under the GIL for coverage, but the proof comes from the free-threaded

‎docs/errors.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ Unlike `DecodeError`, this error fires *before* the HTTP request — no traffic
190190

191191
## `ResponseTooLargeError`
192192

193-
Both `Client` and `AsyncClient` accept a `max_response_body_bytes: int | None = None` constructor argument. It's an opt-in cap — the default `None` means unbounded, matching current behavior. When set, a response body that exceeds the cap raises `ResponseTooLargeError` instead of being returned. The check is status-agnostic (a `200` can trip it just as easily as a `4xx`/`5xx`), and it counts **decoded** bytes. It fires from the non-streaming terminal (`send()` / verb methods) and from `stream()`'s internal error pre-read; bytes you pull yourself via `stream()` iteration are never capped.
193+
Both `Client` and `AsyncClient` accept a `max_response_body_bytes: int | None = None` constructor argument. It's an opt-in cap — the default `None` means unbounded, matching current behavior. When set, a response body that exceeds the cap raises `ResponseTooLargeError` instead of being returned. The check is status-agnostic (a `200` can trip it just as easily as a `4xx`/`5xx`), and it counts **decoded** bytes. It fires from the non-streaming terminal (`send()` / verb methods) and from `stream()`'s internal error pre-read; bytes you pull yourself via `stream()` iteration are never capped. Combining the cap with `follow_redirects=True`, on the client or on a passed `httpx2_client`, raises `ValueError`: `httpx2` reads every intermediate redirect body without it.
194194

195195
`ResponseTooLargeError` carries:
196196

‎docs/index.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ with Client(base_url="https://jsonplaceholder.typicode.com") as client:
6464

6565
`base_url` must not contain a query string: constructing a client with one raises `ValueError`. Put query parameters shared by every request in `params=` instead.
6666

67+
Every other keyword of `httpx2.AsyncClient`/`httpx2.Client` (`verify`, `proxy`, `http2`, `transport`, `follow_redirects`, ...) is forwarded to the `httpx2` client that `httpware` builds and closes. Two are refused: `cert`, deprecated by `httpx2` in favour of an `ssl.SSLContext` passed as `verify`, and `event_hooks`, which run below the middleware chain; use [middleware](middleware.md) instead.
68+
69+
```python
70+
import ssl
71+
72+
from httpware import AsyncClient
73+
74+
client = AsyncClient(
75+
base_url="https://internal.example",
76+
verify=ssl.create_default_context(cafile="/etc/ssl/internal-ca.pem"),
77+
)
78+
```
79+
80+
To share one connection pool between several clients, build the `httpx2` client yourself and pass it as `httpx2_client=`. It is then yours to close, and none of the options above can be combined with it.
81+
6782
Typed decoding via `response_model=` works the same way in both worlds:
6883

6984
```python

‎docs/testing.md‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Testing guide
22

3-
`httpware`'s test seam is `httpx2`. Pass any `httpx2.AsyncClient` (including one built on `httpx2.MockTransport`) to `AsyncClient(httpx2_client=...)` — the middleware chain still runs end-to-end, only the wire is mocked. No special test mode, no monkey-patching, no `respx`.
3+
`httpware`'s test seam is `httpx2`. Pass an `httpx2.MockTransport` as `AsyncClient(transport=...)` — the middleware chain still runs end-to-end, only the wire is mocked, and the client still owns and closes its `httpx2` client. No special test mode, no monkey-patching, no `respx`.
44

5-
`httpx2_client=` is mutually exclusive with `base_url`, `headers`, `params`, `cookies`, `timeout`, `limits`, and `auth`: passing any of those alongside a pre-built `httpx2_client=` raises `TypeError`. Configure the `httpx2.AsyncClient`/`httpx2.Client` you pass instead.
5+
A pre-built `httpx2.AsyncClient`/`httpx2.Client` can be passed as `httpx2_client=` instead. It is mutually exclusive with every `httpx2` client option (`base_url`, `headers`, `transport`, `verify`, ...): passing any of them alongside it raises `TypeError`. Configure the client you pass instead; `httpware` will not close it.
66

77
## The basic pattern
88

@@ -19,8 +19,7 @@ def handler(request: httpx2.Request) -> httpx2.Response:
1919

2020

2121
async def test_get_user() -> None:
22-
transport = httpx2.MockTransport(handler)
23-
async with AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) as client:
22+
async with AsyncClient(transport=httpx2.MockTransport(handler)) as client:
2423
response = await client.get("https://api.example.test/users/1")
2524
assert response.status_code == HTTPStatus.OK
2625
assert response.json()["name"] == "Alice"
@@ -32,7 +31,7 @@ If you use `pytest-asyncio` in auto-mode (`asyncio_mode = "auto"` under `[tool.p
3231

3332
### Sync `Client`
3433

35-
The same pattern works for the sync `Client` — pass an `httpx2.Client` (not `httpx2.AsyncClient`) built on `httpx2.MockTransport`:
34+
The same pattern works for the sync `Client`; `httpx2.MockTransport` serves both worlds:
3635

3736
```python
3837
from http import HTTPStatus
@@ -46,7 +45,7 @@ def test_get_returns_typed_response() -> None:
4645
def handler(request: httpx2.Request) -> httpx2.Response:
4746
return httpx2.Response(HTTPStatus.OK, request=request, json={"ok": True})
4847

49-
with Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler))) as client:
48+
with Client(transport=httpx2.MockTransport(handler)) as client:
5049
response = client.get("https://example.test/x")
5150

5251
assert response.status_code == HTTPStatus.OK
@@ -76,9 +75,8 @@ class _ResponseSequence:
7675

7776
async def test_retry_succeeds_after_503() -> None:
7877
handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK])
79-
transport = httpx2.MockTransport(handler)
8078
async with AsyncClient(
81-
httpx2_client=httpx2.AsyncClient(transport=transport),
79+
transport=httpx2.MockTransport(handler),
8280
middleware=[AsyncRetry(base_delay=0.001, max_delay=0.002)],
8381
) as client:
8482
response = await client.get("https://example.test/x")
@@ -96,7 +94,7 @@ Compose your middleware with the mock transport to exercise the chain end-to-end
9694
async def test_my_middleware_adds_header() -> None:
9795
handler = _ResponseSequence([HTTPStatus.OK])
9896
async with AsyncClient(
99-
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)),
97+
transport=httpx2.MockTransport(handler),
10098
middleware=[MyHeaderMiddleware()],
10199
) as client:
102100
await client.get("https://example.test/x")

‎src/httpware/client.py‎

Lines changed: 95 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Client + AsyncClient — thin httpx2 wrappers with typed decoding and middleware."""
22

33
import contextlib
4+
import ssl
45
import typing
5-
from collections.abc import AsyncIterator, Iterator, Sequence
6+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
67
from http import HTTPStatus
78

89
import httpx2
@@ -29,10 +30,17 @@
2930
T = typing.TypeVar("T")
3031

3132

32-
_FORWARDED_KWARG_NAMES = ("base_url", "headers", "params", "cookies", "timeout", "limits", "auth")
3333
_HTTPX2_CLIENT_CONFLICT_MESSAGE = (
34-
"httpx2_client=... cannot be combined with any of "
35-
f"{_FORWARDED_KWARG_NAMES}; configure the httpx2 client you pass instead."
34+
"httpx2_client=... cannot be combined with httpx2 client options {names}; "
35+
"configure the httpx2 client you pass instead."
36+
)
37+
_UNSUPPORTED_OPTION_HINTS = {
38+
"cert": "cert=... is deprecated by httpx2; pass verify=<ssl.SSLContext> configured with .load_cert_chain().",
39+
"event_hooks": "event_hooks=... is not supported; use middleware=... instead.",
40+
}
41+
_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE = (
42+
"follow_redirects=True cannot be combined with max_response_body_bytes: httpx2 reads every "
43+
"intermediate redirect body without the cap."
3644
)
3745
_BASE_URL_QUERY_MESSAGE = (
3846
"base_url must not contain a query string: httpx2 appends request paths after it, "
@@ -61,63 +69,75 @@ def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
6169
return tuple(decoders)
6270

6371

64-
def _validate_httpx2_client_conflict( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor
65-
*,
66-
base_url: str,
67-
headers: dict[str, str] | None,
68-
params: dict[str, str] | None,
69-
cookies: dict[str, str] | None,
70-
timeout: httpx2.Timeout | float | None,
71-
limits: httpx2.Limits | None,
72-
auth: httpx2.Auth | None,
73-
) -> None:
74-
"""Raise TypeError if httpx2_client=... is combined with a forwarded kwarg."""
75-
forwarded = {
76-
"base_url": base_url,
77-
"headers": headers,
78-
"params": params,
79-
"cookies": cookies,
80-
"timeout": timeout,
81-
"limits": limits,
82-
"auth": auth,
83-
}
84-
if any(value not in (None, "") for value in forwarded.values()):
85-
raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE)
86-
87-
8872
def _reject_base_url_query(base_url: httpx2.URL | str) -> None:
8973
"""Raise ValueError if base_url carries a query string."""
9074
if httpx2.URL(base_url).query:
9175
raise ValueError(_BASE_URL_QUERY_MESSAGE)
9276

9377

94-
def _assemble_httpx2_client_kwargs( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor
78+
class _ClientOptionsBase(typing.TypedDict, total=False):
79+
base_url: str
80+
headers: dict[str, str] | None
81+
params: dict[str, str] | None
82+
cookies: dict[str, str] | None
83+
timeout: httpx2.Timeout | float | None
84+
limits: httpx2.Limits | None
85+
auth: httpx2.Auth | None
86+
verify: ssl.SSLContext | bool
87+
trust_env: bool
88+
http1: bool
89+
http2: bool
90+
proxy: httpx2.URL | str | httpx2.Proxy | None
91+
follow_redirects: bool
92+
max_redirects: int
93+
default_encoding: str | Callable[[bytes], str | None]
94+
95+
96+
class _AsyncClientOptions(_ClientOptionsBase, total=False):
97+
"""Keyword arguments `AsyncClient` forwards to the `httpx2.AsyncClient` it owns."""
98+
99+
transport: httpx2.AsyncBaseTransport | None
100+
mounts: Mapping[str, httpx2.AsyncBaseTransport | None] | None
101+
102+
103+
class _ClientOptions(_ClientOptionsBase, total=False):
104+
"""Keyword arguments `Client` forwards to the `httpx2.Client` it owns."""
105+
106+
transport: httpx2.BaseTransport | None
107+
mounts: Mapping[str, httpx2.BaseTransport | None] | None
108+
109+
110+
def _is_unset(value: object) -> bool:
111+
return value is None or (isinstance(value, str) and not value)
112+
113+
114+
def _select_httpx2_options(
115+
owner: str,
116+
options: Mapping[str, typing.Any],
117+
supported: frozenset[str],
95118
*,
96-
base_url: str,
97-
headers: dict[str, str] | None,
98-
params: dict[str, str] | None,
99-
cookies: dict[str, str] | None,
100-
timeout: httpx2.Timeout | float | None,
101-
limits: httpx2.Limits | None,
102-
auth: httpx2.Auth | None,
119+
httpx2_client: httpx2.Client | httpx2.AsyncClient | None,
120+
max_response_body_bytes: int | None,
103121
) -> dict[str, typing.Any]:
104-
"""Build the kwargs dict for constructing the owned httpx2 client."""
105-
kwargs: dict[str, typing.Any] = {}
106-
if base_url:
107-
kwargs["base_url"] = base_url
108-
if headers is not None:
109-
kwargs["headers"] = headers
110-
if params is not None:
111-
kwargs["params"] = params
112-
if cookies is not None:
113-
kwargs["cookies"] = cookies
114-
if timeout is not None:
115-
kwargs["timeout"] = timeout
116-
if limits is not None:
117-
kwargs["limits"] = limits
118-
if auth is not None:
119-
kwargs["auth"] = auth
120-
return kwargs
122+
"""Return the options to forward to the owned httpx2 client, dropping unset ones.
123+
124+
Raise TypeError for unsupported options or options combined with `httpx2_client`, and
125+
ValueError when the client would follow redirects under a body cap.
126+
"""
127+
unsupported = sorted(options.keys() - supported)
128+
if unsupported:
129+
hints = "".join(
130+
f" {_UNSUPPORTED_OPTION_HINTS[name]}" for name in unsupported if name in _UNSUPPORTED_OPTION_HINTS
131+
)
132+
msg = f"{owner}() got unexpected keyword arguments {unsupported}.{hints}"
133+
raise TypeError(msg)
134+
forwarded = {name: value for name, value in options.items() if not _is_unset(value)}
135+
if httpx2_client is not None and forwarded:
136+
raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE.format(names=sorted(forwarded)))
137+
follows = httpx2_client.follow_redirects if httpx2_client is not None else forwarded.get("follow_redirects")
138+
if follows and max_response_body_bytes is not None:
139+
raise ValueError(_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE)
140+
return forwarded
121141

122142

123143
def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from httpx2 call signatures
@@ -175,47 +195,30 @@ class AsyncClient:
175195
_dispatch: AsyncNext
176196
_max_response_body_bytes: int | None
177197

178-
def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
198+
def __init__(
179199
self,
180200
*,
181-
base_url: str = "",
182-
headers: dict[str, str] | None = None,
183-
params: dict[str, str] | None = None,
184-
cookies: dict[str, str] | None = None,
185-
timeout: httpx2.Timeout | float | None = None,
186-
limits: httpx2.Limits | None = None,
187-
auth: httpx2.Auth | None = None,
188201
httpx2_client: httpx2.AsyncClient | None = None,
189202
decoders: Sequence[ResponseDecoder] | None = None,
190203
middleware: Sequence[AsyncMiddleware] = (),
191204
max_response_body_bytes: int | None = None,
205+
**httpx2_options: typing.Unpack[_AsyncClientOptions],
192206
) -> None:
193207
_validate_max_response_body_bytes(max_response_body_bytes)
208+
forwarded = _select_httpx2_options(
209+
type(self).__name__,
210+
httpx2_options,
211+
_AsyncClientOptions.__optional_keys__,
212+
httpx2_client=httpx2_client,
213+
max_response_body_bytes=max_response_body_bytes,
214+
)
194215
if httpx2_client is not None:
195-
_validate_httpx2_client_conflict(
196-
base_url=base_url,
197-
headers=headers,
198-
params=params,
199-
cookies=cookies,
200-
timeout=timeout,
201-
limits=limits,
202-
auth=auth,
203-
)
204216
_reject_base_url_query(httpx2_client.base_url)
205217
self._httpx2_client = httpx2_client
206218
self._owns_client = False
207219
else:
208-
_reject_base_url_query(base_url)
209-
kwargs = _assemble_httpx2_client_kwargs(
210-
base_url=base_url,
211-
headers=headers,
212-
params=params,
213-
cookies=cookies,
214-
timeout=timeout,
215-
limits=limits,
216-
auth=auth,
217-
)
218-
self._httpx2_client = httpx2.AsyncClient(**kwargs)
220+
_reject_base_url_query(forwarded.get("base_url", ""))
221+
self._httpx2_client = httpx2.AsyncClient(**forwarded)
219222
self._owns_client = True
220223

221224
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
@@ -1129,47 +1132,30 @@ class Client:
11291132
_dispatch: Next
11301133
_max_response_body_bytes: int | None
11311134

1132-
def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
1135+
def __init__(
11331136
self,
11341137
*,
1135-
base_url: str = "",
1136-
headers: dict[str, str] | None = None,
1137-
params: dict[str, str] | None = None,
1138-
cookies: dict[str, str] | None = None,
1139-
timeout: httpx2.Timeout | float | None = None,
1140-
limits: httpx2.Limits | None = None,
1141-
auth: httpx2.Auth | None = None,
11421138
httpx2_client: httpx2.Client | None = None,
11431139
decoders: Sequence[ResponseDecoder] | None = None,
11441140
middleware: Sequence[Middleware] = (),
11451141
max_response_body_bytes: int | None = None,
1142+
**httpx2_options: typing.Unpack[_ClientOptions],
11461143
) -> None:
11471144
_validate_max_response_body_bytes(max_response_body_bytes)
1145+
forwarded = _select_httpx2_options(
1146+
type(self).__name__,
1147+
httpx2_options,
1148+
_ClientOptions.__optional_keys__,
1149+
httpx2_client=httpx2_client,
1150+
max_response_body_bytes=max_response_body_bytes,
1151+
)
11481152
if httpx2_client is not None:
1149-
_validate_httpx2_client_conflict(
1150-
base_url=base_url,
1151-
headers=headers,
1152-
params=params,
1153-
cookies=cookies,
1154-
timeout=timeout,
1155-
limits=limits,
1156-
auth=auth,
1157-
)
11581153
_reject_base_url_query(httpx2_client.base_url)
11591154
self._httpx2_client = httpx2_client
11601155
self._owns_client = False
11611156
else:
1162-
_reject_base_url_query(base_url)
1163-
kwargs = _assemble_httpx2_client_kwargs(
1164-
base_url=base_url,
1165-
headers=headers,
1166-
params=params,
1167-
cookies=cookies,
1168-
timeout=timeout,
1169-
limits=limits,
1170-
auth=auth,
1171-
)
1172-
self._httpx2_client = httpx2.Client(**kwargs)
1157+
_reject_base_url_query(forwarded.get("base_url", ""))
1158+
self._httpx2_client = httpx2.Client(**forwarded)
11731159
self._owns_client = True
11741160

11751161
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()

0 commit comments

Comments
 (0)