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