Repository navigation
fix: do not re-stringify a failed exception when building an internal error - #186
Closed
glenrgordon wants to merge 1 commit into
Closed
glenrgordon wants to merge 1 commit into
glenrgordon wants to merge 1 commit into
Conversation
… error
The internal-error recovery in `Connection._execute_request` guarded
`json.loads(str(exc))` with a `try`, then called `str(exc)` a second time
inside that `except` clause. An exception whose `__str__` raises therefore
made the recovery path re-run the call that had just failed, where no
sibling handler could catch it: the error propagated out of
`_execute_request`, `_run_request` re-raised at `handler.result()`, and no
reply was ever written. The peer waits on that request id forever.
Any exception whose `__str__` depends on state that is gone reaches this —
a lazy proxy, a closed cursor, a partially-initialised object.
Stringify once, before either branch, and fall back to
`f"<unprintable {type(exc).__name__}>"`, which reads the class attribute and
calls nothing on the instance. `repr(exc)` would be the same hazard again.
An exception whose message is valid JSON still takes the `json.loads`
branch; a test pins that.
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
Lead with the structure rather than the symptom, because it reads as pedantic until the shape
is visible: this is the recovery path for an exception, and it recovers by re-running the
call that failed.
Connection._execute_request'sexcept Exceptionclause:The second
str(exc)sits inside theexcept Exceptionopened on the line above, where nosibling handler can catch it. So an exception whose
__str__raises makes the first callraise, is caught, and then raises again from the recovery line — propagating out of
_execute_request._run_requestre-raises it atpayload = handler.result(), nothing issent, and the peer waits on that request id forever.
The fix stringifies once, outside both branches, with its own guard:
type(exc).__name__reads the class's name attribute and calls nothing on the instance, so itcannot fail the same way.
repr(exc)would be the same hazard wearing a different hat.This is reachable without anything exotic. A handler raising
RuntimeError("x")goes throughthis code on every ordinary crash; only a
__str__that raises turns it fatal. Any exceptionclass whose
__str__depends on state that is gone qualifies — a lazy proxy, a closed cursor,a partially-initialised object.
Related issues
None filed yet. Opened as a draft so the report and the fix can be read together; happy to file
a tracking issue first if maintainers prefer that order.
Testing
Two tests added in
tests/test_rpc.py, driving a realConnectionover a socket pair:test_handler_exception_with_raising_str_still_answerstest_handler_exception_with_json_message_keeps_parsed_dataThe first fails on unmodified
mainby timing out — no line is written, so the failure isTimeoutErrorat the read rather than an assertion. The background traceback on that run isthe defect laid out end to end:
With the fix, the peer gets a
-32603whosedata.detailsnames the exception type, so areader can tell this from an ordinary crash whose message happened to be empty.
The second test passes before this change as well as after. It pins the existing behaviour
that an exception whose
__str__returns valid JSON still takes thejson.loadsbranch, sodatais the parsed object rather than{"details": ...}. That behaviour predates this PR anda stringify-once rewrite is the easiest place to break it by accident.
tests/test_request_error_logging.pyis unmodified and passes — it asserts this exactexceptclause logs with
exc_info, which makes it the guard that thelogging.exceptioncall was notdisturbed.
Environment: Windows 11, CPython 3.13,
uv,mingw32-make.make check: 2tydiagnostics, both pre-existingunresolved-importforhypercorninexamples/http_server.py. prek/ruff all pass.deptry srcreportsSuccess! No dependency issues found.— run by hand, because the target stops at the failingtystep on this platform. No new diagnostics.make test:2 failed, 490 passed, 2 skipped. Both failures are pre-existing and intests/real_user/test_stdio_limits.py— a Windows CRLF line-ending artifact and aload-sensitive subprocess test. Measured on unmodified
maintwice in the same worktree:2 failed, 488 passed, 2 skippedboth times, the same two tests. 488 → 490 is exactly thesetwo new tests. CI is the authority on the clean numbers.
Docs & screenshots
None needed. Nothing under
docs/describes the error payload — grepped forinvalid_params,-32602andRequestErrorwith no hits. The only user-visible change is that a crash whichpreviously produced no reply now produces a
-32603naming the exception type.Checklist
make gen-allnot involved.