Skip to content

Keep the real error text on is_error results, and raise a typed error - #1181

Open
adhavan18 wants to merge 1 commit into
anthropics:mainfrom
adhavan18:fix/error-result-text-and-typed-error
Open

Keep the real error text on is_error results, and raise a typed error#1181
adhavan18 wants to merge 1 commit into
anthropics:mainfrom
adhavan18:fix/error-result-text-and-typed-error

Conversation

@adhavan18

Copy link
Copy Markdown

Fixes #1146.

The bug

When the agent loop completes but the final turn was an API error, the CLI emits a terminal result with an empty errors array and subtype: "success", putting the real prose in result:

{"type": "result", "subtype": "success", "is_error": true, "errors": [],
 "result": "API Error: Stream idle timeout - no chunks received"}

"; ".join([]) is falsy, so the text fell through to subtype. Reproduced on current main:

### BEFORE
  raised type : Exception
  raised text : Claude Code returned an error result: success

### AFTER
  raised type : ResultError
  raised text : Claude Code returned an error result: API Error: Stream idle timeout - no chunks received

Self-contradictory, and the actual error was discarded.

The change

1. Prefer the result text. The order is now errorsresultsubtype, and subtype is only used when it actually names an error:

self._last_error_result_text = (
    "; ".join(errors)
    or str(message.get("result") or "")
    or (subtype if subtype.startswith("error") else "")
    or "unknown error"
)

The startswith("error") guard is the second half of the issue's suggestion. Without it, subtype: "success" would still come through as the error text whenever result is absent, which is the same contradiction in a rarer shape. There is a test for that case.

2. Raise a typed error. Terminal error results now raise ResultError(ClaudeSDKError) instead of a bare Exception, so callers can catch it. It carries subtype, errors and exit_code, so the structured fields survive rather than only the formatted string:

try:
    async for msg in client.receive_response():
        ...
except ResultError as e:
    if e.subtype == "error_max_turns":
        ...

The internal error frame carries those fields across the queue so the raise site can populate them. Non-result errors still raise a plain Exception, so nothing else changes.

Why a new error type rather than ProcessError

ProcessError means "the process failed", and its message already appends (exit code: N). Here the process did exactly what it was told: it reported a structured result and exited non-zero on purpose. Reusing it would conflate "the CLI crashed" with "the CLI reported an error result", which are different things for a caller deciding whether to retry. ResultError still exposes exit_code, so nothing is lost.

I am happy to switch to ProcessError if you would rather not add a public name.

Testing

tests/test_error_result_text.py drives a stub transport through the real Query read loop:

  • the reported case: prose preserved, not subtype
  • it raises ResultError, and is catchable as ClaudeSDKError, with subtype/errors/exit_code populated
  • a populated errors list still wins
  • a genuine error_* subtype is still used when there is no prose
  • a success-ish subtype never becomes the message
  • a clean result still does not raise
$ pytest tests/
1336 passed, 12 skipped

ruff check, ruff format and mypy are clean on the changed files.

Fixes anthropics#1146.

When the loop completes but the final turn was an API error, the CLI sends
`{"subtype": "success", "is_error": true, "errors": [], "result": "API Error: ..."}`
and exits non-zero. `errors` is empty, so `"; ".join(errors)` was falsy and the
text fell back to `subtype`, producing:

    Exception: Claude Code returned an error result: success

which contradicts itself and drops the prose the CLI already provided. It now
prefers `result`, and only uses `subtype` when it actually names an error:

    ResultError: Claude Code returned an error result: API Error: Stream idle timeout - no chunks received

The second half of the issue was the bare `Exception`, which callers cannot
catch as an SDK error. Terminal error results now raise `ResultError`, a new
`ClaudeSDKError` subclass carrying `subtype`, `errors` and `exit_code`, so the
structured fields survive instead of only the formatted string. The frame that
crosses the internal queue carries those fields for it.

Non-result errors still raise a plain `Exception`, so nothing else changes.
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.

is_error result with empty errors[] reports 'error result: success' and raises bare Exception, discarding the real error text

1 participant