Skip to content

feat: surface api_error_status on ResultMessage#923

Merged
qing-ant merged 1 commit intomainfrom
qing/add-api-error-status
May 6, 2026
Merged

feat: surface api_error_status on ResultMessage#923
qing-ant merged 1 commit intomainfrom
qing/add-api-error-status

Conversation

@qing-ant
Copy link
Copy Markdown
Contributor

@qing-ant qing-ant commented May 6, 2026

Summary

Adds api_error_status: int | None to ResultMessage and wires it through the message parser.

The Claude Code CLI emits api_error_status: number | null on the final result message in stream-json output since v2.1.110. It is the HTTP status code (e.g. 429, 500, 529) of the failing API call when is_error=true and subtype="success", and null otherwise.

This matters because subtype: "success" + is_error: true is the only signal of an API failure on the result message, and api_error_status is the only safe-to-log field for classifying it (it is strictly int | None — no message content). Previously the parser silently dropped the field.

This parallels the earlier errors field addition to ResultMessage.

Changes

  • types.py: add api_error_status: int | None = None to ResultMessage
  • _internal/message_parser.py: pass data.get("api_error_status") through to the constructor
  • tests/test_message_parser.py: test that 529 is surfaced and absence yields None

🤖 Generated with Claude Code

The CLI emits api_error_status: number | null on the final result
message in stream-json output since v2.1.110. It is the HTTP status
code (e.g. 429, 500, 529) of the failing API call when is_error=true
and subtype="success", null otherwise. Previously the Python SDK
silently dropped this field. Parallels the earlier errors field
addition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward optional-field passthrough following the existing errors pattern.

Extended reasoning...

Overview

Adds a single optional field api_error_status: int | None = None to the ResultMessage dataclass (types.py), wires it through with data.get("api_error_status") in the result-message branch of parse_message (message_parser.py), and adds two test assertions (one new test for a 529 value, one line in the existing optional-fields-absent test). Net change: 1 line of parser logic, 1 dataclass field + a 3-line comment, ~25 lines of tests.

Security risks

None. This is a pure read-side passthrough of an integer (or None) from already-trusted CLI JSON output into a dataclass field. No new inputs, no auth/crypto/permissions, no string content, no execution paths affected.

Level of scrutiny

Low. The change is mechanical and exactly mirrors the established pattern used for the adjacent errors, uuid, model_usage, permission_denials, etc. fields on ResultMessage — optional field with a None default, populated via data.get(...). The new field is appended after the last existing optional field and before uuid (both defaulted), so dataclass field ordering is preserved and existing keyword-arg construction in the parser (the only ResultMessage(...) call site in src/) is unaffected. Backward compatible with older CLI versions that omit the key.

Other factors

  • No CODEOWNERS file in the repo.
  • Bug-hunting system found no issues.
  • Tests cover both presence (529) and absence (None) of the field.
  • No prior reviews or outstanding comments on the PR timeline.

@qing-ant
Copy link
Copy Markdown
Contributor Author

qing-ant commented May 6, 2026

E2E Test Results

Live test against the real CLI.

Setup: the happy path uses a normal request; the error path passes model="claude-nonexistent-9-9999", which the API rejects with a 400, so the CLI emits is_error=True, subtype="success" with api_error_status=400 on the final result message.

"""E2E proof for PR #923: ResultMessage.api_error_status

Two scenarios against the real CLI + API:
  1. Happy path: valid request -> api_error_status is None
  2. Error path: model that the API rejects with a 400 -> CLI emits
     is_error=True result with api_error_status=400.

Note: when the CLI ends a turn with is_error=True it exits with code 1, so
the SDK transport raises ProcessError after the ResultMessage is yielded.
We collect the ResultMessage first and tolerate the trailing error.
"""

import anyio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage


async def run(label: str, options: ClaudeAgentOptions) -> ResultMessage:
    result = None
    try:
        async for msg in query(prompt="Reply with the single word: ok", options=options):
            if isinstance(msg, ResultMessage):
                result = msg
    except Exception as e:
        # CLI exits non-zero when is_error=True; the result was already yielded.
        if result is None:
            raise
        print(f"[{label}] (CLI exited non-zero after result: {e})")
    assert result is not None, f"[{label}] no ResultMessage received"
    print(f"[{label}] subtype={result.subtype!r} is_error={result.is_error} "
          f"api_error_status={result.api_error_status!r}")
    return result


async def main() -> None:
    with anyio.fail_after(180):
        # 1) Happy path
        ok = await run("happy", ClaudeAgentOptions(max_turns=1))
        assert ok.is_error is False
        assert ok.api_error_status is None, ok.api_error_status

        # 2) Error path: model the API rejects -> 400 on the final call
        bad = await run(
            "error",
            ClaudeAgentOptions(max_turns=1, model="claude-nonexistent-9-9999"),
        )
        assert bad.is_error is True
        assert isinstance(bad.api_error_status, int), (
            f"expected int, got {bad.api_error_status!r}"
        )
        assert 400 <= bad.api_error_status < 600, bad.api_error_status

    print("\nPASS: api_error_status is None on success, an HTTP int on API failure.")


anyio.run(main)

Output:

Fatal error in message reader: Command failed with exit code 1 (exit code: 1)
Error output: Check stderr output for details
[happy] subtype='success' is_error=False api_error_status=None
[error] (CLI exited non-zero after result: Command failed with exit code 1 (exit code: 1)
Error output: Check stderr output for details)
[error] subtype='success' is_error=True api_error_status=400

PASS: api_error_status is None on success, an HTTP int on API failure.

For comparison, the raw result line emitted by the CLI for the error case:

{"type":"result","subtype":"success","is_error":true,"api_error_status":400,"duration_ms":313,"duration_api_ms":0,"num_turns":1,"result":"API Error: 400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"adaptive thinking is not supported on this model\"},\"request_id\":\"req_011Can2sS93JwPeMHzHkv9Tj\"}", ...}

And on main (3c08a3a), the field doesn't exist on the dataclass:

$ python -c "from claude_agent_sdk import ResultMessage; import dataclasses; \
    print('api_error_status' in [f.name for f in dataclasses.fields(ResultMessage)])"
False

Summary: ResultMessage.api_error_status is None on success and surfaces the failing API call's HTTP status (400) when is_error=True.

🤖 Generated with Claude Code

@qing-ant qing-ant merged commit b80d244 into main May 6, 2026
12 checks passed
@qing-ant qing-ant deleted the qing/add-api-error-status branch May 6, 2026 21:57
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