|
| 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