Visitar URL original
fix: expose the assistant error kind on ResultError by BitmapAsset · Pull Request #1471 · anthropics/claude-agent-sdk-python · GitHub
Skip to content

fix: expose the assistant error kind on ResultError - #1471

Open
BitmapAsset wants to merge 2 commits into
anthropics:mainfrom
BitmapAsset:fix/result-error-error-kind
Open

BitmapAsset wants to merge 2 commits into
anthropics:mainfrom
BitmapAsset:fix/result-error-error-kind

Conversation

@BitmapAsset

Copy link
Copy Markdown

Problem

When the CLI is not logged in, a headless query() run ends like this (SDK 0.2.178, CLI 2.1.288, empty CLAUDE_CONFIG_DIR, no credentials):

AssistantMessage error= authentication_failed text= ['Not logged in · Please run /login']
ResultMessage subtype= success is_error= True api_error_status= None terminal_reason= api_error
RAISED ResultError: ... subtype= success terminal_reason= api_error api_error_status= None exit_code= 1

That ResultError looks the same as a transient failure. The existing test test_api_error_result_uses_result_text_not_success_subtype builds a stream idle timeout result with exactly these fields (subtype="success", terminal_reason="api_error", api_error_status=None). The only field that tells the two apart is AssistantMessage.error, which arrives on an earlier message and is not attached to the exception.

The ResultError docstring recommends:

if e.terminal_reason == "api_error":   # e.g. overloaded / timeout
    retry()

