Skip to content

Commit 4b52e15

Browse files
authored
Merge pull request #43 from modern-python/fix/msgspec-nested-customtype
fix(decoders): MsgspecDecoder.can_decode rejects nested CustomType (0.9.1)
2 parents ab25469 + 898fac8 commit 4b52e15

10 files changed

Lines changed: 2026 additions & 5 deletions

planning/audit/2026-06-12-delta-audit.md

Lines changed: 359 additions & 0 deletions
Large diffs are not rendered by default.

planning/audit/workflow-delta.mjs

Lines changed: 427 additions & 0 deletions
Large diffs are not rendered by default.

planning/plans/2026-06-12-delta-audit-plan.md

Lines changed: 533 additions & 0 deletions
Large diffs are not rendered by default.

planning/plans/2026-06-13-msgspec-nested-customtype-fix-plan.md

Lines changed: 315 additions & 0 deletions
Large diffs are not rendered by default.

planning/releases/0.9.1.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# httpware 0.9.1 — `MsgspecDecoder` stops claiming containers it can't decode
2+
3+
**Patch release with one behavior change.** When `MsgspecDecoder` is the only decoder registered (an msgspec-only install, or an explicit `decoders=[MsgspecDecoder()]`), a `response_model=` of `list[SomePydanticModel]`, `dict[str, SomePydanticModel]`, `SomePydanticModel | None`, or any container parameterized by a type msgspec can't natively decode now raises `MissingDecoderError` *before* a request is sent — instead of sending the request and failing at decode with `DecodeError`.
4+
5+
## The gap
6+
7+
`MsgspecDecoder.can_decode` answers the client's pre-flight question "can you decode this type?" — and on a `False` from every registered decoder, the client raises `MissingDecoderError` without touching the network. msgspec builds a `json.Decoder` for almost any type via a generic `CustomType` fallback, so `can_decode` used `msgspec.inspect.type_info` to detect and reject that fallback. But it inspected only the **top-level** node: `type_info(list[PUser])` is a `ListType` whose `item_type` is the `CustomType`, so the top-level check passed, the decoder built, and `can_decode` returned `True`. The pre-flight was bypassed, a real HTTP request went out, and `decode` then raised a validation error (surfaced as `DecodeError`). The false-positive was cached per instance, so every later request of that shape repeated the wasted round-trip.
8+
9+
Under the default pydantic-first `decoders=[PydanticDecoder(), MsgspecDecoder()]`, this was masked — pydantic claims `list[PUser]` first. The bug only bit msgspec-only configurations.
10+
11+
## The fix
12+
13+
`can_decode` now walks the full `type_info` tree and rejects if a `CustomType` appears **anywhere** in it, via a recursive helper that visits every nested element type (`list`/`dict`/`set`/`tuple`/`Optional`/`Union`, arbitrarily nested). The walk stops at `Struct`/dataclass field boundaries automatically, so genuine msgspec targets like `list[SomeStruct]` stay accepted and self-referential structs can't loop.
14+
15+
No public-API change: the `ResponseDecoder` protocol and `can_decode`'s signature are unchanged. Only the set of types `MsgspecDecoder` claims is corrected.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Spec: Delta audit of the 0.9.0 multi-decoder epic — code + docs
2+
3+
**Date:** 2026-06-12
4+
**Baseline:** tag `0.8.6` (last commit covered by the 2026-06-07 deep audit, all findings closed by 0.8.6)
5+
**Head:** current `main` (`ab25469`, post-0.9.0)
6+
**Prior art:** [2026-06-07-deep-audit-design.md](2026-06-07-deep-audit-design.md), [planning/audit/workflow.mjs](../audit/workflow.mjs), [planning/audit/2026-06-07-deep-audit.md](../audit/2026-06-07-deep-audit.md)
7+
8+
## Purpose
9+
10+
Audit everything that changed since the deep audit closed — the 0.9.0 multi-decoder routing epic (PRs #41, #42) plus the docs rewrite and GH Pages migration — and sweep the *entire* docs surface for consistency with the new decoder story. The deep audit's machinery is proven; this run adapts it to a ~1k-line delta instead of the full repo.
11+
12+
The deliverable is a verified, severity-bucketed findings document. No fixes in this effort; fixes become follow-up specs, as with the prior audit.
13+
14+
## Non-goals
15+
16+
- Re-auditing unchanged code (`middleware/`, resilience internals, `_internal/`) — swept four days ago, untouched since.
17+
- Performance, security, supply-chain dimensions (same exclusion as the deep audit).
18+
- Fixing anything, including trivial typos.
19+
- Re-running the stalled full-suite `tests` dimension from the deep audit; this run covers only the ~700 new test lines.
20+
21+
## Audit scope (what the finders read)
22+
23+
Code + tests, from `git diff 0.8.6..HEAD`:
24+
25+
- `src/httpware/client.py``decoders=[...]` parameter, type-dispatched routing, `_build_default_decoders`, `MissingDecoderError` pre-flight
26+
- `src/httpware/decoders/__init__.py`, `decoders/pydantic.py`, `decoders/msgspec.py``can_decode` predicate, per-instance caches
27+
- `src/httpware/errors.py``MissingDecoderError`
28+
- `src/httpware/__init__.py` — new exports
29+
- Changed tests: `test_client_construction.py`, `test_client_decoders_default.py`, `test_client_dispatch.py`, `test_client_send_with_response{,_sync}.py`, `test_client_sync.py`, `test_decoders_msgspec.py`, `test_decoders_pydantic.py`, `test_errors.py`, `test_optional_extras_pydantic_missing.py`, `test_public_api.py`
30+
31+
Docs surface (full sweep, not delta — 0.9.0 changed the decoder narrative and reversed the 0.3.0 fail-fast behavior, so unchanged pages may now be stale):
32+
33+
- `docs/index.md`, `docs/errors.md`, `docs/middleware.md`, `docs/resilience.md`, `docs/testing.md`, `docs/recipes/*.md`, `docs/dev/*.md`
34+
- `README.md`, `CLAUDE.md`
35+
- `planning/engineering.md`, `planning/deferred-work.md`, `planning/releases/0.9.0.md`
36+
- `mkdocs.yml` nav vs. files on disk
37+
38+
## Architecture
39+
40+
Single Workflow run (one chunk — the delta is too small to justify the deep audit's 4-chunk gating). Pipeline reuses the deep-audit skeleton:
41+
42+
1. **Find** — 4 dimension finders in parallel (below). No discover phase: the file lists above are embedded verbatim in each finder prompt, replacing `_discover.json`.
43+
2. **Verify** — every candidate finding judged by the existing 3-voter panel (`code_reality` / `reproducer` / `spec_grounded` lenses); survives at ≥2 confirms; severity raised on ≥2 `raise` votes, lowered on ≥1 `lower` vote. Per-dimension candidate cap: 15.
44+
3. **Synthesize** — one agent writes the audit doc and commits it.
45+
46+
### Lessons from the deep audit baked in
47+
48+
- **Test-quality finder gets Opus.** Sonnet stalled twice (~1.5M tokens, zero findings) on meta-review dimensions; the `new_tests` finder runs on Opus with a narrower target (~700 lines, named files).
49+
- **Synthesis must not create other files.** The synthesis prompt explicitly forbids writing or editing anything except the audit doc.
50+
- **args JSON.parse shim retained.** `args` may arrive as a JSON string; normalize before use.
51+
52+
### Model assignment
53+
54+
- Finders 1, 2, 4: Sonnet (`claude-sonnet-4-6`)
55+
- Finder 3 (`new_tests`): Opus (`claude-opus-4-8`)
56+
- Verifiers: Sonnet
57+
- Synthesis: Opus
58+
59+
## The 4 dimensions
60+
61+
### 1. `decoder_routing` (Sonnet)
62+
63+
Correctness of the new dispatch machinery: `can_decode` first-match ordering semantics; `MissingDecoderError` pre-flight timing (must raise before the request is sent, per the 0.9.0 spec); the default-decoder resolution matrix in `_build_default_decoders` (installed-extras detection); per-instance adapter/decoder cache behavior (PR #42 — no shared module state, no cross-instance leakage); the msgspec `CustomType` quirk — `msgspec.json.Decoder(BaseModel)` *succeeds* via CustomType fallback rather than raising, so `can_decode` must use `msgspec.inspect.type_info` filtering, not try/except.
64+
65+
Out of scope: sync/async divergence (dimension 2), test quality (3), docs (4).
66+
67+
### 2. `seam_parity` (Sonnet)
68+
69+
Seam B contract and sync/async parity of the changed code: both `Client.send` and `AsyncClient.send` must invoke decoder routing identically; `send_with_response` (both sides) must route through the same dispatch as `send`; `DecodeError` must still wrap decoder failures at Seam B (0.8.1 contract); `MissingDecoderError` must conform to the exception-construction conventions in CLAUDE.md (or be a documented deliberate deviation — it is not status-keyed, so the single-positional-`response` rule may legitimately not apply; the finder should check what the code does against what `errors.py` docstrings and `engineering.md` claim).
70+
71+
Out of scope: routing logic bugs (dimension 1), docs accuracy (4).
72+
73+
### 3. `new_tests` (Opus)
74+
75+
Quality of the changed/new test files only (named list above): tautological asserts (a known reviewer blind spot from the audit-closure retro); dispatch-matrix coverage gaps (decoder order, overlapping `can_decode`, zero-decoder client, model type matching no decoder); sync/async test parity for every new behavior; mocks/transports that hide real httpx2 behavior; tests passing for the wrong reason.
76+
77+
Out of scope: production-code bugs (1, 2), pre-existing test files untouched by the delta.
78+
79+
### 4. `docs_consistency` (Sonnet)
80+
81+
Every code block and load-bearing claim in the full docs surface, verified against 0.9.0 reality: residual `decoder=` (singular) usage anywhere; the fail-fast story — 0.3.0's "raise at `__init__` when extra missing" was *reversed* by 0.9.0's `MissingDecoderError` pre-flight, so any page still describing init-time failure is wrong; Seam B descriptions in `engineering.md` and CLAUDE.md vs. the actual `decode(content, model)` + `can_decode` surface; `planning/deferred-work.md` items quietly resolved (the module-global `lru_cache` item appears closed by PR #42); broken internal links; mkdocs nav vs. files on disk; release-notes claims in `planning/releases/0.9.0.md`.
82+
83+
When a docs finding is verified, the verifier must state whether the fix belongs in the doc or in the code; if code, synthesis recategorizes it to dimension 1 or 2.
84+
85+
Out of scope: prose style, doc structure opinions (the docs-philosophy memory: no autodoc, no migration guides — absence of a migration guide is not a finding).
86+
87+
## Schemas, verification, severity
88+
89+
Reuse the deep-audit schemas unchanged: `FINDING_SCHEMA` (dimension, title, file, line_hint, claim, evidence_quote, suspected_severity, reproducer_hint), `VERDICT_SCHEMA` (lens, confirmed, reason, quoted_evidence, severity_adjustment), and the severity bucket definitions:
90+
91+
- **Blocker** — wrong-correctness bug affecting users in normal usage; documented invariant violated; doc example that does not run.
92+
- **High** — bug behind a non-default path; missing safety check at a documented boundary; docs that mislead a reasonable reader.
93+
- **Medium** — works today but relies on undocumented invariants; accurate-but-ambiguous docs; test gaps in load-bearing primitives.
94+
- **Low** — minor inaccuracies, weak idioms, hardening suggestions.
95+
- **Nit** — style, naming, punctuation; collapsed into one rolled-up entry per dimension if more than 4 surface.
96+
97+
## Output
98+
99+
`planning/audit/2026-06-12-delta-audit.md`, same per-finding format as the deep audit (title, `file:line`, claim ≤3 sentences, fenced evidence quote, verifier consensus with lenses, suggested direction), but single-section — no chunk headers. Top-of-file summary with bucket counts and the headline finding. Synthesis commits the file as `audit(delta): 0.9.0 multi-decoder delta audit findings`.
100+
101+
The adapted script is saved as `planning/audit/workflow-delta.mjs` and committed alongside, mirroring how `workflow.mjs` was kept.
102+
103+
## File layout
104+
105+
```
106+
planning/audit/
107+
├── workflow.mjs # deep-audit script (unchanged, kept)
108+
├── workflow-delta.mjs # this run's adapted script (new)
109+
└── 2026-06-12-delta-audit.md # the report (new)
110+
```
111+
112+
No `_discover.json` equivalent — file lists are inlined in prompts.
113+
114+
## Token budget (estimate)
115+
116+
One chunk: ~600k–900k Sonnet (3 finders + ~3 verifiers × ~20–35 surviving candidates) + ~150k Opus (`new_tests` finder + synthesis). Roughly a quarter of the deep audit's spend.
117+
118+
## Risks & mitigations
119+
120+
- **Finders re-surface closed deep-audit findings.** Mitigation: each finder prompt names the baseline (`0.8.6`) and links the prior audit doc; the `spec_grounded` verifier checks candidates against the prior audit's closed list; synthesis dedupes against it.
121+
- **`docs_consistency` drowns in nits after a full-site sweep.** Mitigation: 15-candidate cap, nit roll-up rule, and the docs-philosophy exclusions stated in the prompt.
122+
- **Opus `new_tests` finder over-reports style opinions.** Mitigation: prompt anchors on "passes for the wrong reason / gap in the dispatch matrix" framing with the same default-to-silence instruction as the other finders.
123+
- **False positives.** Mitigation: unchanged 2-of-3 consensus with verifiers defaulting to `confirmed: false`.
124+
125+
## Open questions for writing-plans
126+
127+
- Exact finder prompt text (the plan drafts them; this spec fixes their scope and exclusions).
128+
- Whether the `0.8.6..HEAD` diff itself (or just file lists) is embedded in finder prompts. Default: file lists + instruction to run `git diff 0.8.6..HEAD -- <file>` themselves, so finders see change context without bloating prompts.
129+
130+
## Acceptance criteria
131+
132+
1. `planning/audit/2026-06-12-delta-audit.md` exists, committed, with findings from all four dimensions (or an explicit "no findings survived" note per dimension).
133+
2. Each finding carries title, `file:line`, claim, evidence quote, verifier consensus, suggested direction.
134+
3. No file other than the audit doc and `workflow-delta.mjs` is created or modified by the run.
135+
4. The user has reviewed the report as the audit deliverable.

0 commit comments

Comments
 (0)