Skip to content

Commit 7705fc8

Browse files
authored
refactor(client): extract shared request-assembly functions
Extracts 3 shared, non-I/O helper functions out of Client/AsyncClient's hand-duplicated __init__/_prepare_request/stream() bodies in client.py: _validate_httpx2_client_conflict, _assemble_httpx2_client_kwargs, _assemble_request_kwargs (the last reused across 4 call sites). Kept inside client.py (not _internal/) and as plain functions (not a class), per the _CircuitBreakerState/_RetryPolicy precedent. Per-verb methods, _request_with_body/_terminal, and I/O call sites stay out of scope — that split is structural and codegen was considered and rejected. Design: planning/changes/2026-07-13.02-client-request-assembly-extraction.md
1 parent bcce8a7 commit 7705fc8

3 files changed

Lines changed: 607 additions & 132 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
---
2+
summary: Extract the non-I/O construction-validation and kwargs-assembly logic hand-duplicated between Client and AsyncClient into shared module-level functions in client.py.
3+
---
4+
5+
# Design: Extract shared request-assembly functions from `Client`/`AsyncClient`
6+
7+
## Summary
8+
9+
`AsyncClient` (`client.py:60-1031`, ~972 lines) and `Client` (`client.py:1032-1999`,
10+
~968 lines) are structurally identical, byte-for-byte duplicated except for
11+
`async`/`await` and the sync-vs-async I/O calls. This change extracts the
12+
purely non-I/O logic — `__init__`'s `httpx2_client=...` conflict validation,
13+
`__init__`'s httpx2-client-constructor kwargs assembly, and the per-request
14+
kwargs assembly shared by `_prepare_request` and `stream()` — into three
15+
module-level private functions in `client.py`, called by both classes. The
16+
public per-verb surface (`get`/`post`/`put`/.../`request` and their
17+
`_with_response` siblings, each `async`/sync and separately overloaded) and
18+
the actual I/O call sites (`_terminal`, `_prepare_request`'s
19+
`build_request` call, `stream()`'s `self._httpx2_client.stream(...)` call)
20+
stay exactly as they are — mirroring httpx2's own per-method API is a
21+
deliberate design choice (see the `# noqa: PLR0913 — mirrors httpx2
22+
per-method signatures` comments throughout), and the `await`/non-`await`
23+
split at the I/O boundary is irreducible without source-to-source codegen,
24+
which this change deliberately does not introduce. No behavior change.
25+
26+
## Motivation
27+
28+
- The package already solved this exact duplication shape twice:
29+
`middleware/resilience/circuit_breaker.py`'s `_CircuitBreakerState` and
30+
`middleware/resilience/retry.py`'s `_RetryPolicy` are both stateless/
31+
lock-free decision logic shared by a sync and an async wrapper that
32+
otherwise differ only in `await`/locking. `client.py` never received the
33+
same treatment — its non-I/O logic is hand-copied instead of shared.
34+
- **Depth:** three blocks of pure, input-to-output logic are duplicated
35+
verbatim: the `httpx2_client=...` conflict check (~11 lines ×2), the
36+
httpx2-client-constructor kwargs dict-building (~14 lines ×2), and the
37+
per-request kwargs dict-building (~19 lines — duplicated not just across
38+
`AsyncClient`/`Client` but a second time within each class, between
39+
`_prepare_request` and `stream()`, for 4 call sites total). None of this
40+
logic touches `httpx2.Client` vs `httpx2.AsyncClient` differently.
41+
- **Deletion test:** delete the three shared functions after this change
42+
and the same four dict-building/validation blocks reappear at their
43+
six call sites — real, load-bearing logic, not a pass-through.
44+
- **Scope discipline:** the per-verb methods (`get`/`post`/etc.) are
45+
call-site boilerplate whose complexity is the sheer *count* of methods
46+
(8 verbs × 2 overloads + impl + `_with_response` sibling × 2 classes),
47+
not hidden logic — each body is already just "forward these named kwargs
48+
to `_request_with_body`". There is nothing further to extract there
49+
without codegen, and the type overloads are part of the public interface
50+
(caller ergonomics), not internal duplication. This change does not touch
51+
them.
52+
53+
## Non-goals
54+
55+
- No behavior change. Every existing test must pass with the same
56+
assertions; only the internal call path changes.
57+
- Not collapsing the per-verb methods, `_request_with_body`/
58+
`_request_with_body_with_response`, `_terminal`, or `stream()`'s I/O
59+
call — these stay duplicated (thin as they already are), because the
60+
`await`/non-`await` split there is structural, not incidental.
61+
- Not introducing codegen (unasync-style generation of `Client` from
62+
`AsyncClient`). Considered and explicitly rejected for this change: no
63+
precedent in this codebase for that kind of build/lint tooling, and the
64+
bulk of the actual duplication cost is concentrated in the non-I/O logic
65+
this change already collapses.
66+
- Not moving the new functions to `_internal/`. Precedent
67+
(`_CircuitBreakerState` in `circuit_breaker.py`, `_RetryPolicy` in
68+
`retry.py`) keeps logic private to one file's two classes in that same
69+
file; `_internal/` is reserved for logic shared *across* otherwise-
70+
unrelated modules (`status.py`, `exception_mapping.py`).
71+
- Not a class. `_CircuitBreakerState`/`_RetryPolicy` are classes because
72+
they hold config plus evolving/shared state reused across many calls.
73+
The three functions extracted here hold no state between calls — each is
74+
a pure function of its arguments — so a class would be a namespace with
75+
no benefit.
76+
77+
## Design
78+
79+
### 1. `_validate_httpx2_client_conflict`
80+
81+
```python
82+
def _validate_httpx2_client_conflict(
83+
*,
84+
base_url: str,
85+
headers: dict[str, str] | None,
86+
params: dict[str, str] | None,
87+
cookies: dict[str, str] | None,
88+
timeout: httpx2.Timeout | float | None,
89+
limits: httpx2.Limits | None,
90+
auth: httpx2.Auth | None,
91+
) -> None:
92+
"""Raise TypeError if httpx2_client=... is combined with a forwarded kwarg."""
93+
forwarded = {
94+
"base_url": base_url,
95+
"headers": headers,
96+
"params": params,
97+
"cookies": cookies,
98+
"timeout": timeout,
99+
"limits": limits,
100+
"auth": auth,
101+
}
102+
if any(value not in (None, "") for value in forwarded.values()):
103+
raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE)
104+
```
105+
106+
Replaces `client.py:86-97` (async) and the equivalent sync block verbatim.
107+
Called only from inside the `if httpx2_client is not None:` branch of both
108+
`__init__`s, immediately before `self._httpx2_client = httpx2_client`.
109+
110+
### 2. `_assemble_httpx2_client_kwargs`
111+
112+
```python
113+
def _assemble_httpx2_client_kwargs(
114+
*,
115+
base_url: str,
116+
headers: dict[str, str] | None,
117+
params: dict[str, str] | None,
118+
cookies: dict[str, str] | None,
119+
timeout: httpx2.Timeout | float | None,
120+
limits: httpx2.Limits | None,
121+
auth: httpx2.Auth | None,
122+
) -> dict[str, typing.Any]:
123+
"""Build the kwargs dict for constructing the owned httpx2 client."""
124+
kwargs: dict[str, typing.Any] = {}
125+
if base_url:
126+
kwargs["base_url"] = base_url
127+
if headers is not None:
128+
kwargs["headers"] = headers
129+
if params is not None:
130+
kwargs["params"] = params
131+
if cookies is not None:
132+
kwargs["cookies"] = cookies
133+
if timeout is not None:
134+
kwargs["timeout"] = timeout
135+
if limits is not None:
136+
kwargs["limits"] = limits
137+
if auth is not None:
138+
kwargs["auth"] = auth
139+
return kwargs
140+
```
141+
142+
Replaces `client.py:101-115` (async) and the equivalent sync block. Each
143+
`__init__`'s `else:` branch becomes:
144+
145+
```python
146+
kwargs = _assemble_httpx2_client_kwargs(
147+
base_url=base_url, headers=headers, params=params, cookies=cookies,
148+
timeout=timeout, limits=limits, auth=auth,
149+
)
150+
self._httpx2_client = httpx2.AsyncClient(**kwargs) # or httpx2.Client(**kwargs)
151+
self._owns_client = True
152+
```
153+
154+
Uses `timeout is not None` — client-construction semantics. Distinct from
155+
function 3's `timeout is not httpx2.USE_CLIENT_DEFAULT` check; these are
156+
different semantics for a same-named parameter and must stay two separate
157+
functions (confirmed during design — a merged function would have silently
158+
conflated them).
159+
160+
### 3. `_assemble_request_kwargs`
161+
162+
```python
163+
def _assemble_request_kwargs(
164+
*,
165+
params: typing.Any | None,
166+
headers: typing.Any | None,
167+
cookies: typing.Any | None,
168+
timeout: typing.Any,
169+
extensions: typing.Any | None,
170+
json: typing.Any | None,
171+
content: typing.Any | None,
172+
data: typing.Any | None,
173+
files: typing.Any | None,
174+
) -> dict[str, typing.Any]:
175+
"""Build the kwargs dict for a per-request httpx2 call (build_request/stream)."""
176+
kwargs: dict[str, typing.Any] = {}
177+
if params is not None:
178+
kwargs["params"] = params
179+
if headers is not None:
180+
kwargs["headers"] = headers
181+
if cookies is not None:
182+
kwargs["cookies"] = cookies
183+
if timeout is not httpx2.USE_CLIENT_DEFAULT:
184+
kwargs["timeout"] = timeout
185+
if extensions is not None:
186+
kwargs["extensions"] = extensions
187+
if json is not None:
188+
kwargs["json"] = json
189+
if content is not None:
190+
kwargs["content"] = content
191+
if data is not None:
192+
kwargs["data"] = data
193+
if files is not None:
194+
kwargs["files"] = files
195+
return kwargs
196+
```
197+
198+
Replaces `client.py:202-220` (async `_prepare_request`) and the
199+
byte-identical block at `client.py:976-994` (async `stream()`), plus both
200+
sync equivalents — **4 call sites total**, not 2. `_prepare_request` keeps
201+
its own `build_request(...)` call and the streaming-body-marker check
202+
(`_is_streaming_body_async`/`_sync` — this predicate genuinely differs
203+
between worlds and stays inline); `stream()` keeps its own
204+
`self._httpx2_client.stream(...)` call. Only the kwargs-dict construction
205+
moves.
206+
207+
### 4. Placement
208+
209+
All three functions go in `client.py`, module level, alongside the
210+
existing `_build_default_decoders` (before `class AsyncClient:`) —
211+
matching where `_CircuitBreakerState`/`_RetryPolicy` sit relative to their
212+
wrapper classes: private to the file, not moved to `_internal/`.
213+
214+
## Testing
215+
216+
- **Parity net:** all existing suites stay green unchanged —
217+
`test_client_construction.py`, `test_client_sync.py`,
218+
`test_client_dispatch.py`, `test_client_methods.py`,
219+
`test_client_stream.py`, `test_client_stream_sync.py`, and the rest.
220+
Byte-identical behavior is the bar.
221+
- **New seam tests:** `tests/test_request_assembly.py` drives
222+
`_assemble_httpx2_client_kwargs` and `_assemble_request_kwargs` directly
223+
(no client, no `MockTransport`) — which keys appear/are omitted for
224+
`None`/falsy/`USE_CLIENT_DEFAULT` inputs, and that a fully-populated call
225+
includes every key. `_validate_httpx2_client_conflict` gets no separate
226+
direct test: it's a single boolean check already directly asserted via
227+
`TypeError` through the public constructor in
228+
`test_client_construction.py`, and a duplicate direct test would add no
229+
leverage.
230+
- `just lint && just test` both clean; 100% coverage maintained.
231+
232+
## Risk
233+
234+
- **Behavioral drift during extraction** (unlikely × medium): a subtle
235+
reordering changes which kwarg wins or silently drops a falsy-but-valid
236+
value (e.g. `base_url=""` vs `base_url` unset — the existing code already
237+
treats empty string as "unset" via the truthy check, and this must be
238+
preserved exactly). *Mitigation:* extract verbatim under the existing
239+
green suites, which construct clients with every combination of
240+
set/unset kwargs; do not edit those suites in this change.
241+
- **Conflating the two `timeout` semantics** (unlikely × high, if it
242+
happened): function 2 must keep `is not None`, function 3 must keep
243+
`is not httpx2.USE_CLIENT_DEFAULT` — merging them into one function was
244+
considered and rejected specifically to avoid this. *Mitigation:* keep
245+
them as two functions per this design; `test_request_assembly.py`
246+
asserts each function's actual sentinel/None behavior directly.
247+
- **Per-verb layer accidentally touched** (unlikely × low): scope creep
248+
into the per-verb methods would reopen the codegen-vs-hand-duplication
249+
question this change deliberately defers. *Mitigation:* this design's
250+
Non-goals section is explicit; the task brief will name the exact
251+
functions and line ranges to touch and nothing else.

0 commit comments

Comments
 (0)