Repository navigation
fix: answer the peer when a response payload cannot be encoded - #187
Closed
glenrgordon wants to merge 1 commit into
Closed
glenrgordon wants to merge 1 commit into
glenrgordon wants to merge 1 commit into
Conversation
A response payload is attributed to a request id before it is encoded, and
json.dumps runs inside transport.send. So any payload the serialiser refuses
makes the TypeError escape the response task, no reply is ever written, and
the peer waits on that id forever.
Two routes reach it today with no hostile input at all:
- a handler returning a raw value that is not JSON-native, e.g. {"x": {1, 2}};
a BaseModel result is safe because model_dump(mode="json") coerces, but a
raw return value was assigned unchecked;
- a handler raising RequestError(..., data=...), whose data is typed Any and
passed straight through to the error object.
Gate the finished payload once, at the end of _execute_request, with the same
json.dumps the transports call. Pydantic's to_jsonable_python would not be a
faithful gate here: it coerces a set or bytes rather than rejecting them, so it
would pass payloads json.dumps then refuses.
The check is deliberately not at the send. A transport failure means the peer
is unreachable and propagating it unanswered is correct, which
test_response_send_failure_is_not_mapped_to_a_handler_error asserts; an
encoding failure means the peer is reachable and the payload is our fault.
Catching around transport.send would conflate the two and would convert a
transport implementation's own TypeError into a protocol error reported to the
peer. That file is untouched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Summary
A response payload is attributed to a request id before it is encoded, and
json.dumpsruns inside
transport.send. So any payload the serialiser refuses makes theTypeErrorescape the response task, no reply is ever written, and the peer waits on that id forever.
Two routes reach this today, neither needing a hostile input:
{"x": {1, 2}}. ABaseModelresult is safe, becausemodel_dump(mode="json", ...)coerces even anAnyfield holding a
setinto a list. A raw return value was assigned unchecked.RequestError(..., data=...), whosedatais typedAnyand passedstraight through to
to_error_obj(). This is the SDK's own public way to return astructured protocol error, and
Anyis exactly where a caller puts "whatever object I hadin hand".
The fix gates the finished payload once, at the end of
_execute_request:Ten lines including comments, in one file, covering every payload shape rather than the two
routes that happen to be known.
jsonandloggingare already imported, so there is no newimport and nothing for
deptryto say.str(exc)is safe here specifically becauseexccomes fromjson.dumps: its message isbuilt from a type name (
Object of type set is not JSON serializable), not from the offendingobject, and the serialiser never calls
repron it.Why the gate must use
json.dumpsThe natural implementation is
to_jsonable_python— pydantic's serialiser, already adependency. It is not a faithful gate: without a fallback it coerces a
setto a list andbytesto astrand raises only on a genuinely unknown type, so it would pass payloadsjson.dumpsthen refuses. That is a check which reports success and changes nothing. All thetransports encode with
json.dumps(task/sender.py,_sse.py,ws/client.py, differingonly in
separators), so the gate does too. Verified by substitution: built onto_jsonable_python, the raw-result test fails.Why the gate is not at the send
This is the part worth agreeing with before the fix:
Catching
(TypeError, ValueError)aroundawait self._transport.send(payload)would conflatethe two. It would also convert a transport implementation's own unrelated
TypeErrorinto a-32603reported to the peer as a protocol error, masking exactly the bug you want to see.Gating inside
_execute_requestkeeps those apart at the cost of one serialisation, andtests/test_request_error_logging.pyis untouched by this PR.Also rejected: coercing the payload with
fallback=strbefore sending. That ends the silenceby substituting a lie —
{"result": {"x": "{1, 2}"}}arrives under aresultkey,indistinguishable from a genuine answer, and the peer acts on it. An error is self-describing;
a mangled success is not. The gate drops
resultand replaces it with an error for the samereason.
The cost, stated plainly
One extra
json.dumpsof the payload per response, including theBaseModelpath that isalready known-safe: measured at ~1 µs for a small payload, ~11 µs at 10 KB, ~1.2 ms at 1 MB on
this machine. A narrower per-site check would avoid that on the
BaseModelpath at the cost oftwo separate checks, more lines, and no guarantee about payload shapes nobody has written yet.
Happy to switch to the narrow form if the hot-path cost matters more than the coverage — the
per-site version is written and tested, and I can push it as an alternative.
Related issues
None filed yet. Opened as a draft so the reproduction and the fix can be read together; happy
to file a tracking issue first if maintainers prefer that order.
fc545d6 fix: align JSON-RPC error handling across transports (#174)is inmainand doesnot address either route — worth saying, since it is adjacent work a maintainer will recall.
Testing
Three tests added in
tests/test_rpc.py, all driving a realConnectionover a socket pair sojson.dumpsruns where it runs in production:test_unserialisable_raw_result_answers_the_peertest_unserialisable_request_error_data_answers_the_peertest_base_model_result_with_a_set_is_coerced_and_still_answersThe first two fail on unmodified
mainby timing out —TimeoutErroratserver_reader.readline(), never reaching an assertion, because no line is ever written. Thatis the defect: not a wrong value, an absent reply. Each logs the real site on the way down:
The third passes before this change as well as after, deliberately. It pins the
pre-existing pydantic behaviour that makes a
BaseModelresult safe; ifmodel_dump(mode="json")ever stops coercing, that test says so. Also probed: a
BaseModelwhoseAnyfield holds aplain non-JSON-native object raises
PydanticSerializationErrorinside the existingtryandbecomes a
-32603already.tests/test_request_error_logging.pyis unmodified and passes, includingtest_response_send_failure_is_not_mapped_to_a_handler_error. If this change had needed to editthat file, it would have drifted into the send path and the reasoning above would be void.
Environment: Windows 11, CPython 3.13,
uv,mingw32-make.make check: 2tydiagnostics, both pre-existingunresolved-importforhypercorninexamples/http_server.py(undeclared inpyproject.tomlanduv.lock; the target's--exclude "examples/*.py"does not match on this platform). All 10 prek hooks pass,ruff checkandruff formatincluded. No new diagnostics.make test:1 failed, 492 passed, 2 skipped— 489 plus these three tests. The one failureis a pre-existing Windows CRLF line-ending artifact in
tests/real_user/test_stdio_limits.py(assert 71682 == 71681). A second test in that fileis load-sensitive and fails intermittently here on unmodified
maintoo. CI is theauthority on the clean numbers.
Docs & screenshots
None needed. Nothing under
docs/describes the error payload,RequestError.data, or thehandler return contract — grepped for
invalid_params,-32602andRequestErrorwith nohits — so there is no page to update. The behaviour change runs in one direction only: a
handler that previously stranded its caller now gets a
-32603whosedetailsname theencoding failure. No handler that worked before behaves differently.
Checklist
make gen-allnot involved.