A logged-out worker (cron, CI, container) matches that branch and gets retried forever. To avoid that, callers have to string-match "Not logged in" or keep their own copy of the last assistant message, which is the kind of work ResultError was added to remove (#1205).

Change

  • Query remembers the error of the latest top-level assistant message (subagent messages are ignored) and passes it to ResultError as a new optional error attribute (an AssistantMessageError value such as "authentication_failed", "rate_limit" or "server_error", or None).
  • The docstring example now branches on e.error: retry on rate_limit / server_error, stop on authentication_failed.
  • error is a keyword argument with a default, so type(e)(*e.args) reconstruction and existing callers keep working. It round-trips through pickle and copy like the other attributes.

Tests

  • New test_auth_failure_result_error_carries_assistant_error_kind: a mocked transport sends assistant(error="authentication_failed"), then an error result shaped like the timeout above, then exit 1. The test asserts e.error == "authentication_failed". It fails with the query.py change reverted.
  • The existing pickle/copy and malformed-fields tests now also check error.
  • pytest tests/: 1644 passed, 6 skipped. ruff check, ruff format --check and mypy src/ scripts/ are clean.

The TypeScript Agent SDK has the same gap (it throws a plain Error on error results), but its source isn't in a public repo, so this PR covers Python only.

A run that fails because the CLI is not logged in ends with the same
result shape as a transient stream timeout: subtype "success",
terminal_reason "api_error" and no api_error_status. The only field that
tells them apart is the `error` of the preceding assistant message
("authentication_failed"), and ResultError never carried it. The
docstring's example (`if e.terminal_reason == "api_error": retry()`)
therefore retries a logged-out headless worker forever.

Track the `error` of the latest top-level assistant message in Query and
pass it to ResultError as `error`. Update the docstring example to branch
on `e.error`. The attribute survives pickle/copy like the others.
@kimnamu

kimnamu commented Nov 7, 2026

Copy link
Copy Markdown

Thanks for writing up the logged-out case so clearly, and for extending the pickle/copy tests to the new attribute. I ran this PR's head (8517258) against CLI 2.1.319 with Bedrock as the provider to check whether e.error also separates Bedrock failures, and it does. The one gap I hit is in the type the new attribute points to: the CLI sends error kinds that AssistantMessageError (types.py:1288 on main) doesn't list.

Case (CLI 2.1.319) api_error_status e.error on this PR
First-party, not logged in (your repro) None authentication_failed
Bedrock, malformed API key (HTTP 403) 403 authentication_failed, after 261 s of CLI retries
Bedrock, unknown model ID (HTTP 400) 400 model_not_found

model_not_found is one of 7 kinds that the CLI and the TypeScript SDK's SDKAssistantMessageError (@anthropic-ai/claude-agent-sdk 0.3.319) have and the Python Literal doesn't:

CLI 2.1.319 / TS 0.3.319: authentication_failed oauth_org_not_allowed account_on_hold verification_required billing_error rate_limit overloaded invalid_request model_not_found server_error unknown max_output_tokens cloud_credential_error
Python main (2209f2c):    authentication_failed billing_error rate_limit invalid_request server_error unknown

The value passes through at runtime, but under mypy --strict a branch on it is rejected:

check_kind.py:5: error: Non-overlapping equality check (left operand type: "Literal['authentication_failed', 'billing_error', 'rate_limit', 'invalid_request', 'server_error', 'unknown'] | None", right operand type: "Literal['model_not_found']")  [comparison-overlap]

Mirroring the TS list is one hunk, here or in a separate PR, whichever you and the maintainers prefer:

 AssistantMessageError = Literal[
     "authentication_failed",
+    "oauth_org_not_allowed",
+    "account_on_hold",
+    "verification_required",
     "billing_error",
     "rate_limit",
+    "overloaded",
     "invalid_request",
+    "model_not_found",
     "server_error",
     "unknown",
+    "max_output_tokens",
+    "cloud_credential_error",
 ]

With overloaded in the list, the docstring's transient branch may want it as well (("rate_limit", "overloaded", "server_error")). I may be missing a reason the Python list was kept shorter, so please treat this as a data point.

How I ran it
  • SDK main @ 2209f2c (0.2.179) and this PR @ 8517258; CLI 2.1.319 (same as the bundled version); us-east-1; us.anthropic.claude-haiku-4-5-20251001-v1:0.
  • Each case: clean environment, fresh CLAUDE_CONFIG_DIR, setting_sources=[], max_turns=1, tools=[], query(prompt="Reply with exactly OK.").
  • On main the unknown-model case raises ResultError with api_error_status=400 and no error attribute.
  • Malformed key: 12 SystemMessage(subtype="api_retry") frames, each with error_status: 403, error: "authentication_failed", from 3 s to 225 s, then the error result at 261 s. The retry loop is CLI-side.
  • Bedrock with no credentials at all never reaches a result: Control request timeout: initialize after 60 s as a bare Exception, which is Control-request failures raise a bare Exception rather than a ClaudeSDKError subclass #1304.

Output from this PR's head (retry frames trimmed):

## bedrock, unknown model id
  AssistantMessage error='model_not_found' text='API Error (us.anthropic.claude-does-not-exist-v1:0): 400 The provided model identifier is invalid..'
  ResultMessage subtype=success is_error=True api_error_status=400 terminal_reason=api_error
  -> raised ResultError: error='model_not_found' api_error_status=400 terminal_reason=api_error exit_code=1
## bedrock, invalid bearer token
  AssistantMessage error='authentication_failed' text='AWS authentication failed · refresh your AWS credentials (SSO sign-in, access keys, API key or proxy token) an'
  ResultMessage subtype=success is_error=True api_error_status=403 terminal_reason=api_error
  -> raised ResultError: error='authentication_failed' api_error_status=403 terminal_reason=api_error exit_code=1

The CLI sends "overloaded" as an assistant error kind, so the docstring's
retry branch should include it. Also note that ResultError.error is typed
as str, so kinds newer than AssistantMessageError pass through and can be
compared without a type error.
@BitmapAsset

Copy link
Copy Markdown
Author

Thanks @kimnamu, this is really useful. The Bedrock 403 case is exactly the one this PR is for, and it's good to see the kind holds up on a second provider, including after the CLI's 261 s of retries.

On the types: ResultError.error is annotated str | None, not AssistantMessageError | None, so a branch on it already type-checks for kinds the Literal doesn't list. With this PR's head under mypy --strict:

def on_result_error(e: ResultError) -> bool:
    return e.error == "model_not_found" or e.error in ("rate_limit", "overloaded", "server_error")  # passes

def on_assistant(m: AssistantMessage) -> bool:
    return m.error == "model_not_found"  # error: Non-overlapping equality check  [comparison-overlap]

So the error you hit comes from AssistantMessage.error, i.e. the Literal itself. That hunk is already open as #1369 (@v0ropaev): the same seven values, ordered to match sdk.d.ts, plus a test that pins the set. I checked it against @anthropic-ai/claude-agent-sdk 0.3.319 (SDKAssistantMessageError, sdk.d.ts:3831) and all 13 values match. To keep the two PRs from conflicting on the same lines, I'll leave the Literal to #1369.

The docstring was misleading on this point, since it described the attribute as "an AssistantMessageError value". I pushed d7ab489, which:

  • adds "overloaded" to the transient branch of the example, as you suggested;
  • says that error is typed as str, so kinds from newer CLI versions pass through as-is.

pytest tests/: 1644 passed, 6 skipped. ruff check, ruff format --check and mypy src/ scripts/ are clean.

This branch has not been deployed

No deployments
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.

2 participants