Skip to content

fix: do not re-stringify a failed exception when building an internal error - #186

Closed
glenrgordon wants to merge 1 commit into
agentclientprotocol:mainfrom
glenrgordon:fix/no-restringify-failed-exception
Closed

glenrgordon wants to merge 1 commit into
agentclientprotocol:mainfrom
glenrgordon:fix/no-restringify-failed-exception

Conversation

@glenrgordon

Copy link
Copy Markdown

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's except Exception clause:

try:
    data = json.loads(str(exc))     # str(exc) here IS guarded
except Exception:
    data = {"details": str(exc)}    # and called AGAIN, unguarded

The second str(exc) sits inside the except Exception opened on the line above, where no
sibling handler can catch it. So an exception whose __str__ raises makes the first call
raise, is caught, and then raises again from the recovery line — propagating out of
_execute_request. _run_request re-raises it at payload = handler.result(), nothing is
sent, and the peer waits on that request id forever.

The fix stringifies once, outside both branches, with its own guard:

try:
    text = str(exc)
except Exception:
    text = f"<unprintable {type(exc).__name__}>"
try:
    data = json.loads(text)
except Exception:
    data = {"details": text}

type(exc).__name__ reads the class's name attribute and calls nothing on the instance, so it
cannot 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 through
this code on every ordinary crash; only a __str__ that raises turns it fatal. Any exception
class 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 real Connection over a socket pair:

  • test_handler_exception_with_raising_str_still_answers
  • test_handler_exception_with_json_message_keeps_parsed_data

The first fails on unmodified main by timing out — no line is written, so the failure is
TimeoutError at the read rather than an assertion. The background traceback on that run is
the defect laid out end to end:

  File "src/acp/connection.py", line 282, in _execute_request
    data = json.loads(str(exc))
RuntimeError: __str__ exploded

During handling of the above exception, another exception occurred:

  File "src/acp/connection.py", line 284, in _execute_request
    data = {"details": str(exc)}
RuntimeError: __str__ exploded
...
  File "src/acp/connection.py", line 250, in _run_request
    payload = handler.result()
RuntimeError: __str__ exploded

With the fix, the peer gets a -32603 whose data.details names the exception type, so a
reader 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 the json.loads branch, so
data is the parsed object rather than {"details": ...}. That behaviour predates this PR and
a stringify-once rewrite is the easiest place to break it by accident.

tests/test_request_error_logging.py is unmodified and passes — it asserts this exact except
clause logs with exc_info, which makes it the guard that the logging.exception call was not
disturbed.

Environment: Windows 11, CPython 3.13, uv, mingw32-make.

  • make check: 2 ty diagnostics, both pre-existing unresolved-import for hypercorn in
    examples/http_server.py. prek/ruff all pass. deptry src reports
    Success! No dependency issues found. — run by hand, because the target stops at the failing
    ty step on this platform. No new diagnostics.
  • make test: 2 failed, 490 passed, 2 skipped. Both failures are pre-existing and in
    tests/real_user/test_stdio_limits.py — a Windows CRLF line-ending artifact and a
    load-sensitive subprocess test. Measured on unmodified main twice in the same worktree:
    2 failed, 488 passed, 2 skipped both times, the same two tests. 488 → 490 is exactly these
    two new tests. CI is the authority on the clean numbers.

Docs & screenshots

None needed. Nothing under docs/ describes the error payload — grepped for invalid_params,
-32602 and RequestError with no hits. The only user-visible change is that a crash which
previously produced no reply now produces a -32603 naming the exception type.

Checklist

  • Conventional Commit title.
  • Tests cover the change.
  • Docs/examples updated when behaviour is user-facing — explained above.
  • No schema regeneration; make gen-all not involved.

… 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>
@glenrgordon glenrgordon closed this Nov 5, 2026
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