perf: discriminate JSONRPCMessage union by key presence instead of smart-union scoring#3135
Open
sandole wants to merge 2 commits into
Open
perf: discriminate JSONRPCMessage union by key presence instead of smart-union scoring#3135sandole wants to merge 2 commits into
sandole wants to merge 2 commits into
Conversation
…art-union scoring Every transport (stdio, SSE, streamable HTTP; client and server) decodes inbound messages through jsonrpc_message_adapter, which validated the 4-member JSONRPCMessage union in Pydantic smart-union mode - scoring all four branches per message. The variants are distinguishable by key presence per JSON-RPC 2.0, so a callable Discriminator selects exactly one branch: 1.6-2.2x faster envelope decoding (see scripts/bench_jsonrpc_codec.py), byte-identical wire output, and exact classification parity with the previous behavior (including the null/float/bool-id notification downgrade and error-over-result precedence for degenerate shapes), pinned by tests. JSONRPCMessage itself stays a plain union so isinstance() keeps working; the Tag/Discriminator wrapping lives only inside the TypeAdapter, so no call sites change. Unclassifiable input now raises a single jsonrpc_message_invalid error with an actionable message instead of a four-branch error dump; error codes and HTTP statuses are unchanged. Also documents why the server POST path keeps its two-phase from_json + validate_python parse: with a callable discriminator, validate_json must materialize the payload to call the discriminator and measures ~1.75x slower on large bodies.
sandole
marked this pull request as ready for review
July 19, 2026 22:50
There was a problem hiding this comment.
All reported issues were addressed across 4 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…k ordering
The discriminator does not reproduce smart-union scoring on spec-invalid
hybrids: {method, error} now classifies as a call instead of
JSONRPCError, and {method: <non-str>, result} is rejected instead of
falling through to JSONRPCResponse. No fixed key-priority order can
mirror field-count scoring on these shapes, and the key-presence rule is
the safer choice (a malformed frame can no longer masquerade as the
error response to a pending request), so pin the divergence with tests
and correct the docstring's parity claim to spec-valid messages.
Also alternate adapter run order each benchmark round so warm-up and
CPU-state effects are not attributed to either adapter; speedups are
unchanged (1.6-2.2x).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3136
Summary
jsonrpc_message_adapter- the decode choke point for every transport (stdio, SSE,streamable HTTP; client and server) - validates the 4-member
JSONRPCMessageunion inPydantic smart-union mode, scoring all four branches on every inbound message:
The variants are distinguishable by key presence per JSON-RPC 2.0 (
method[+ validid] → request/notification,error→ error,result→ response), so this PR adds akey-presence callable
DiscriminatorandTags inside theTypeAdapter, selectingexactly one branch per message.
Measurements
uv run python scripts/bench_jsonrpc_codec.py(darwin arm64, CPython 3.14.2, pydantic 2.12.5; best of 10 rounds with adapter run order alternating each round, per cubic's review note, so warm-up/CPU-state effects are not attributed to either adapter):The benchmark script is included so the numbers are reproducible and to pin the
counter-intuitive finding below; happy to drop it from the PR and keep the numbers
here only, at maintainer discretion.
Compatibility
JSONRPCMessagestays a plain union (isinstancekeeps working);Tag/Discriminatorlive only inside theTypeAdapter. Zero call-site changes.methodobjects still downgrade to notifications (interaction with Requests with "id": null silently misclassified as notifications #2057/Reject JSON-RPC requests with null id instead of misclassifying as notifications #2075 noted - the id-guard is the natural locus for that change if it lands); degenerate{method,id,result}→ Request and{id,result,error}→ Error unchanged.{method, error}hybrids now classify as a call (themethodkey wins deterministically) instead ofJSONRPCError, and{method: <non-str>, result}is rejected instead of falling through toJSONRPCResponse. Smart union picked those branches via field-count scoring - a heuristic that let a malformed frame masquerade as the error response to a pending request. No fixed key-priority order can reproduce the scoring behavior on these shapes, and the key-presence rule is the spec-faithful choice for input that violates JSON-RPC 2.0's member exclusivity anyway.dump_jsonwithby_alias/exclude_noneround-trip tested).jsonrpc_message_invaliderror with an actionable message instead of a 4-branch error dump. Error codes and HTTP statuses unchanged.from_json→validate_pythonparse: with a callable discriminator, single-passvalidate_jsonmust materialize the payload to call the discriminator and measures ~1.75x slower on large bodies, so the two-phase form is intentional (guards against a future "cleanup" regression).Tests
tests/types/test_jsonrpc_discriminator.py- 21 tests, one per discriminator branch, including instance-revalidation identity, dump-parity pins, and pins for the documented spec-invalid-hybrid divergences.test_modern_post_with_deeply_nested_body_is_parse_error_not_a_crash- fails identically on cleanmain; unrelated.)ruff format --check,ruff check, repo-widepyright(strict), and the mcp-types standalone import check: all clean.AI disclosure
AI assistance (Claude Code) was used while preparing this PR. I reviewed the final
change, ran the measurements and full verification matrix above locally, and take
responsibility for it.