feat: surface api_error_status on ResultMessage#923
Conversation
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>
There was a problem hiding this comment.
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.
E2E Test ResultsLive test against the real CLI. Setup: the happy path uses a normal request; the error path passes """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: For comparison, the raw {"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 Summary: 🤖 Generated with Claude Code |
Summary
Adds
api_error_status: int | NonetoResultMessageand wires it through the message parser.The Claude Code CLI emits
api_error_status: number | nullon the finalresultmessage in stream-json output since v2.1.110. It is the HTTP status code (e.g. 429, 500, 529) of the failing API call whenis_error=trueandsubtype="success", andnullotherwise.This matters because
subtype: "success"+is_error: trueis the only signal of an API failure on the result message, andapi_error_statusis the only safe-to-log field for classifying it (it is strictlyint | None— no message content). Previously the parser silently dropped the field.This parallels the earlier
errorsfield addition toResultMessage.Changes
types.py: addapi_error_status: int | None = NonetoResultMessage_internal/message_parser.py: passdata.get("api_error_status")through to the constructortests/test_message_parser.py: test that529is surfaced and absence yieldsNone🤖 Generated with Claude Code