Skip to content

Commit f884a26

Browse files
authored
Merge pull request #33 from modern-python/feat/send-with-response
feat: add send_with_response method for atomic (response, decoded) pair
2 parents df729fa + c2da4ea commit f884a26

7 files changed

Lines changed: 351 additions & 2 deletions

File tree

docs/index.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,44 @@ async def main() -> None:
8181
user = await client.get("/users/1", response_model=User)
8282
```
8383

84+
### Response metadata + typed body
85+
86+
When you need both the raw `httpx2.Response` (for headers, status, or the
87+
request URL) **and** a typed body, use `send_with_response`. It returns
88+
both atomically and routes the decode through the configured
89+
`ResponseDecoder`, so decoder failures surface as `DecodeError` — caught
90+
by `except httpware.ClientError` like every other failure mode.
91+
92+
Canonical use case: RFC 5988 Link header pagination.
93+
94+
Assume `process` and `next_link` are caller-defined — pick a Link header parser that fits.
95+
96+
```python
97+
from httpware import AsyncClient
98+
from pydantic import BaseModel
99+
100+
101+
class Tag(BaseModel):
102+
name: str
103+
104+
105+
async def main() -> None:
106+
async with AsyncClient(base_url="https://gitlab.example/api/v4") as client:
107+
url = "/projects/1/repository/tags"
108+
params: dict[str, str] | None = {"per_page": "100", "page": "1"}
109+
while url:
110+
request = client.build_request("GET", url, params=params)
111+
response, tags = await client.send_with_response(request, response_model=list[Tag])
112+
for tag in tags:
113+
process(tag)
114+
url = next_link(response.headers.get("link")) # caller's parser
115+
params = None # next link carries query
116+
```
117+
118+
For body-only with a high-level verb, prefer `client.get(..., response_model=...)`.
119+
For body-only with a custom `Request`, prefer `client.send(request, response_model=...)`.
120+
`send_with_response` is not for streaming responses — use `stream()`.
121+
84122
### Streaming responses
85123

86124
For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:

planning/deferred-work.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ As of 0.7.0, all planned epics (3, 4, 5, 6) are closed — see [`engineering.md`
99
### Decoder-side
1010

1111
- **`_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`)
12+
13+
### Client API surface
14+
15+
- **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`)

planning/engineering.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ The 0.1.0 seams numbered 1 (Middleware↔Transport) and 4 (Transport↔httpx2) h
3939
### Seam B: `Client`/`AsyncClient``ResponseDecoder`
4040

