Skip to content

Commit 2b7d9b7

Browse files
lesnik512claude
andauthored
feat(client): per-verb *_with_response siblings (0.12.0) (#68)
* refactor(client): extract _prepare_request from _request_with_body Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(client): add async per-verb *_with_response siblings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(client): add sync per-verb *_with_response siblings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(client): document per-verb *_with_response siblings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(release): 0.12.0 — per-verb *_with_response siblings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(planning): add the per-verb-with-response change bundle Design + plan for the get_with_response … request_with_response siblings, and the Active Index entry. Bundle stays active/draft until merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(release): cite PR #68 in 0.12.0 shipped-via Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(planning): archive the per-verb-with-response bundle (#68) Ship bookkeeping: mark design.md + plan.md shipped (pr: 68, outcome filled), move the bundle from changes/active/ to changes/archive/, and flip its Index line from Active to Archived. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b7cc53b commit 2b7d9b7

11 files changed

Lines changed: 1496 additions & 14 deletions

File tree

architecture/client.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ The sync and async surfaces are kept at parity. Shared state is thread-safe wher
1212

1313
The async middleware surface uses the `Async*`/`async_*` prefix, aligning with httpx2's convention.
1414

15+
## `send_with_response` and per-verb siblings
16+
17+
`send_with_response(request, *, response_model)` returns `(httpx2.Response, T)` atomically — the decoded body and the raw response together. This is the building block for cases where response metadata (headers, status) is needed alongside the typed body, such as Link-header pagination.
18+
19+
The per-verb `*_with_response` siblings — `get_with_response`, `post_with_response`, `put_with_response`, `patch_with_response`, `delete_with_response`, and `request_with_response` — are the one-call ergonomic form: `response_model` is required, they return `tuple[httpx2.Response, T]`, and they accept the same keyword arguments as their non-`_with_response` counterparts; there is no `head_with_response` or `options_with_response` — use `request_with_response` for those methods.
20+
1521
## Streaming
1622

1723
Both `Client.stream()` (sync) and `AsyncClient.stream()` (async) provide a context-manager API for chunked response bodies. Both bypass the middleware chain by design.

docs/recipes/link-header-pagination.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,26 @@ async def main() -> None:
3030

3131
`process` and `next_link` are caller-defined. Pick a Link-header parser that fits your project — there are several on PyPI, and the format is small enough to hand-roll.
3232

33+
## Shorthand: per-verb `*_with_response`
34+
35+
When you do not need a pre-built `Request` object, the per-verb siblings collapse the `build_request` + `send_with_response` two-step into a single call:
36+
37+
```python
38+
# two-step (pre-built request, required when you need full Request control)
39+
request = client.build_request("GET", url, params=params)
40+
response, tags = await client.send_with_response(request, response_model=list[Tag])
41+
42+
# one-call shorthand (equivalent for the simple case)
43+
response, tags = await client.get_with_response(url, params=params, response_model=list[Tag])
44+
```
45+
46+
The full set of siblings is `get_with_response`, `post_with_response`, `put_with_response`, `patch_with_response`, `delete_with_response`, and `request_with_response`. There is no `head_with_response` or `options_with_response` — use `request_with_response` for those methods.
47+
3348
## When to use which API
3449

3550
- **Body only, high-level verb:** `client.get(..., response_model=...)`
3651
- **Body only, custom `Request`:** `client.send(request, response_model=...)`
37-
- **Body + response metadata:** `client.send_with_response(request, response_model=...)`
52+
- **Body + response metadata, simple URL:** `client.get_with_response(url, response_model=...)`
53+
- **Body + response metadata, pre-built `Request`:** `client.send_with_response(request, response_model=...)`
3854

39-
`send_with_response` is not for streaming responses — use [`stream()`](../index.md#streaming-responses) for those.
55+
`send_with_response` and the `*_with_response` siblings are not for streaming responses — use [`stream()`](../index.md#streaming-responses) for those.

planning/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ _None._
7474

7575
### Archived (shipped)
7676

77+
- **[per-verb-with-response](changes/archive/2026-06-16.01-per-verb-with-response/design.md)** (#68, 2026-06-16) — Added `get_with_response``request_with_response` siblings (required `response_model`, returns `(Response, T)`) to both clients. Shipped 0.12.0; closed the deferred "Per-verb-with-response siblings" item.
7778
- **[custom-decoder-guide](changes/archive/2026-06-15.01-custom-decoder-guide/change.md)** (#67, 2026-06-15) — Docs: a "write your own `ResponseDecoder`" guide for Seam B, mirroring `docs/middleware.md`. Closed deferred item G6.
7879
- **[audit-doc-fixes](changes/archive/2026-06-14.06-audit-doc-fixes/change.md)** (#66, 2026-06-14) — Closed the [deep-audit](audits/2026-06-14-deep-audit.md) doc-accuracy findings: `Client.stream()` docs, terminal-call attribution, the four auto-raise sites, the pydantic upper bound, and root import paths.
7980
- **[audit-test-quality](changes/archive/2026-06-14.05-audit-test-quality/change.md)** (#65, 2026-06-14) — Closed 11 [deep-audit](audits/2026-06-14-deep-audit.md) test-quality findings: sync-terminal + CookieConflict coverage, the `StatusError.__init__` invariant, missing status constructions, sync mirrors, typing overloads, a deterministic bulkhead barrier, a pinned budget clock, an observability assertion, and the `TimeoutError` circuit trigger.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
---
2+
status: shipped
3+
date: 2026-06-16
4+
slug: per-verb-with-response
5+
supersedes: null
6+
superseded_by: null
7+
pr: 68
8+
outcome: Shipped 0.12.0 — 6 per-verb *_with_response siblings (get/post/put/patch/delete/request) on both clients, returning (Response, T). Closed the deferred "Per-verb-with-response siblings" item.
9+
---
10+
11+
# Design: Per-verb `*_with_response` siblings
12+
13+
## Summary
14+
15+
Add `get_with_response`, `post_with_response`, `put_with_response`,
16+
`patch_with_response`, `delete_with_response`, and `request_with_response` to
17+
both `AsyncClient` and `Client`. Each takes a **required** `response_model` and
18+
returns `tuple[httpx2.Response, T]` — the per-verb ergonomic form of the
19+
existing `send_with_response`, for the "I need response metadata *and* a typed
20+
body" case without the `build_request(...)` + `send_with_response(...)`
21+
two-step. Additive, no breaking change; ships as 0.12.0.
22+
23+
## Motivation
24+
25+
`send_with_response` (shipped 0.8.2) covers the headers-plus-typed-body case —
26+
Link-header pagination, ETag / rate-limit-header reads alongside a decoded
27+
payload. But it forces a two-call shape:
28+
29+
```python
30+
request = client.build_request("GET", "/users", params={"page": 2})
31+
response, users = await client.send_with_response(request, response_model=list[User])
32+
```
33+
34+
The plain verbs (`get`, `post`, …) already collapse `build_request` + `send`
35+
into one call. There is no one-call form for the *with-response* path, so the
36+
common "GET a page, read its `Link` header, decode its body" flow is wordier
37+
than the body-only flow it sits beside. This was parked in
38+
[`deferred.md`](../../deferred.md) under "Per-verb-with-response siblings"
39+
pending concrete demand; the revisit trigger is now met.
40+
41+
The deferred note estimated "~400 LOC of overload boilerplate per side." That
42+
estimate assumed each sibling needs the same 3-overload block the plain verbs
43+
carry. It does not — see Design §1.
44+
45+
## Non-goals
46+
47+
- **No `head`/`options` siblings.** HEAD bodies are empty by definition;
48+
OPTIONS bodies are rarely decoded. `request_with_response("HEAD", ...)` is the
49+
escape hatch if ever needed.
50+
- **No streaming variant.** `*_with_response` decodes `response.content`, which
51+
requires a fully-read body — same constraint as `send_with_response`. Use
52+
`stream()` for streaming.
53+
- **No new top-level exports.** These are methods on existing classes; nothing
54+
joins `httpware.__all__`.
55+
- **No change to `send_with_response` itself.** The siblings delegate to it.
56+
57+
## Design
58+
59+
### 1. No overloads — one call shape per sibling
60+
61+
The plain verbs carry three signatures each (two `@typing.overload` stubs for
62+
`response_model: None → Response` and `response_model: type[T] → T`, plus the
63+
implementation) because they have two return shapes. `send_with_response` has
64+
**one** shape: `response_model` is required and the return is always
65+
`tuple[Response, T]`. It is a single concrete method, no overloads — and the
66+
verb siblings mirror that. Each sibling is one method: a body-kwargs signature
67+
plus a one-line delegation (~15-25 lines), not a 3-overload block.
68+
69+
### 2. Extract `_prepare_request`, add a parallel with-response helper
70+
71+
`AsyncClient._request_with_body` (and its sync twin) currently inlines two
72+
concerns: assembling the non-`None` kwargs dict + setting `STREAMING_BODY_MARKER`,
73+
then calling `self.send(...)`. Split the first concern out so both paths share
74+
it:
75+
76+
```python
77+
def _prepare_request(self, method, url, *, params=None, headers=None,
78+
cookies=None, timeout=USE_CLIENT_DEFAULT, extensions=None,
79+
json=None, content=None, data=None, files=None) -> httpx2.Request:
80+
# kwargs-dict assembly + streaming-body marker (moved verbatim from
81+
# _request_with_body); returns the built httpx2.Request.
82+
83+
async def _request_with_body(self, method, url, *, ..., response_model=None):
84+
request = self._prepare_request(method, url, ...)
85+
return await self.send(request, response_model=response_model)
86+
87+
async def _request_with_body_with_response(self, method, url, *, ..., response_model: type[T]):
88+
request = self._prepare_request(method, url, ...)
89+
return await self.send_with_response(request, response_model=response_model)
90+
```
91+
92+
The six verb siblings delegate to `_request_with_body_with_response` exactly as
93+
the plain verbs delegate to `_request_with_body`. `_prepare_request` takes the
94+
full body-kwarg superset; `get_with_response` simply does not pass the body
95+
kwargs (mirroring plain `get`).
96+
97+
### 3. Sibling signatures
98+
99+
Each mirrors its plain verb's kwargs, with `response_model` promoted to required
100+
keyword-only and the return type changed:
101+
102+
| Sibling | Kwargs (beyond `response_model`) | Returns |
103+
|---|---|---|
104+
| `get_with_response(url, *, ...)` | params, headers, cookies, timeout, extensions | `tuple[Response, T]` |
105+
| `post_with_response(url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` |
106+
| `put_with_response(url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` |
107+
| `patch_with_response(url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` |
108+
| `delete_with_response(url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` |
109+
| `request_with_response(method, url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` |
110+
111+
`response_model: type[T]` is required (no default). Reuse the existing
112+
`# noqa: PLR0913 — mirrors httpx2 per-method signatures` justification on the
113+
wide-signature methods. Docstrings follow the plain verbs ("Send a GET request;
114+
return (response, decoded).").
115+
116+
### 4. Inherited behavior (free via delegation)
117+
118+
Because every sibling bottoms out at `send_with_response`, it inherits without
119+
new code:
120+
121+
- `MissingDecoderError` raised **before** the HTTP call when no decoder claims
122+
the model.
123+
- `DecodeError` wrapping a decoder failure on a malformed body.
124+
- `STREAMING_BODY_MARKER` handling for streaming request bodies (set in
125+
`_prepare_request`).
126+
127+
No new error paths are introduced.
128+
129+
### 5. Sync parity
130+
131+
`Client` gains the identical six siblings + the same helper split, sync flavor.
132+
Sync and async surfaces stay at parity (an architecture invariant).
133+
134+
## Testing
135+
136+
- **Per-verb parity, async + sync** (12 verb × client combinations): each
137+
sibling returns a `(response, decoded)` tuple where `response` is the
138+
`httpx2.Response` (headers reachable, e.g. a seeded `Link` header) and the
139+
second element is the decoded model instance. Inject via `httpx2.MockTransport`
140+
per the testing convention.
141+
- **Pre-flight `MissingDecoderError`:** calling a sibling with a model no
142+
decoder claims raises before the transport is hit (assert the mock saw zero
143+
requests).
144+
- **`DecodeError` on bad payload:** a sibling against a malformed body raises
145+
`DecodeError` carrying `response`/`model`/`original`.
146+
- **Typed-usage check:** one test (or a `ty`-checked usage) confirming the
147+
declared return is `tuple[Response, Model]` — no overloads to exercise, just
148+
the concrete return.
149+
- `just test` green; `just lint` clean.
150+
151+
## Risk
152+
153+
- **Surface duplication drift (low × medium).** Six near-identical methods per
154+
side invite copy-paste errors (a wrong verb string, a dropped kwarg). The
155+
`_prepare_request` + `_request_with_body_with_response` extraction confines the
156+
logic to one place; the verb methods are pure delegation, and per-verb tests
157+
catch a wrong method string. The `_request_with_body` refactor is behavior-
158+
preserving and covered by the existing plain-verb suite.
159+
- **`_prepare_request` regression (low × high).** Moving the kwargs/marker logic
160+
could subtly change request construction. Mitigated by running the full
161+
existing suite before adding siblings — the plain verbs exercise the moved
162+
code unchanged.
163+
164+
## Out of scope
165+
166+
`head_with_response` / `options_with_response`; a streaming with-response
167+
variant; any change to `send`, `send_with_response`, or the decoder seam.

0 commit comments

Comments
 (0)