Skip to content

Commit b084e6c

Browse files
lesnik512claude
andauthored
Extract _DecoderResolver for Seam B (#77)
* docs(planning): add decoder-resolver-extraction change bundle Full-lane design.md + plan.md for extracting a _DecoderResolver (+ generic _BoundDecoder) behind Seam B, collapsing the 4-site resolve/raise/decode/ wrap smear in client.py. Design only — no source changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(decoders): extract _DecoderResolver for Seam B Move the 4-site resolve/raise/decode/wrap smear in client.py behind one _DecoderResolver (+ generic _BoundDecoder) in decoders/_resolver.py. resolve() returns a _BoundDecoder sealing decoder+model together; the four send/ send_with_response sites collapse to resolve -> dispatch -> bound.decode. Both clients keep self._decoders and gain self._decoder_resolver; both _dispatch_decoder methods removed. Behaviour byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(decoders): cover _DecoderResolver at the seam Direct decision-matrix tests for the resolver — no client, no MockTransport: resolve->bound->decode, no-claimer MissingDecoderError with registered_names + ordering, empty-tuple names, first-match-wins, and decode failure wrapped as DecodeError with response/model/original/__cause__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(decoders): promote _DecoderResolver into architecture truth Update Seam B contract in architecture/decoders.md: resolution + pre-flight MissingDecoderError live in _DecoderResolver.resolve; DecodeError wrapping in _BoundDecoder.decode. Replaces the _dispatch_decoder / send-wraps framing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(planning): mark decoder-resolver-extraction shipped (#77) 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 4744f11 commit b084e6c

6 files changed

Lines changed: 461 additions & 65 deletions

File tree

‎architecture/decoders.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ A protocol seam is a documented internal boundary. AI agents and contributors mu
77
Both clients take `decoders: Sequence[ResponseDecoder] | None = None` (a *list*, not a single instance) and dispatch via each decoder's `can_decode(model)` predicate. `AsyncClient()` / `Client()` do not raise on missing extras.
88

99
- **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`.
10-
- **Contract:** the client holds `_decoders: tuple[ResponseDecoder, ...]` composed at `__init__` and frozen for the client's lifetime. The Protocol exposes two methods:
11-
- `can_decode(model: type) -> bool` — predicate used at send-time to walk `_decoders` and pick the first claiming decoder (`_dispatch_decoder` on both classes). Built-in decoders claim broadly (pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.inspect.type_info(model)` + `CustomType` filter); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected. `can_decode` MUST NOT raise — it runs in `_dispatch_decoder`, outside the `DecodeError` try/except, so a raising probe escapes the `ClientError` contract; a decoder that cannot decide must return False, not raise (the built-ins treat any probe failure as False). This is a documented obligation on implementers, not an enforced guard.
12-
- `decode(content: bytes, model: type[T]) -> T` — the decode itself. Any exception is wrapped by `Client.send` / `AsyncClient.send` (when `response_model=` is set) and `Client.send_with_response` / `AsyncClient.send_with_response` into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly.
13-
- **Pre-flight check:** when `response_model=` is set and no decoder claims it, `send` / `send_with_response` raise `MissingDecoderError(model=..., registered_names=...)` BEFORE the HTTP call. `MissingDecoderError` is a sibling of `DecodeError` under `ClientError`, and is distinct from it: `DecodeError` means the decoder ran and the payload was malformed; the two have distinct corrective actions (install an extra or pass `decoders=[...]`).
10+
- **Contract:** the client holds `_decoders: tuple[ResponseDecoder, ...]` composed at `__init__` and frozen for the client's lifetime, plus a `_decoder_resolver: _DecoderResolver` (`decoders/_resolver.py`) wrapping that tuple — the synchronous Seam B orchestrator both clients share. The Protocol exposes two methods:
11+
- `can_decode(model: type) -> bool` — predicate used at send-time to walk `_decoders` and pick the first claiming decoder (`_DecoderResolver.resolve`). Built-in decoders claim broadly (pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.inspect.type_info(model)` + `CustomType` filter); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected. `can_decode` MUST NOT raise — it runs in `_DecoderResolver.resolve`, outside the `_BoundDecoder.decode` wrap, so a raising probe escapes the `ClientError` contract; a decoder that cannot decide must return False, not raise (the built-ins treat any probe failure as False). This is a documented obligation on implementers, not an enforced guard.
12+
- `decode(content: bytes, model: type[T]) -> T` — the decode itself. Any exception is wrapped by `_BoundDecoder.decode` — the bound decoder `resolve` returns, sealing decoder + model together — into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`); this runs for `send` / `send_with_response` on both clients when `response_model=` is set. Decoder implementers do not need to raise `DecodeError` directly.
13+
- **Pre-flight check:** when `response_model=` is set and no decoder claims it, `_DecoderResolver.resolve` (called by `send` / `send_with_response` before `_dispatch`) raises `MissingDecoderError(model=..., registered_names=...)` BEFORE the HTTP call. `MissingDecoderError` is a sibling of `DecodeError` under `ClientError`, and is distinct from it: `DecodeError` means the decoder ran and the payload was malformed; the two have distinct corrective actions (install an extra or pass `decoders=[...]`).
1414
- **Default list:** `decoders=None` resolves via `client.py:_build_default_decoders()` against installed extras — pydantic-first when both are present, either-only when only one is installed, empty tuple when neither. `AsyncClient()` / `Client()` never raise on missing extras; failure surfaces only at the first `response_model=` use site.
1515
- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` cached per-instance on `PydanticDecoder._adapters: dict[type, TypeAdapter]` (populated lazily on first `_get_adapter()` call); the msgspec adapter mirrors the pattern with `MsgspecDecoder._msgspec_decoders: dict[type, msgspec.json.Decoder]`. Cache lifetime matches the decoder/client, not the process — no module-level state, no autouse cache-clear fixtures in tests.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
---
2+
status: shipped
3+
date: 2026-06-23
4+
slug: decoder-resolver-extraction
5+
summary: Extract a _DecoderResolver (+ generic _BoundDecoder) for Seam B, collapsing the 4-site resolve/raise/decode/wrap smear in client.py.
6+
supersedes: null
7+
superseded_by: null
8+
pr: 77
9+
outcome: Shipped via #77 — resolution + pre-flight MissingDecoderError + DecodeError wrapping moved into _DecoderResolver/_BoundDecoder (decoders/_resolver.py); the 4 send/send_with_response sites collapse to resolve→dispatch→bound.decode, both _dispatch_decoder methods removed, decoder+model sealed together. Behaviour byte-identical (723 tests, 100% coverage). New seam suite tests/test_decoder_resolver.py; promoted into architecture/decoders.md. Internal refactor — no release.
10+
---
11+
12+
# Design: Extract a `_DecoderResolver` for Seam B
13+
14+
## Summary
15+
16+
The decoder seam (Seam B) is real — two adapters, pydantic and msgspec — but the
17+
logic for *using* it is copy-pasted across four `send` / `send_with_response`
18+
methods on the two clients. This change pulls that logic behind one
19+
`_DecoderResolver` (plus a small generic `_BoundDecoder`) in a new
20+
`decoders/_resolver.py`, so the resolution + pre-flight error + decode-wrapping
21+
live once and the four call sites shrink to `resolve → dispatch → decode`.
22+
Behaviour is unchanged.
23+
24+
## Motivation
25+
26+
- The same ~8-line block appears at `client.py:185`, `:213`, `:1200`, `:1228`:
27+
walk `_decoders`, raise `MissingDecoderError(registered_names=…)` if none
28+
claims the model (pre-flight, before the HTTP call), then `decoder.decode(...)`
29+
wrapping any failure as `DecodeError`. Plus `_dispatch_decoder` defined twice.
30+
- **Depth:** the `ResponseDecoder` protocol is a clean interface, but its *use*
31+
is shallow — smeared across four call sites instead of behind one module.
32+
Extracting concentrates it: one place to change the error wording or
33+
resolution, and a seam testable directly with a fake decoder list (no HTTP).
34+
- **Deletion test:** delete the resolver and the 8-line block reappears in all
35+
four methods — it concentrates real complexity, so it earns its keep.
36+
(Contrast `_dispatch_decoder` *today*: a 3-line loop, shallow on its own; the
37+
win is bundling resolution **with** the pre-flight error and decode-wrapping.)
38+
- Fully synchronous (`can_decode`/`decode` are sync), so **one** resolver shape
39+
serves both `Client` and `AsyncClient` — no sync/async twins.
40+
41+
## Non-goals
42+
43+
- No behaviour change. Resolution order, the pre-flight `MissingDecoderError`
44+
(with its `registered_names` snapshot), and `DecodeError` wrapping stay
45+
byte-identical.
46+
- Not changing the `ResponseDecoder` protocol, the pydantic/msgspec adapters, or
47+
`_build_default_decoders()` (stays in `client.py`, still tested there).
48+
- Not touching the `decoders=None → defaults` resolution — the resolver takes
49+
the already-built tuple.
50+
51+
## Design
52+
53+
### 1. `decoders/_resolver.py` — `_DecoderResolver` + `_BoundDecoder`
54+
55+
```python
56+
class _BoundDecoder(Generic[T]):
57+
"""A decoder bound to the model it will decode into."""
58+
59+
def __init__(self, decoder: ResponseDecoder, model: type[T]) -> None:
60+
self._decoder = decoder
61+
self._model = model
62+
63+
def decode(self, response: httpx2.Response) -> T:
64+
try:
65+
return self._decoder.decode(response.content, self._model)
66+
except Exception as exc:
67+
raise DecodeError(response=response, model=self._model, original=exc) from exc
68+
69+
70+
class _DecoderResolver:
71+
"""Resolves a response_model to the first claiming decoder; the Seam B orchestrator."""
72+
73+
def __init__(self, decoders: tuple[ResponseDecoder, ...]) -> None:
74+
self._decoders = decoders
75+
76+
def resolve(self, model: type[T]) -> _BoundDecoder[T]:
77+
for decoder in self._decoders:
78+
if decoder.can_decode(model):
79+
return _BoundDecoder(decoder, model)
80+
raise MissingDecoderError(
81+
model=model,
82+
registered_names=tuple(type(d).__name__ for d in self._decoders),
83+
)
84+
```
85+
86+
`_BoundDecoder` seals the decoder and the model together at resolve time, so a
87+
decoder/model mismatch is unrepresentable downstream; the caller's remaining
88+
knowledge shrinks to "decode this response". Lives in a new private module
89+
inside the decoders subpackage — co-located with the protocol + adapters it
90+
orchestrates, no import cycle (`decoders/` never imports `client`).
91+
92+
### 2. The four call sites collapse
93+
94+
```python
95+
bound = self._decoder_resolver.resolve(response_model) # pre-flight; may raise MissingDecoderError
96+
response = self._dispatch(request)
97+
return bound.decode(response) # post-HTTP; wraps DecodeError
98+
```
99+
100+
`send_with_response` is the same but returns `(response, bound.decode(response))`.
101+
The pre-flight invariant — `MissingDecoderError` before the HTTP call — stays
102+
enforced by call ordering (`resolve` before `_dispatch`), exactly as
103+
`_dispatch_decoder` is called before `_dispatch` today.
104+
105+
### 3. What the clients hold
106+
107+
- Keep `self._decoders` (four test files assert `client._decoders == …` via
108+
`# noqa: SLF001` — it stays a public-by-convention attribute).
109+
- Add `self._decoder_resolver = _DecoderResolver(self._decoders)` in both
110+
`__init__`s.
111+
- Remove both `_dispatch_decoder` methods (used nowhere but the four sites).
112+
113+
## Out of scope
114+
115+
- Folding `_build_default_decoders()` into the resolver.
116+
- Any change to streaming (`stream()` never decodes — no `response_model`).
117+
118+
## Testing
119+
120+
- **Parity net:** existing decoder/client suites stay green unchanged —
121+
`test_decoders_pydantic.py`, `test_decoders_msgspec.py`,
122+
`test_client_decoders_default.py`, `test_client_construction.py`,
123+
`test_client_sync.py`, `test_optional_extras_pydantic_missing.py`.
124+
- **New seam tests:** `tests/test_decoder_resolver.py` drives `resolve` /
125+
`_BoundDecoder.decode` directly with a fake decoder list (no client, no
126+
`MockTransport`): claiming model → bound that decodes; no claimer →
127+
`MissingDecoderError` with the right `registered_names` and order; decode
128+
success; decode raises → `DecodeError` carrying `response` / `model` /
129+
`original`; first-match-wins ordering across two fakes.
130+
- `just lint` and `just test` both clean (100% coverage gate).
131+
132+
## Risk
133+
134+
- **Behavioural drift** (low × medium): a reordering changes resolution
135+
precedence or the `registered_names` content. *Mitigation:* extract under the
136+
green decoder/client suites, which assert defaults, ordering, and error
137+
identity; do not edit them in this change.
138+
- **Pre-flight ordering regression** (low × medium): `MissingDecoderError` must
139+
still fire before the HTTP call. *Mitigation:* the call sites keep `resolve`
140+
strictly before `_dispatch`; existing tests assert no request is sent on a
141+
missing decoder.
142+
- **Typing** (low × low): `_BoundDecoder` must be `Generic[T]` so `T` flows to
143+
`send`'s overloads. *Mitigation:* `ty check` in the gate; `send`'s overload
144+
tests in `test_client_typing.py` cover the surface.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
---
2+
status: shipped
3+
date: 2026-06-23
4+
slug: decoder-resolver-extraction
5+
spec: decoder-resolver-extraction
6+
pr: 77
7+
---
8+
9+
# decoder-resolver-extraction — implementation plan
10+
11+
> **For agentic workers:** REQUIRED SUB-SKILL: Use
12+
> superpowers:subagent-driven-development (recommended) or
13+
> superpowers:executing-plans to implement this plan task-by-task. Steps
14+
> use checkbox (`- [ ]`) syntax for tracking.
15+
16+
**Goal:** Move the 4-site decoder resolve/raise/decode/wrap smear behind one
17+
`_DecoderResolver` (+ generic `_BoundDecoder`), with no behaviour change.
18+
19+
**Spec:** [`design.md`](./design.md)
20+
21+
**Branch:** `refactor/decoder-resolver-extraction`
22+
23+
**Commit strategy:** Per-task commits.
24+
25+
---
26+
27+
### Task 1: Add `_DecoderResolver` and rewire both clients
28+
29+
**Files:**
30+
- Create: `src/httpware/decoders/_resolver.py`
31+
- Modify: `src/httpware/client.py`
32+
33+
Existing decoder/client suites are the parity net — do not edit them in this task.
34+
35+
- [ ] **Step 1: Create `decoders/_resolver.py`**
36+
37+
Add `_BoundDecoder(Generic[T])` (holds decoder + model; `decode(response) -> T`
38+
wrapping `except Exception` as `DecodeError`) and `_DecoderResolver`
39+
(`__init__(decoders: tuple[ResponseDecoder, ...])`, `resolve(model: type[T]) ->
40+
_BoundDecoder[T]` walking `_decoders`, raising `MissingDecoderError(model=…,
41+
registered_names=tuple(type(d).__name__ for d in self._decoders))` when none
42+
claims). Import `T` from `httpware.decoders` (the protocol's TypeVar) or define
43+
locally; import `DecodeError`/`MissingDecoderError` from `httpware.errors`,
44+
`ResponseDecoder` from `httpware.decoders`.
45+
46+
- [ ] **Step 2: Wire the resolver into both clients**
47+
48+
In `AsyncClient.__init__` and `Client.__init__`, after `self._decoders = …`,
49+
add `self._decoder_resolver = _DecoderResolver(self._decoders)`. Keep
50+
`self._decoders`. Import `_DecoderResolver` from
51+
`httpware.decoders._resolver`.
52+
53+
- [ ] **Step 3: Replace the four call sites**
54+
55+
In `AsyncClient.send`, `AsyncClient.send_with_response`, `Client.send`,
56+
`Client.send_with_response`, replace each resolve/raise/decode/wrap block with
57+
`bound = self._decoder_resolver.resolve(response_model)` (before
58+
`self._dispatch(request)`), then `bound.decode(response)` after. Keep the
59+
`send_with_response` `(response, decoded)` shape. Remove both
60+
`_dispatch_decoder` methods. `MissingDecoderError`/`DecodeError` imports in
61+
`client.py` may become unused — drop them if so.
62+
63+
- [ ] **Step 4: Verify parity**
64+
65+
```bash
66+
uv run pytest tests/test_decoders_pydantic.py tests/test_decoders_msgspec.py \
67+
tests/test_client_decoders_default.py tests/test_client_construction.py \
68+
tests/test_client_sync.py tests/test_optional_extras_pydantic_missing.py \
69+
tests/test_client_typing.py --no-cov -q
70+
```
71+
All green, unchanged. If any fail, the extraction drifted — fix the resolver,
72+
not the tests.
73+
74+
- [ ] **Step 5: Commit**
75+
76+
```bash
77+
git add src/httpware/decoders/_resolver.py src/httpware/client.py
78+
git commit -m "refactor(decoders): extract _DecoderResolver for Seam B
79+
80+
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
81+
```
82+
83+
---
84+
85+
### Task 2: Add seam-level resolver tests
86+
87+
**Files:**
88+
- Create: `tests/test_decoder_resolver.py`
89+
90+
Drive `resolve` / `_BoundDecoder.decode` directly with a fake decoder list — no
91+
client, no `MockTransport`.
92+
93+
- [ ] **Step 1: Write the matrix**
94+
95+
A `FakeDecoder` claiming a set of model types (annotate all args). Cover:
96+
claiming model → bound whose `.decode(response)` returns the decoded value;
97+
no claimer → `MissingDecoderError` with `registered_names` matching the fake
98+
list and order; first-match-wins across two fakes; decode success on good
99+
bytes; decoder raises → `DecodeError` carrying `response` / `model` /
100+
`original`; empty decoder tuple → `MissingDecoderError` with `()`.
101+
102+
- [ ] **Step 2: Run**
103+
104+
```bash
105+
uv run pytest tests/test_decoder_resolver.py --no-cov -q
106+
```
107+
All green.
108+
109+
- [ ] **Step 3: Commit**
110+
111+
```bash
112+
git add tests/test_decoder_resolver.py
113+
git commit -m "test(decoders): cover _DecoderResolver at the seam
114+
115+
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
116+
```
117+
118+
---
119+
120+
### Task 3: Promote to architecture, lint, full suite
121+
122+
**Files:**
123+
- Modify: `architecture/decoders.md`
124+
- Modify: `planning/changes/2026-06-23.02-decoder-resolver-extraction/design.md` (frontmatter at ship)
125+
126+
- [ ] **Step 1: Promote the living truth**
127+
128+
In `architecture/decoders.md`, replace the `_dispatch_decoder` references and
129+
the "`send`/`send_with_response` raise `MissingDecoderError`" framing with the
130+
`_DecoderResolver` / `_BoundDecoder` description: resolution + pre-flight error
131+
+ decode-wrapping live in the resolver; the clients call `resolve` before
132+
dispatch and `bound.decode` after. Keep it prose, no frontmatter.
133+
134+
- [ ] **Step 2: Full gate**
135+
136+
```bash
137+
just lint && just test
138+
```
139+
Both clean (100% coverage). Confirm the `httpx2._` and other review-only
140+
invariants still hold in the diff.
141+
142+
- [ ] **Step 3: Ship frontmatter + commit**
143+
144+
Set `status: shipped`, `pr`, and `outcome` in `design.md` once the PR number
145+
exists. Run `just index` to confirm the listing regenerates.
146+
147+
```bash
148+
git add architecture/decoders.md planning/changes/2026-06-23.02-decoder-resolver-extraction/
149+
git commit -m "docs(decoders): promote _DecoderResolver into architecture truth
150+
151+
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
152+
```

0 commit comments

Comments
 (0)