4141
- **Where:** `src/httpware/client.py``src/httpware/decoders/`.
42-
- **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.
42+
- **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.
4343
- **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)`.
4444

4545
### Seam C: `httpware ↔ optional extras`

src/httpware/client.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,28 @@ async def send(
159159
except Exception as exc:
160160
raise DecodeError(response=response, model=response_model, original=exc) from exc
161161

162+
async def send_with_response(
163+
self,
164+
request: httpx2.Request,
165+
*,
166+
response_model: type[T],
167+
) -> tuple[httpx2.Response, T]:
168+
"""Send `request` through the middleware chain; return (response, decoded).
169+
170+
Use this when you need response metadata (headers, status, request URL)
171+
AND a typed body — most commonly for Link-header pagination. For the
172+
body-only case, prefer ``send(request, response_model=...)``.
173+
174+
Not for streaming responses — decodes ``response.content``, which
175+
requires the body to be fully read. Use ``stream()`` for streaming.
176+
"""
177+
response = await self._dispatch(request)
178+
try:
179+
decoded = self._decoder.decode(response.content, response_model)
180+
except Exception as exc:
181+
raise DecodeError(response=response, model=response_model, original=exc) from exc
182+
return response, decoded
183+
162184
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
163185
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
164186
return self._httpx2_client.build_request(method, url, **kwargs)
@@ -879,6 +901,28 @@ def send(
879901
except Exception as exc:
880902
raise DecodeError(response=response, model=response_model, original=exc) from exc
881903

904+
def send_with_response(
905+
self,
906+
request: httpx2.Request,
907+
*,
908+
response_model: type[T],
909+
) -> tuple[httpx2.Response, T]:
910+
"""Send `request` through the middleware chain; return (response, decoded).
911+
912+
Use this when you need response metadata (headers, status, request URL)
913+
AND a typed body — most commonly for Link-header pagination. For the
914+
body-only case, prefer ``send(request, response_model=...)``.
915+
916+
Not for streaming responses — decodes ``response.content``, which
917+
requires the body to be fully read. Use ``stream()`` for streaming.
918+
"""
919+
response = self._dispatch(request)
920+
try:
921+
decoded = self._decoder.decode(response.content, response_model)
922+
except Exception as exc:
923+
raise DecodeError(response=response, model=response_model, original=exc) from exc
924+
return response, decoded
925+
882926
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
883927
"""Delegate request construction to the wrapped httpx2.Client."""
884928
return self._httpx2_client.build_request(method, url, **kwargs)

src/httpware/middleware/resilience/bulkhead.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ def _check_loop(self) -> None:
8787
with self._loop_lock:
8888
if self._loop is None:
8989
self._loop = current
90-
elif self._loop is not current:
90+
# pragma below: inner double-check-with-lock race arm; only
91+
# reachable when two threads simultaneously pass the outer
92+
# cached-loop check, which single-threaded tests can't trigger.
93+
elif self._loop is not current: # pragma: no cover
9194
raise RuntimeError(
9295
_ASYNCBULKHEAD_CROSS_LOOP_MSG.format(first=self._loop, current=current),
9396
)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Tests for AsyncClient.send_with_response — atomic (response, decoded) pair."""
2+
3+
from http import HTTPStatus
4+
5+
import httpx2
6+
import pydantic
7+
import pytest
8+
9+
from httpware import AsyncClient, ClientError, DecodeError, NotFoundError
10+
from httpware.middleware import async_before_request
11+
12+
13+
class _User(pydantic.BaseModel):
14+
id: int
15+
name: str
16+
17+
18+
def _client_with_payload(
19+
payload: bytes,
20+
*,
21+
status: int = HTTPStatus.OK,
22+
headers: dict[str, str] | None = None,
23+
) -> AsyncClient:
24+
response_headers = {"content-type": "application/json"}
25+
if headers is not None:
26+
response_headers.update(headers)
27+
28+
def handler(request: httpx2.Request) -> httpx2.Response:
29+
return httpx2.Response(status, content=payload, headers=response_headers, request=request)
30+
31+
transport = httpx2.MockTransport(handler)
32+
return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport))
33+
34+
35+
async def test_send_with_response_returns_response_and_decoded() -> None:
36+
client = _client_with_payload(b'{"id": 1, "name": "ada"}')
37+
request = client.build_request("GET", "https://example.test/u")
38+
response, user = await client.send_with_response(request, response_model=_User)
39+
assert isinstance(response, httpx2.Response)
40+
assert isinstance(user, _User)
41+
assert user == _User(id=1, name="ada")
42+
assert response.content == b'{"id": 1, "name": "ada"}'
43+
44+
45+
async def test_send_with_response_preserves_response_headers() -> None:
46+
"""Pagination callers read Link / X-Total-Count off the returned response."""
47+
client = _client_with_payload(
48+
b'{"id": 1, "name": "p"}',
49+
headers={"link": '<https://example.test/u?page=2>; rel="next"', "x-total-count": "100"},
50+
)
51+
request = client.build_request("GET", "https://example.test/u?page=1")
52+
response, _ = await client.send_with_response(request, response_model=_User)
53+
assert response.headers.get("link") == '<https://example.test/u?page=2>; rel="next"'
54+
assert response.headers.get("x-total-count") == "100"
55+
56+
57+
async def test_send_with_response_response_request_url_populated() -> None:
58+
"""Pagination loops do str(response.request.url) to compute the next page."""
59+
client = _client_with_payload(b'{"id": 1, "name": "p"}')
60+
request = client.build_request("GET", "https://example.test/u?page=1")
61+
response, _ = await client.send_with_response(request, response_model=_User)
62+
assert str(response.request.url) == "https://example.test/u?page=1"
63+
64+
65+
async def test_send_with_response_decode_failure_raises_decode_error() -> None:
66+
client = _client_with_payload(b"null")
67+
request = client.build_request("GET", "https://example.test/u")
68+
with pytest.raises(DecodeError) as exc_info:
69+
await client.send_with_response(request, response_model=_User)
70+
exc = exc_info.value
71+
assert exc.response.status_code == HTTPStatus.OK
72+
assert exc.model is _User
73+
assert isinstance(exc.original, pydantic.ValidationError)
74+
assert exc.__cause__ is exc.original
75+
76+
77+
async def test_send_with_response_malformed_json_raises_decode_error() -> None:
78+
client = _client_with_payload(b"{not json")
79+
request = client.build_request("GET", "https://example.test/u")
80+
with pytest.raises(DecodeError) as exc_info:
81+
await client.send_with_response(request, response_model=_User)
82+
exc = exc_info.value
83+
assert exc.response.status_code == HTTPStatus.OK
84+
assert exc.model is _User
85+
assert isinstance(exc.original, pydantic.ValidationError)
86+
87+
88+
async def test_send_with_response_decode_error_caught_by_client_error() -> None:
89+
"""The user-facing promise: `except ClientError` catches decode failures."""
90+
client = _client_with_payload(b"null")
91+
request = client.build_request("GET", "https://example.test/u")
92+
with pytest.raises(ClientError) as exc_info:
93+
await client.send_with_response(request, response_model=_User)
94+
assert isinstance(exc_info.value, DecodeError)
95+
96+
97+
async def test_send_with_response_status_error_raised_before_decoder_runs() -> None:
98+
"""4xx never produces a DecodeError — terminal raises StatusError first."""
99+
client = _client_with_payload(b'{"id": 1, "name": "x"}', status=HTTPStatus.NOT_FOUND)
100+
request = client.build_request("GET", "https://example.test/u")
101+
with pytest.raises(NotFoundError):
102+
await client.send_with_response(request, response_model=_User)
103+
104+
105+
async def test_send_with_response_runs_middleware_chain() -> None:
106+
"""User middleware mutates the request; mutation is visible on the wire."""
107+
recorded: list[httpx2.Request] = []
108+
109+
async def stamp(request: httpx2.Request) -> httpx2.Request:
110+
request.headers["x-test"] = "ok"
111+
return request
112+
113+
def handler(request: httpx2.Request) -> httpx2.Response:
114+
recorded.append(request)
115+
return httpx2.Response(
116+
HTTPStatus.OK,
117+
content=b'{"id": 1, "name": "z"}',
118+
headers={"content-type": "application/json"},
119+
request=request,
120+
)
121+
122+
transport = httpx2.MockTransport(handler)
123+
client = AsyncClient(
124+
httpx2_client=httpx2.AsyncClient(transport=transport),
125+
middleware=[async_before_request(stamp)],
126+
)
127+
request = client.build_request("GET", "https://example.test/u")
128+
response, _ = await client.send_with_response(request, response_model=_User)
129+
assert recorded[0].headers.get("x-test") == "ok"
130+
assert response.request.headers.get("x-test") == "ok"
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Tests for Client.send_with_response — atomic (response, decoded) pair (sync)."""
2+
3+
from http import HTTPStatus
4+
5+
import httpx2
6+
import pydantic
7+
import pytest
8+
9+
from httpware import Client, ClientError, DecodeError, NotFoundError
10+
from httpware.middleware import before_request
11+
12+
13+
class _User(pydantic.BaseModel):
14+
id: int
15+
name: str
16+
17+
18+
def _client_with_payload(
19+
payload: bytes,
20+
*,
21+
status: int = HTTPStatus.OK,
22+
headers: dict[str, str] | None = None,
23+
) -> Client:
24+
response_headers = {"content-type": "application/json"}
25+
if headers is not None:
26+
response_headers.update(headers)
27+
28+
def handler(request: httpx2.Request) -> httpx2.Response:
29+
return httpx2.Response(status, content=payload, headers=response_headers, request=request)
30+
31+
transport = httpx2.MockTransport(handler)
32+
return Client(httpx2_client=httpx2.Client(transport=transport))
33+
34+
35+
def test_send_with_response_returns_response_and_decoded() -> None:
36+
client = _client_with_payload(b'{"id": 1, "name": "ada"}')
37+
request = client.build_request("GET", "https://example.test/u")
38+
response, user = client.send_with_response(request, response_model=_User)
39+
assert isinstance(response, httpx2.Response)
40+
assert isinstance(user, _User)
41+
assert user == _User(id=1, name="ada")
42+
assert response.content == b'{"id": 1, "name": "ada"}'
43+
44+
45+
def test_send_with_response_preserves_response_headers() -> None:
46+
"""Pagination callers read Link / X-Total-Count off the returned response."""
47+
client = _client_with_payload(
48+
b'{"id": 1, "name": "p"}',
49+
headers={"link": '<https://example.test/u?page=2>; rel="next"', "x-total-count": "100"},
50+
)
51+
request = client.build_request("GET", "https://example.test/u?page=1")
52+
response, _ = client.send_with_response(request, response_model=_User)
53+
assert response.headers.get("link") == '<https://example.test/u?page=2>; rel="next"'
54+
assert response.headers.get("x-total-count") == "100"
55+
56+
57+
def test_send_with_response_response_request_url_populated() -> None:
58+
"""Pagination loops do str(response.request.url) to compute the next page."""
59+
client = _client_with_payload(b'{"id": 1, "name": "p"}')
60+
request = client.build_request("GET", "https://example.test/u?page=1")
61+
response, _ = client.send_with_response(request, response_model=_User)
62+
assert str(response.request.url) == "https://example.test/u?page=1"
63+
64+
65+
def test_send_with_response_decode_failure_raises_decode_error() -> None:
66+
client = _client_with_payload(b"null")
67+
request = client.build_request("GET", "https://example.test/u")
68+
with pytest.raises(DecodeError) as exc_info:
69+
client.send_with_response(request, response_model=_User)
70+
exc = exc_info.value
71+
assert exc.response.status_code == HTTPStatus.OK
72+
assert exc.model is _User
73+
assert isinstance(exc.original, pydantic.ValidationError)
74+
assert exc.__cause__ is exc.original
75+
76+
77+
def test_send_with_response_malformed_json_raises_decode_error() -> None:
78+
client = _client_with_payload(b"{not json")
79+
request = client.build_request("GET", "https://example.test/u")
80+
with pytest.raises(DecodeError) as exc_info:
81+
client.send_with_response(request, response_model=_User)
82+
exc = exc_info.value
83+
assert exc.response.status_code == HTTPStatus.OK
84+
assert exc.model is _User
85+
assert isinstance(exc.original, pydantic.ValidationError)
86+
87+
88+
def test_send_with_response_decode_error_caught_by_client_error() -> None:
89+
"""The user-facing promise: `except ClientError` catches decode failures."""
90+
client = _client_with_payload(b"null")
91+
request = client.build_request("GET", "https://example.test/u")
92+
with pytest.raises(ClientError) as exc_info:
93+
client.send_with_response(request, response_model=_User)
94+
assert isinstance(exc_info.value, DecodeError)
95+
96+
97+
def test_send_with_response_status_error_raised_before_decoder_runs() -> None:
98+
"""4xx never produces a DecodeError — terminal raises StatusError first."""
99+
client = _client_with_payload(b'{"id": 1, "name": "x"}', status=HTTPStatus.NOT_FOUND)
100+
request = client.build_request("GET", "https://example.test/u")
101+
with pytest.raises(NotFoundError):
102+
client.send_with_response(request, response_model=_User)
103+
104+
105+
def test_send_with_response_runs_middleware_chain() -> None:
106+
"""User middleware mutates the request; mutation is visible on the wire."""
107+
recorded: list[httpx2.Request] = []
108+
109+
def stamp(request: httpx2.Request) -> httpx2.Request:
110+
request.headers["x-test"] = "ok"
111+
return request
112+
113+
def handler(request: httpx2.Request) -> httpx2.Response:
114+
recorded.append(request)
115+
return httpx2.Response(
116+
HTTPStatus.OK,
117+
content=b'{"id": 1, "name": "z"}',
118+
headers={"content-type": "application/json"},
119+
request=request,
120+
)
121+
122+
transport = httpx2.MockTransport(handler)
123+
client = Client(
124+
httpx2_client=httpx2.Client(transport=transport),
125+
middleware=[before_request(stamp)],
126+
)
127+
request = client.build_request("GET", "https://example.test/u")
128+
response, _ = client.send_with_response(request, response_model=_User)
129+
assert recorded[0].headers.get("x-test") == "ok"
130+
assert response.request.headers.get("x-test") == "ok"

0 commit comments

Comments
 (0)