diff --git a/docs/index.md b/docs/index.md index 581fb79..3a5608a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -81,6 +81,44 @@ async def main() -> None: user = await client.get("/users/1", response_model=User) ``` +### Response metadata + typed body + +When you need both the raw `httpx2.Response` (for headers, status, or the +request URL) **and** a typed body, use `send_with_response`. It returns +both atomically and routes the decode through the configured +`ResponseDecoder`, so decoder failures surface as `DecodeError` — caught +by `except httpware.ClientError` like every other failure mode. + +Canonical use case: RFC 5988 Link header pagination. + +Assume `process` and `next_link` are caller-defined — pick a Link header parser that fits. + +```python +from httpware import AsyncClient +from pydantic import BaseModel + + +class Tag(BaseModel): + name: str + + +async def main() -> None: + async with AsyncClient(base_url="https://gitlab.example/api/v4") as client: + url = "/projects/1/repository/tags" + params: dict[str, str] | None = {"per_page": "100", "page": "1"} + while url: + request = client.build_request("GET", url, params=params) + response, tags = await client.send_with_response(request, response_model=list[Tag]) + for tag in tags: + process(tag) + url = next_link(response.headers.get("link")) # caller's parser + params = None # next link carries query +``` + +For body-only with a high-level verb, prefer `client.get(..., response_model=...)`. +For body-only with a custom `Request`, prefer `client.send(request, response_model=...)`. +`send_with_response` is not for streaming responses — use `stream()`. + ### Streaming responses For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager: diff --git a/planning/deferred-work.md b/planning/deferred-work.md index e50aff8..9ff9bd4 100644 --- a/planning/deferred-work.md +++ b/planning/deferred-work.md @@ -9,3 +9,7 @@ As of 0.7.0, all planned epics (3, 4, 5, 6) are closed — see [`engineering.md` ### Decoder-side - **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. No such configurability is on the roadmap, so this item is dormant unless a real use-case surfaces. (`src/httpware/decoders/pydantic.py:12-14`) + +### Client API surface + +- **Per-verb-with-response siblings** (`get_with_response`, `post_with_response`, `request_with_response`) — the v0.8.2 spec deliberately ships only `send_with_response`; the verb-method shape would add ~400 LOC of overload boilerplate per side for a pattern (response headers + typed body) that's almost always paired with a GET and `build_request`. Revisit if a concrete consumer demand surfaces. (`src/httpware/client.py`) diff --git a/planning/engineering.md b/planning/engineering.md index 34d47bb..88e376c 100644 --- a/planning/engineering.md +++ b/planning/engineering.md @@ -39,7 +39,7 @@ The 0.1.0 seams numbered 1 (Middleware↔Transport) and 4 (Transport↔httpx2) h ### Seam B: `Client`/`AsyncClient` ↔ `ResponseDecoder` - **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. -- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Any exception raised by `decode` is wrapped by `Client.send` / `AsyncClient.send` into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly. +- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Any exception raised by `decode` is wrapped by the call sites in `client.py` — `Client.send` / `AsyncClient.send` (when `response_model=` is set) and `Client.send_with_response` / `AsyncClient.send_with_response` — into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly. - **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` itself memoized via `@functools.lru_cache(maxsize=1024)` on a module-level `_get_adapter(model)` factory (the adapter is the expensive part to build). The msgspec adapter implements it as `msgspec.json.decode(content, type=model)`. ### Seam C: `httpware ↔ optional extras` diff --git a/src/httpware/client.py b/src/httpware/client.py index 11820cc..6ea259e 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -159,6 +159,28 @@ async def send( except Exception as exc: raise DecodeError(response=response, model=response_model, original=exc) from exc + async def send_with_response( + self, + request: httpx2.Request, + *, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send `request` through the middleware chain; return (response, decoded). + + Use this when you need response metadata (headers, status, request URL) + AND a typed body — most commonly for Link-header pagination. For the + body-only case, prefer ``send(request, response_model=...)``. + + Not for streaming responses — decodes ``response.content``, which + requires the body to be fully read. Use ``stream()`` for streaming. + """ + response = await self._dispatch(request) + try: + decoded = self._decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc + return response, decoded + def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: """Delegate request construction to the wrapped httpx2.AsyncClient.""" return self._httpx2_client.build_request(method, url, **kwargs) @@ -879,6 +901,28 @@ def send( except Exception as exc: raise DecodeError(response=response, model=response_model, original=exc) from exc + def send_with_response( + self, + request: httpx2.Request, + *, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send `request` through the middleware chain; return (response, decoded). + + Use this when you need response metadata (headers, status, request URL) + AND a typed body — most commonly for Link-header pagination. For the + body-only case, prefer ``send(request, response_model=...)``. + + Not for streaming responses — decodes ``response.content``, which + requires the body to be fully read. Use ``stream()`` for streaming. + """ + response = self._dispatch(request) + try: + decoded = self._decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc + return response, decoded + def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: """Delegate request construction to the wrapped httpx2.Client.""" return self._httpx2_client.build_request(method, url, **kwargs) diff --git a/src/httpware/middleware/resilience/bulkhead.py b/src/httpware/middleware/resilience/bulkhead.py index 5df7331..0060951 100644 --- a/src/httpware/middleware/resilience/bulkhead.py +++ b/src/httpware/middleware/resilience/bulkhead.py @@ -87,7 +87,10 @@ def _check_loop(self) -> None: with self._loop_lock: if self._loop is None: self._loop = current - elif self._loop is not current: + # pragma below: inner double-check-with-lock race arm; only + # reachable when two threads simultaneously pass the outer + # cached-loop check, which single-threaded tests can't trigger. + elif self._loop is not current: # pragma: no cover raise RuntimeError( _ASYNCBULKHEAD_CROSS_LOOP_MSG.format(first=self._loop, current=current), ) diff --git a/tests/test_client_send_with_response.py b/tests/test_client_send_with_response.py new file mode 100644 index 0000000..29c0a0e --- /dev/null +++ b/tests/test_client_send_with_response.py @@ -0,0 +1,130 @@ +"""Tests for AsyncClient.send_with_response — atomic (response, decoded) pair.""" + +from http import HTTPStatus + +import httpx2 +import pydantic +import pytest + +from httpware import AsyncClient, ClientError, DecodeError, NotFoundError +from httpware.middleware import async_before_request + + +class _User(pydantic.BaseModel): + id: int + name: str + + +def _client_with_payload( + payload: bytes, + *, + status: int = HTTPStatus.OK, + headers: dict[str, str] | None = None, +) -> AsyncClient: + response_headers = {"content-type": "application/json"} + if headers is not None: + response_headers.update(headers) + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(status, content=payload, headers=response_headers, request=request) + + transport = httpx2.MockTransport(handler) + return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + + +async def test_send_with_response_returns_response_and_decoded() -> None: + client = _client_with_payload(b'{"id": 1, "name": "ada"}') + request = client.build_request("GET", "https://example.test/u") + response, user = await client.send_with_response(request, response_model=_User) + assert isinstance(response, httpx2.Response) + assert isinstance(user, _User) + assert user == _User(id=1, name="ada") + assert response.content == b'{"id": 1, "name": "ada"}' + + +async def test_send_with_response_preserves_response_headers() -> None: + """Pagination callers read Link / X-Total-Count off the returned response.""" + client = _client_with_payload( + b'{"id": 1, "name": "p"}', + headers={"link": '; rel="next"', "x-total-count": "100"}, + ) + request = client.build_request("GET", "https://example.test/u?page=1") + response, _ = await client.send_with_response(request, response_model=_User) + assert response.headers.get("link") == '; rel="next"' + assert response.headers.get("x-total-count") == "100" + + +async def test_send_with_response_response_request_url_populated() -> None: + """Pagination loops do str(response.request.url) to compute the next page.""" + client = _client_with_payload(b'{"id": 1, "name": "p"}') + request = client.build_request("GET", "https://example.test/u?page=1") + response, _ = await client.send_with_response(request, response_model=_User) + assert str(response.request.url) == "https://example.test/u?page=1" + + +async def test_send_with_response_decode_failure_raises_decode_error() -> None: + client = _client_with_payload(b"null") + request = client.build_request("GET", "https://example.test/u") + with pytest.raises(DecodeError) as exc_info: + await client.send_with_response(request, response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + assert exc.__cause__ is exc.original + + +async def test_send_with_response_malformed_json_raises_decode_error() -> None: + client = _client_with_payload(b"{not json") + request = client.build_request("GET", "https://example.test/u") + with pytest.raises(DecodeError) as exc_info: + await client.send_with_response(request, response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + + +async def test_send_with_response_decode_error_caught_by_client_error() -> None: + """The user-facing promise: `except ClientError` catches decode failures.""" + client = _client_with_payload(b"null") + request = client.build_request("GET", "https://example.test/u") + with pytest.raises(ClientError) as exc_info: + await client.send_with_response(request, response_model=_User) + assert isinstance(exc_info.value, DecodeError) + + +async def test_send_with_response_status_error_raised_before_decoder_runs() -> None: + """4xx never produces a DecodeError — terminal raises StatusError first.""" + client = _client_with_payload(b'{"id": 1, "name": "x"}', status=HTTPStatus.NOT_FOUND) + request = client.build_request("GET", "https://example.test/u") + with pytest.raises(NotFoundError): + await client.send_with_response(request, response_model=_User) + + +async def test_send_with_response_runs_middleware_chain() -> None: + """User middleware mutates the request; mutation is visible on the wire.""" + recorded: list[httpx2.Request] = [] + + async def stamp(request: httpx2.Request) -> httpx2.Request: + request.headers["x-test"] = "ok" + return request + + def handler(request: httpx2.Request) -> httpx2.Response: + recorded.append(request) + return httpx2.Response( + HTTPStatus.OK, + content=b'{"id": 1, "name": "z"}', + headers={"content-type": "application/json"}, + request=request, + ) + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=[async_before_request(stamp)], + ) + request = client.build_request("GET", "https://example.test/u") + response, _ = await client.send_with_response(request, response_model=_User) + assert recorded[0].headers.get("x-test") == "ok" + assert response.request.headers.get("x-test") == "ok" diff --git a/tests/test_client_send_with_response_sync.py b/tests/test_client_send_with_response_sync.py new file mode 100644 index 0000000..d1c3b02 --- /dev/null +++ b/tests/test_client_send_with_response_sync.py @@ -0,0 +1,130 @@ +"""Tests for Client.send_with_response — atomic (response, decoded) pair (sync).""" + +from http import HTTPStatus + +import httpx2 +import pydantic +import pytest + +from httpware import Client, ClientError, DecodeError, NotFoundError +from httpware.middleware import before_request + + +class _User(pydantic.BaseModel): + id: int + name: str + + +def _client_with_payload( + payload: bytes, + *, + status: int = HTTPStatus.OK, + headers: dict[str, str] | None = None, +) -> Client: + response_headers = {"content-type": "application/json"} + if headers is not None: + response_headers.update(headers) + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(status, content=payload, headers=response_headers, request=request) + + transport = httpx2.MockTransport(handler) + return Client(httpx2_client=httpx2.Client(transport=transport)) + + +def test_send_with_response_returns_response_and_decoded() -> None: + client = _client_with_payload(b'{"id": 1, "name": "ada"}') + request = client.build_request("GET", "https://example.test/u") + response, user = client.send_with_response(request, response_model=_User) + assert isinstance(response, httpx2.Response) + assert isinstance(user, _User) + assert user == _User(id=1, name="ada") + assert response.content == b'{"id": 1, "name": "ada"}' + + +def test_send_with_response_preserves_response_headers() -> None: + """Pagination callers read Link / X-Total-Count off the returned response.""" + client = _client_with_payload( + b'{"id": 1, "name": "p"}', + headers={"link": '; rel="next"', "x-total-count": "100"}, + ) + request = client.build_request("GET", "https://example.test/u?page=1") + response, _ = client.send_with_response(request, response_model=_User) + assert response.headers.get("link") == '; rel="next"' + assert response.headers.get("x-total-count") == "100" + + +def test_send_with_response_response_request_url_populated() -> None: + """Pagination loops do str(response.request.url) to compute the next page.""" + client = _client_with_payload(b'{"id": 1, "name": "p"}') + request = client.build_request("GET", "https://example.test/u?page=1") + response, _ = client.send_with_response(request, response_model=_User) + assert str(response.request.url) == "https://example.test/u?page=1" + + +def test_send_with_response_decode_failure_raises_decode_error() -> None: + client = _client_with_payload(b"null") + request = client.build_request("GET", "https://example.test/u") + with pytest.raises(DecodeError) as exc_info: + client.send_with_response(request, response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + assert exc.__cause__ is exc.original + + +def test_send_with_response_malformed_json_raises_decode_error() -> None: + client = _client_with_payload(b"{not json") + request = client.build_request("GET", "https://example.test/u") + with pytest.raises(DecodeError) as exc_info: + client.send_with_response(request, response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + + +def test_send_with_response_decode_error_caught_by_client_error() -> None: + """The user-facing promise: `except ClientError` catches decode failures.""" + client = _client_with_payload(b"null") + request = client.build_request("GET", "https://example.test/u") + with pytest.raises(ClientError) as exc_info: + client.send_with_response(request, response_model=_User) + assert isinstance(exc_info.value, DecodeError) + + +def test_send_with_response_status_error_raised_before_decoder_runs() -> None: + """4xx never produces a DecodeError — terminal raises StatusError first.""" + client = _client_with_payload(b'{"id": 1, "name": "x"}', status=HTTPStatus.NOT_FOUND) + request = client.build_request("GET", "https://example.test/u") + with pytest.raises(NotFoundError): + client.send_with_response(request, response_model=_User) + + +def test_send_with_response_runs_middleware_chain() -> None: + """User middleware mutates the request; mutation is visible on the wire.""" + recorded: list[httpx2.Request] = [] + + def stamp(request: httpx2.Request) -> httpx2.Request: + request.headers["x-test"] = "ok" + return request + + def handler(request: httpx2.Request) -> httpx2.Response: + recorded.append(request) + return httpx2.Response( + HTTPStatus.OK, + content=b'{"id": 1, "name": "z"}', + headers={"content-type": "application/json"}, + request=request, + ) + + transport = httpx2.MockTransport(handler) + client = Client( + httpx2_client=httpx2.Client(transport=transport), + middleware=[before_request(stamp)], + ) + request = client.build_request("GET", "https://example.test/u") + response, _ = client.send_with_response(request, response_model=_User) + assert recorded[0].headers.get("x-test") == "ok" + assert response.request.headers.get("x-test") == "ok"