From ad269695972268c202a594bfe65ee7ee959ae19e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 1 Oct 2026 13:47:17 +0300 Subject: [PATCH 1/2] 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. --- AGENTS.md | 2 +- ...every-httpx2-client-option-is-forwarded.md | 18 ++ docs/errors.md | 2 +- docs/index.md | 15 ++ docs/testing.md | 16 +- src/httpware/client.py | 204 ++++++++---------- tests/test_client_construction.py | 33 --- tests/test_client_options.py | 145 +++++++++++++ tests/test_client_sync.py | 35 --- tests/test_request_assembly.py | 77 +------ 10 files changed, 283 insertions(+), 264 deletions(-) create mode 100644 docs/adr/0011-every-httpx2-client-option-is-forwarded.md create mode 100644 tests/test_client_options.py diff --git a/AGENTS.md b/AGENTS.md index 34c299b..5c584ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Every module under `src/httpware/` is named for what it does; read it. What a si ### Testing patterns -Transport mocking is `httpx2.MockTransport` passed as `httpx2_client=`, never `respx` — `respx` +Transport mocking is `httpx2.MockTransport` passed as `transport=`, never `respx` — `respx` targets `httpx`, not `httpx2`, and patches its internals. Concurrency-sensitive components carry Hypothesis property tests in `test_*_props.py`, and `stress`-marked tests drive real thread parallelism: they run under the GIL for coverage, but the proof comes from the free-threaded diff --git a/docs/adr/0011-every-httpx2-client-option-is-forwarded.md b/docs/adr/0011-every-httpx2-client-option-is-forwarded.md new file mode 100644 index 0000000..2b0bf51 --- /dev/null +++ b/docs/adr/0011-every-httpx2-client-option-is-forwarded.md @@ -0,0 +1,18 @@ +# Every httpx2 client option is forwarded + +`Client` and `AsyncClient` forward every keyword of the `httpx2` client constructor to the client +they build and own, typed through `**kwargs: Unpack[TypedDict]`. Forwarding a hand-picked subset +made one missing option, such as `verify`, cost the caller the whole construction: they had to +build the `httpx2` client themselves, move every other option onto it, and close it themselves. A +named parameter per option was rejected because each one had to be listed three times, and an +untyped `httpx2_client_kwargs=` dict because it gives up the checking that the TypedDict keeps. +Two options are refused: `cert`, deprecated by httpx2 in favour of an `ssl.SSLContext` passed as +`verify`, and `event_hooks`, which run below the middleware chain, so an `httpx2.HTTPError` raised +in a hook reaches retry and the circuit breaker as a `TransportError`. `follow_redirects=True` +cannot be combined with `max_response_body_bytes`, because httpx2 reads intermediate redirect +bodies without the cap ([ADR-0009](0009-the-body-cap-counts-decoded-bytes.md)). Unset options +(`None`, an empty `base_url`) are dropped rather than forwarded, because `timeout=None` means "the +httpx2 default" to httpware but "no timeout" to httpx2. `ty` does not reject an unknown key under +`Unpack`, so the constructor checks keys at runtime, and +`tests/test_client_options.py::test_options_cover_every_httpx2_client_kwarg_except_excluded` fails +when an httpx2 upgrade adds an option nobody has forwarded or refused. diff --git a/docs/errors.md b/docs/errors.md index 5269e93..fdf7850 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -190,7 +190,7 @@ Unlike `DecodeError`, this error fires *before* the HTTP request — no traffic ## `ResponseTooLargeError` -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. +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. `ResponseTooLargeError` carries: diff --git a/docs/index.md b/docs/index.md index 87584bb..28b157c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,6 +64,21 @@ with Client(base_url="https://jsonplaceholder.typicode.com") as client: `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. +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. + +```python +import ssl + +from httpware import AsyncClient + +client = AsyncClient( + base_url="https://internal.example", + verify=ssl.create_default_context(cafile="/etc/ssl/internal-ca.pem"), +) +``` + +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. + Typed decoding via `response_model=` works the same way in both worlds: ```python diff --git a/docs/testing.md b/docs/testing.md index eaeabd5..b67d58a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,8 +1,8 @@ # Testing guide -`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`. +`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`. -`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. +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. ## The basic pattern @@ -19,8 +19,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: async def test_get_user() -> None: - transport = httpx2.MockTransport(handler) - async with AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) as client: + async with AsyncClient(transport=httpx2.MockTransport(handler)) as client: response = await client.get("https://api.example.test/users/1") assert response.status_code == HTTPStatus.OK assert response.json()["name"] == "Alice" @@ -32,7 +31,7 @@ If you use `pytest-asyncio` in auto-mode (`asyncio_mode = "auto"` under `[tool.p ### Sync `Client` -The same pattern works for the sync `Client` — pass an `httpx2.Client` (not `httpx2.AsyncClient`) built on `httpx2.MockTransport`: +The same pattern works for the sync `Client`; `httpx2.MockTransport` serves both worlds: ```python from http import HTTPStatus @@ -46,7 +45,7 @@ def test_get_returns_typed_response() -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(HTTPStatus.OK, request=request, json={"ok": True}) - with Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler))) as client: + with Client(transport=httpx2.MockTransport(handler)) as client: response = client.get("https://example.test/x") assert response.status_code == HTTPStatus.OK @@ -76,9 +75,8 @@ class _ResponseSequence: async def test_retry_succeeds_after_503() -> None: handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK]) - transport = httpx2.MockTransport(handler) async with AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), + transport=httpx2.MockTransport(handler), middleware=[AsyncRetry(base_delay=0.001, max_delay=0.002)], ) as client: 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 async def test_my_middleware_adds_header() -> None: handler = _ResponseSequence([HTTPStatus.OK]) async with AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + transport=httpx2.MockTransport(handler), middleware=[MyHeaderMiddleware()], ) as client: await client.get("https://example.test/x") diff --git a/src/httpware/client.py b/src/httpware/client.py index 222f2a3..807070a 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -1,8 +1,9 @@ """Client + AsyncClient — thin httpx2 wrappers with typed decoding and middleware.""" import contextlib +import ssl import typing -from collections.abc import AsyncIterator, Iterator, Sequence +from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence from http import HTTPStatus import httpx2 @@ -29,10 +30,17 @@ T = typing.TypeVar("T") -_FORWARDED_KWARG_NAMES = ("base_url", "headers", "params", "cookies", "timeout", "limits", "auth") _HTTPX2_CLIENT_CONFLICT_MESSAGE = ( - "httpx2_client=... cannot be combined with any of " - f"{_FORWARDED_KWARG_NAMES}; configure the httpx2 client you pass instead." + "httpx2_client=... cannot be combined with httpx2 client options {names}; " + "configure the httpx2 client you pass instead." +) +_UNSUPPORTED_OPTION_HINTS = { + "cert": "cert=... is deprecated by httpx2; pass verify= configured with .load_cert_chain().", + "event_hooks": "event_hooks=... is not supported; use middleware=... instead.", +} +_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE = ( + "follow_redirects=True cannot be combined with max_response_body_bytes: httpx2 reads every " + "intermediate redirect body without the cap." ) _BASE_URL_QUERY_MESSAGE = ( "base_url must not contain a query string: httpx2 appends request paths after it, " @@ -61,63 +69,75 @@ def _build_default_decoders() -> tuple[ResponseDecoder, ...]: return tuple(decoders) -def _validate_httpx2_client_conflict( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor - *, - base_url: str, - headers: dict[str, str] | None, - params: dict[str, str] | None, - cookies: dict[str, str] | None, - timeout: httpx2.Timeout | float | None, - limits: httpx2.Limits | None, - auth: httpx2.Auth | None, -) -> None: - """Raise TypeError if httpx2_client=... is combined with a forwarded kwarg.""" - forwarded = { - "base_url": base_url, - "headers": headers, - "params": params, - "cookies": cookies, - "timeout": timeout, - "limits": limits, - "auth": auth, - } - if any(value not in (None, "") for value in forwarded.values()): - raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE) - - def _reject_base_url_query(base_url: httpx2.URL | str) -> None: """Raise ValueError if base_url carries a query string.""" if httpx2.URL(base_url).query: raise ValueError(_BASE_URL_QUERY_MESSAGE) -def _assemble_httpx2_client_kwargs( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor +class _ClientOptionsBase(typing.TypedDict, total=False): + base_url: str + headers: dict[str, str] | None + params: dict[str, str] | None + cookies: dict[str, str] | None + timeout: httpx2.Timeout | float | None + limits: httpx2.Limits | None + auth: httpx2.Auth | None + verify: ssl.SSLContext | bool + trust_env: bool + http1: bool + http2: bool + proxy: httpx2.URL | str | httpx2.Proxy | None + follow_redirects: bool + max_redirects: int + default_encoding: str | Callable[[bytes], str | None] + + +class _AsyncClientOptions(_ClientOptionsBase, total=False): + """Keyword arguments `AsyncClient` forwards to the `httpx2.AsyncClient` it owns.""" + + transport: httpx2.AsyncBaseTransport | None + mounts: Mapping[str, httpx2.AsyncBaseTransport | None] | None + + +class _ClientOptions(_ClientOptionsBase, total=False): + """Keyword arguments `Client` forwards to the `httpx2.Client` it owns.""" + + transport: httpx2.BaseTransport | None + mounts: Mapping[str, httpx2.BaseTransport | None] | None + + +def _is_unset(value: object) -> bool: + return value is None or (isinstance(value, str) and not value) + + +def _select_httpx2_options( + owner: str, + options: Mapping[str, typing.Any], + supported: frozenset[str], *, - base_url: str, - headers: dict[str, str] | None, - params: dict[str, str] | None, - cookies: dict[str, str] | None, - timeout: httpx2.Timeout | float | None, - limits: httpx2.Limits | None, - auth: httpx2.Auth | None, + httpx2_client: httpx2.Client | httpx2.AsyncClient | None, + max_response_body_bytes: int | None, ) -> dict[str, typing.Any]: - """Build the kwargs dict for constructing the owned httpx2 client.""" - kwargs: dict[str, typing.Any] = {} - if base_url: - kwargs["base_url"] = base_url - if headers is not None: - kwargs["headers"] = headers - if params is not None: - kwargs["params"] = params - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not None: - kwargs["timeout"] = timeout - if limits is not None: - kwargs["limits"] = limits - if auth is not None: - kwargs["auth"] = auth - return kwargs + """Return the options to forward to the owned httpx2 client, dropping unset ones. + + Raise TypeError for unsupported options or options combined with `httpx2_client`, and + ValueError when the client would follow redirects under a body cap. + """ + unsupported = sorted(options.keys() - supported) + if unsupported: + hints = "".join( + f" {_UNSUPPORTED_OPTION_HINTS[name]}" for name in unsupported if name in _UNSUPPORTED_OPTION_HINTS + ) + msg = f"{owner}() got unexpected keyword arguments {unsupported}.{hints}" + raise TypeError(msg) + forwarded = {name: value for name, value in options.items() if not _is_unset(value)} + if httpx2_client is not None and forwarded: + raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE.format(names=sorted(forwarded))) + follows = httpx2_client.follow_redirects if httpx2_client is not None else forwarded.get("follow_redirects") + if follows and max_response_body_bytes is not None: + raise ValueError(_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE) + return forwarded def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from httpx2 call signatures @@ -175,47 +195,30 @@ class AsyncClient: _dispatch: AsyncNext _max_response_body_bytes: int | None - def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API + def __init__( self, *, - base_url: str = "", - headers: dict[str, str] | None = None, - params: dict[str, str] | None = None, - cookies: dict[str, str] | None = None, - timeout: httpx2.Timeout | float | None = None, - limits: httpx2.Limits | None = None, - auth: httpx2.Auth | None = None, httpx2_client: httpx2.AsyncClient | None = None, decoders: Sequence[ResponseDecoder] | None = None, middleware: Sequence[AsyncMiddleware] = (), max_response_body_bytes: int | None = None, + **httpx2_options: typing.Unpack[_AsyncClientOptions], ) -> None: _validate_max_response_body_bytes(max_response_body_bytes) + forwarded = _select_httpx2_options( + type(self).__name__, + httpx2_options, + _AsyncClientOptions.__optional_keys__, + httpx2_client=httpx2_client, + max_response_body_bytes=max_response_body_bytes, + ) if httpx2_client is not None: - _validate_httpx2_client_conflict( - base_url=base_url, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - limits=limits, - auth=auth, - ) _reject_base_url_query(httpx2_client.base_url) self._httpx2_client = httpx2_client self._owns_client = False else: - _reject_base_url_query(base_url) - kwargs = _assemble_httpx2_client_kwargs( - base_url=base_url, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - limits=limits, - auth=auth, - ) - self._httpx2_client = httpx2.AsyncClient(**kwargs) + _reject_base_url_query(forwarded.get("base_url", "")) + self._httpx2_client = httpx2.AsyncClient(**forwarded) self._owns_client = True self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() @@ -1129,47 +1132,30 @@ class Client: _dispatch: Next _max_response_body_bytes: int | None - def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API + def __init__( self, *, - base_url: str = "", - headers: dict[str, str] | None = None, - params: dict[str, str] | None = None, - cookies: dict[str, str] | None = None, - timeout: httpx2.Timeout | float | None = None, - limits: httpx2.Limits | None = None, - auth: httpx2.Auth | None = None, httpx2_client: httpx2.Client | None = None, decoders: Sequence[ResponseDecoder] | None = None, middleware: Sequence[Middleware] = (), max_response_body_bytes: int | None = None, + **httpx2_options: typing.Unpack[_ClientOptions], ) -> None: _validate_max_response_body_bytes(max_response_body_bytes) + forwarded = _select_httpx2_options( + type(self).__name__, + httpx2_options, + _ClientOptions.__optional_keys__, + httpx2_client=httpx2_client, + max_response_body_bytes=max_response_body_bytes, + ) if httpx2_client is not None: - _validate_httpx2_client_conflict( - base_url=base_url, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - limits=limits, - auth=auth, - ) _reject_base_url_query(httpx2_client.base_url) self._httpx2_client = httpx2_client self._owns_client = False else: - _reject_base_url_query(base_url) - kwargs = _assemble_httpx2_client_kwargs( - base_url=base_url, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - limits=limits, - auth=auth, - ) - self._httpx2_client = httpx2.Client(**kwargs) + _reject_base_url_query(forwarded.get("base_url", "")) + self._httpx2_client = httpx2.Client(**forwarded) self._owns_client = True self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() diff --git a/tests/test_client_construction.py b/tests/test_client_construction.py index f9a809b..a80907e 100644 --- a/tests/test_client_construction.py +++ b/tests/test_client_construction.py @@ -33,25 +33,6 @@ def test_construction_with_caller_owned_httpx2_client() -> None: assert isinstance(client, AsyncClient) -@pytest.mark.parametrize( - "kwargs", - [ - {"base_url": "https://example.test"}, - {"headers": {"x": "1"}}, - {"params": {"x": "1"}}, - {"cookies": {"x": "1"}}, - {"timeout": 5.0}, - {"limits": httpx2.Limits(max_connections=10)}, - {"auth": httpx2.BasicAuth("u", "p")}, - ], -) -def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - caller = httpx2.AsyncClient(transport=transport) - with pytest.raises(TypeError, match="httpx2_client"): - AsyncClient(httpx2_client=caller, **kwargs) - - def test_default_decoders_includes_pydantic_when_installed() -> None: client = AsyncClient() assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 @@ -96,20 +77,6 @@ class _Foo: assert exc_info.value.registered_names == () -@pytest.mark.parametrize( - "kwargs", - [ - {"cookies": {"session": "abc"}}, - {"limits": httpx2.Limits(max_connections=5)}, - {"auth": httpx2.BasicAuth("user", "pass")}, - ], -) -def test_construction_with_optional_forwarded_kwargs(kwargs: dict) -> None: - """Exercises cookies/limits/auth branches in __init__ when no httpx2_client is supplied.""" - client = AsyncClient(**kwargs) - assert isinstance(client, AsyncClient) - - def test_explicit_middleware_is_honored() -> None: captured: list[str] = [] diff --git a/tests/test_client_options.py b/tests/test_client_options.py new file mode 100644 index 0000000..d93dc00 --- /dev/null +++ b/tests/test_client_options.py @@ -0,0 +1,145 @@ +"""httpx2 client options are forwarded to the owned httpx2 client by both worlds.""" + +import inspect +import ssl +import typing +from http import HTTPStatus +from unittest.mock import patch + +import httpx2 +import pytest + +from httpware import AsyncClient, Client +from httpware.client import _AsyncClientOptions, _ClientOptions + + +_EXCLUDED = {"cert", "event_hooks"} + + +def _ok(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, request=request) + + +def _samples() -> dict[str, typing.Any]: + transport = httpx2.MockTransport(_ok) + return { + "base_url": "https://example.test", + "headers": {"x": "1"}, + "params": {"x": "1"}, + "cookies": {"x": "1"}, + "timeout": 5.0, + "limits": httpx2.Limits(max_connections=10), + "auth": httpx2.BasicAuth("u", "p"), + "verify": ssl.create_default_context(), + "trust_env": False, + "http1": False, + "http2": True, + "proxy": "http://proxy.test:8080", + "follow_redirects": True, + "max_redirects": 3, + "default_encoding": "latin-1", + "mounts": {"http://": transport}, + "transport": transport, + } + + +_KEYS = sorted(_samples()) + + +_WORLDS = [ + pytest.param(AsyncClient, "AsyncClient", id="async"), + pytest.param(Client, "Client", id="sync"), +] + + +@pytest.mark.parametrize( + ("httpx2_name", "option_keys"), + [ + pytest.param("AsyncClient", _AsyncClientOptions.__optional_keys__, id="async"), + pytest.param("Client", _ClientOptions.__optional_keys__, id="sync"), + ], +) +def test_options_cover_every_httpx2_client_kwarg_except_excluded(httpx2_name: str, option_keys: frozenset[str]) -> None: + httpx2_kwargs = set(inspect.signature(getattr(httpx2, httpx2_name).__init__).parameters) - {"self"} + + assert option_keys == httpx2_kwargs - _EXCLUDED + assert set(_KEYS) == option_keys + + +@pytest.mark.parametrize("key", _KEYS) +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_option_is_forwarded_to_owned_httpx2_client( + client_cls: type, + httpx2_name: str, + key: str, +) -> None: + value = _samples()[key] + with patch.object(httpx2, httpx2_name) as httpx2_cls: + client_cls(**{key: value}) + + httpx2_cls.assert_called_once_with(**{key: value}) + + +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_unset_options_are_not_forwarded( + client_cls: type, + httpx2_name: str, +) -> None: + with patch.object(httpx2, httpx2_name) as httpx2_cls: + client_cls(base_url="", headers=None, timeout=None, proxy=None, transport=None) + + httpx2_cls.assert_called_once_with() + + +@pytest.mark.parametrize("key", _KEYS) +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_option_with_caller_owned_httpx2_client_is_typeerror( + client_cls: type, + httpx2_name: str, + key: str, +) -> None: + caller = getattr(httpx2, httpx2_name)(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TypeError, match=f"httpx2_client.*{key}"): + client_cls(httpx2_client=caller, **{key: _samples()[key]}) + + +@pytest.mark.parametrize("key", ["cert", "event_hooks", "verfy"]) +@pytest.mark.parametrize("client_cls", [AsyncClient, Client]) +def test_unsupported_option_is_typeerror(client_cls: type, key: str) -> None: + with pytest.raises(TypeError, match=key): + client_cls(**{key: object()}) + + +@pytest.mark.parametrize("client_cls", [AsyncClient, Client]) +def test_follow_redirects_with_body_cap_is_valueerror(client_cls: type) -> None: + with pytest.raises(ValueError, match="follow_redirects"): + client_cls(follow_redirects=True, max_response_body_bytes=1024) + + +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_caller_owned_client_following_redirects_with_body_cap_is_valueerror( + client_cls: type, + httpx2_name: str, +) -> None: + caller = getattr(httpx2, httpx2_name)(follow_redirects=True) + with pytest.raises(ValueError, match="follow_redirects"): + client_cls(httpx2_client=caller, max_response_body_bytes=1024) + + +@pytest.mark.parametrize("client_cls", [AsyncClient, Client]) +def test_body_cap_without_following_redirects_is_accepted(client_cls: type) -> None: + client_cls(follow_redirects=False, max_response_body_bytes=1024) + + +async def test_async_transport_option_keeps_the_httpx2_client_owned() -> None: + async with AsyncClient(transport=httpx2.MockTransport(_ok)) as client: + response = await client.get("https://example.test/") + assert response.status_code == HTTPStatus.OK + assert client._httpx2_client.is_closed # noqa: SLF001 + + +def test_sync_transport_option_keeps_the_httpx2_client_owned() -> None: + with Client(transport=httpx2.MockTransport(_ok)) as client: + response = client.get("https://example.test/") + assert response.status_code == HTTPStatus.OK + assert client._httpx2_client.is_closed # noqa: SLF001 diff --git a/tests/test_client_sync.py b/tests/test_client_sync.py index 3f79415..0c29076 100644 --- a/tests/test_client_sync.py +++ b/tests/test_client_sync.py @@ -39,26 +39,6 @@ def test_construction_with_caller_owned_httpx2_client() -> None: caller.close() -@pytest.mark.parametrize( - "kwargs", - [ - {"base_url": "https://example.test"}, - {"headers": {"x": "1"}}, - {"params": {"x": "1"}}, - {"cookies": {"x": "1"}}, - {"timeout": 5.0}, - {"limits": httpx2.Limits(max_connections=10)}, - {"auth": httpx2.BasicAuth("u", "p")}, - ], -) -def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - caller = httpx2.Client(transport=transport) - with pytest.raises(TypeError, match="httpx2_client"): - Client(httpx2_client=caller, **kwargs) - caller.close() - - def test_default_decoders_includes_pydantic_when_installed() -> None: client = Client() assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 @@ -105,21 +85,6 @@ class _Foo: client.close() -@pytest.mark.parametrize( - "kwargs", - [ - {"cookies": {"session": "abc"}}, - {"limits": httpx2.Limits(max_connections=5)}, - {"auth": httpx2.BasicAuth("user", "pass")}, - ], -) -def test_construction_with_optional_forwarded_kwargs(kwargs: dict) -> None: - """Exercises cookies/limits/auth branches in __init__ when no httpx2_client is supplied.""" - client = Client(**kwargs) - assert isinstance(client, Client) - client.close() - - def test_explicit_middleware_is_honored() -> None: class _Tag: def __call__(self, request, next) -> httpx2.Response: # noqa: A002, ANN001 # pragma: no cover — never invoked; the test only checks the stored middleware tuple diff --git a/tests/test_request_assembly.py b/tests/test_request_assembly.py index 12974af..6b859ed 100644 --- a/tests/test_request_assembly.py +++ b/tests/test_request_assembly.py @@ -2,82 +2,7 @@ import httpx2 -from httpware.client import _assemble_httpx2_client_kwargs, _assemble_request_kwargs - - -class TestAssembleHttpx2ClientKwargs: - """Tests for _assemble_httpx2_client_kwargs.""" - - def test_all_unset_returns_empty_dict(self) -> None: - """When all arguments are at their unset values, return empty dict.""" - result = _assemble_httpx2_client_kwargs( - base_url="", - headers=None, - params=None, - cookies=None, - timeout=None, - limits=None, - auth=None, - ) - assert result == {} - - def test_all_set_returns_full_dict(self) -> None: - """When all arguments are set to real values, return dict with all keys.""" - timeout_val = 10.0 - limits_val = httpx2.Limits(max_connections=100) - auth_val = httpx2.BasicAuth("user", "pass") - headers_val = {"X-Test": "value"} - params_val = {"key": "value"} - cookies_val = {"session": "abc123"} - base_url_val = "https://example.com" - - result = _assemble_httpx2_client_kwargs( - base_url=base_url_val, - headers=headers_val, - params=params_val, - cookies=cookies_val, - timeout=timeout_val, - limits=limits_val, - auth=auth_val, - ) - - assert result == { - "base_url": base_url_val, - "headers": headers_val, - "params": params_val, - "cookies": cookies_val, - "timeout": timeout_val, - "limits": limits_val, - "auth": auth_val, - } - - def test_base_url_empty_string_omitted(self) -> None: - """When base_url is an empty string, it is omitted (falsy string check).""" - result = _assemble_httpx2_client_kwargs( - base_url="", - headers=None, - params=None, - cookies=None, - timeout=None, - limits=None, - auth=None, - ) - assert "base_url" not in result - assert result == {} - - def test_base_url_non_empty_string_included(self) -> None: - """When base_url is a non-empty string, it is included.""" - result = _assemble_httpx2_client_kwargs( - base_url="https://example.com", - headers=None, - params=None, - cookies=None, - timeout=None, - limits=None, - auth=None, - ) - assert "base_url" in result - assert result["base_url"] == "https://example.com" +from httpware.client import _assemble_request_kwargs class TestAssembleRequestKwargs: From b47cb442d424309168624474d38ae48e897e2c70 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 1 Oct 2026 13:49:53 +0300 Subject: [PATCH 2/2] docs: drop the ADR for httpx2 client option forwarding --- ...-every-httpx2-client-option-is-forwarded.md | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 docs/adr/0011-every-httpx2-client-option-is-forwarded.md diff --git a/docs/adr/0011-every-httpx2-client-option-is-forwarded.md b/docs/adr/0011-every-httpx2-client-option-is-forwarded.md deleted file mode 100644 index 2b0bf51..0000000 --- a/docs/adr/0011-every-httpx2-client-option-is-forwarded.md +++ /dev/null @@ -1,18 +0,0 @@ -# Every httpx2 client option is forwarded - -`Client` and `AsyncClient` forward every keyword of the `httpx2` client constructor to the client -they build and own, typed through `**kwargs: Unpack[TypedDict]`. Forwarding a hand-picked subset -made one missing option, such as `verify`, cost the caller the whole construction: they had to -build the `httpx2` client themselves, move every other option onto it, and close it themselves. A -named parameter per option was rejected because each one had to be listed three times, and an -untyped `httpx2_client_kwargs=` dict because it gives up the checking that the TypedDict keeps. -Two options are refused: `cert`, deprecated by httpx2 in favour of an `ssl.SSLContext` passed as -`verify`, and `event_hooks`, which run below the middleware chain, so an `httpx2.HTTPError` raised -in a hook reaches retry and the circuit breaker as a `TransportError`. `follow_redirects=True` -cannot be combined with `max_response_body_bytes`, because httpx2 reads intermediate redirect -bodies without the cap ([ADR-0009](0009-the-body-cap-counts-decoded-bytes.md)). Unset options -(`None`, an empty `base_url`) are dropped rather than forwarded, because `timeout=None` means "the -httpx2 default" to httpware but "no timeout" to httpx2. `ty` does not reject an unknown key under -`Unpack`, so the constructor checks keys at runtime, and -`tests/test_client_options.py::test_options_cover_every_httpx2_client_kwarg_except_excluded` fails -when an httpx2 upgrade adds an option nobody has forwarded or refused.