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
38 changes: 38 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions planning/deferred-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
2 changes: 1 addition & 1 deletion planning/engineering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
44 changes: 44 additions & 0 deletions src/httpware/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion src/httpware/middleware/resilience/bulkhead.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
130 changes: 130 additions & 0 deletions tests/test_client_send_with_response.py
Original file line number Diff line number Diff line change
@@ -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": '<https://example.test/u?page=2>; 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") == '<https://example.test/u?page=2>; 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"
130 changes: 130 additions & 0 deletions tests/test_client_send_with_response_sync.py
Original file line number Diff line number Diff line change
@@ -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": '<https://example.test/u?page=2>; 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") == '<https://example.test/u?page=2>; 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"
Loading