Skip to content

Commit 3a6f6dc

Browse files
lesnik512claude
andcommitted
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>
1 parent 241c34b commit 3a6f6dc

3 files changed

Lines changed: 721 additions & 1 deletion

File tree

planning/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ carry **no** frontmatter — living prose, dated by git.
7070

7171
### Active
7272

73-
_None._
73+
- **[per-verb-with-response](changes/active/2026-06-16.01-per-verb-with-response/design.md)** (2026-06-16) — Add `get_with_response``request_with_response` siblings (required `response_model`, returns `(Response, T)`) to both clients. Closes the deferred "Per-verb-with-response siblings" item. Targets 0.12.0.
7474

7575
### Archived (shipped)
7676

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
---
2+
status: draft
3+
date: 2026-06-16
4+
slug: per-verb-with-response
5+
supersedes: null
6+
superseded_by: null
7+
pr: null
8+
outcome: null
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)