Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
15 changes: 15 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
204 changes: 95 additions & 109 deletions src/httpware/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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=<ssl.SSLContext> 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, "
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading