Skip to content

fix(decoders): MsgspecDecoder.can_decode rejects nested CustomType (0.9.1)#43

Merged
lesnik512 merged 9 commits into
mainfrom
fix/msgspec-nested-customtype
Jun 13, 2026
Merged

fix(decoders): MsgspecDecoder.can_decode rejects nested CustomType (0.9.1)#43
lesnik512 merged 9 commits into
mainfrom
fix/msgspec-nested-customtype

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Summary

Two linked deliverables from a post-0.9.0 review pass:

  1. Delta audit of the 0.9.0 multi-decoder epic — a verified, severity-bucketed audit (planning/audit/2026-06-12-delta-audit.md) of everything changed since 0.8.6, run via a single-chunk find→verify→synthesize workflow. 14 confirmed findings (2 high, 1 medium, 3 low, 8 nit).
  2. The fix for the headline High findingMsgspecDecoder.can_decode no longer falsely claims parameterized containers of pydantic models.

The bug

can_decode inspected only the top-level msgspec.inspect.type_info node. For list[PUser] (a ListType whose item_type is a CustomType), the guard passed, msgspec built a decoder via its CustomType fallback, and can_decode returned True. The MissingDecoderError pre-flight was bypassed, a real request was sent, and decode then failed with DecodeError — cached per-instance, so every later request of that shape repeated the wasted round-trip. Masked under the pydantic-first default; only bit msgspec-only configs.

The fix

A recursive _contains_custom_type walker rejects when a CustomType appears anywhere in the type tree — covering list/dict/set/tuple/Optional/Union and arbitrary nesting via generic introspection. It stops at Struct/dataclass field boundaries automatically (those expose Field, not Type), so valid targets like list[SomeStruct] stay accepted and self-referential structs can't loop. No public-API or ResponseDecoder protocol change.

Ships as 0.9.1 (release notes drafted in planning/releases/0.9.1.md; tagging is a separate manual step).

Test Plan

  • just lint clean (ruff format + check + ty)
  • just test — 501 passed, 100% coverage
  • Unit: parametrized rejection (list[PUser], dict[str, PUser], dict[PUser, str], PUser | None, tuple[PUser, int], list[list[PUser]], set[PUser]) + acceptance (list[Struct], dict[str, int], scalars) sets
  • Integration: sync + async msgspec-only client raises MissingDecoderError for list[PUser] with the transport handler asserted never invoked
  • Regression proven genuine: reverting only the source fix makes both dispatch tests fail (request sent)

🤖 Generated with Claude Code

lesnik512 and others added 9 commits June 12, 2026 14:11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…itive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
list[PUser], dict[str, PUser], and other containers parameterized by a
pydantic BaseModel (any CustomType-falling element) were wrongly accepted
because can_decode inspected only the top-level type_info node. They now
route to MissingDecoderError before a request is sent.

Closes the High finding in planning/audit/2026-06-12-delta-audit.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A msgspec-only client asked for list[_PydanticUser] now raises
MissingDecoderError without sending a request. Sync + async twins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lesnik512 lesnik512 merged commit 4b52e15 into main Jun 13, 2026
5 checks passed
@lesnik512 lesnik512 deleted the fix/msgspec-nested-customtype branch June 13, 2026 07:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant