[BUG] Headless `claude -p`: API errors (e.g. rate limit) return `subtype:"success"` + exit code 0 — error text only in `result`/stdout
Summary
When the underlying API request fails (observed with a per-model rate limit), headless claude -p reports the failure inconsistently across every signal a script can key on:
--output-format json:"subtype": "success"and"is_error": truein the same result object; the only truthful failure detail is the prose string inresult("API Error: Rate limit reached").stop_reasonis"stop_sequence", which is also misleading (no sequence was hit — the request was rejected before inference;duration_api_ms: 0, zero tokens).- Plain mode: the error is printed to stdout (not stderr) and the process exits 0.
Consumers following the documented result envelope (subtype as the outcome discriminator) or standard Unix conventions (exit code, stderr) conclude the run succeeded. Pipelines then treat the literal string API Error: Rate limit reached as the model's output.
Environment
- Claude Code v2.1.49 (native install), macOS Darwin 25.x, Apple Silicon
- Logged-in OAuth session (no
ANTHROPIC_API_KEYin env) - Trigger:
--model <limited-model>while that model's requests were being rate-limited; identical invocations with other models succeed, so the shape below is specifically the CLI's rendering of an API-level rejection
Evidence
# JSON mode: subtype says success, is_error says failure, exit code says success
$ printf 'Say only: ok' | claude -p --output-format json --model <limited-model>
{"type":"result","subtype":"success","is_error":true,
"result":"API Error: Rate limit reached","stop_reason":"stop_sequence",
"duration_ms":1219,"duration_api_ms":0,"total_cost_usd":0,
"usage":{"input_tokens":0,"output_tokens":0,...},"modelUsage":{},...}
$ echo $?
0
# Plain mode: the error goes to STDOUT (not stderr), exit code 0
$ printf 'Say only: ok' | claude -p --model <limited-model>
API Error: Rate limit reached
$ echo $?
0
Repro
- Get any model into a state where the API rejects the request (rate limit is the easy one).
printf 'hi' | claude -p --output-format json --model <limited-model>; echo $?- Observe
subtype:"success",is_error:true, exit0. printf 'hi' | claude -p --model <limited-model> 1>/dev/null; echo $?— observe the error vanishes (it was on stdout) and exit is still0.
Expected
subtypereflects the failure (e.g."error_api"/"error_rate_limit"), consistent withis_error.- Non-zero exit code for runs that produced no model output due to an API error.
- Plain-mode error text on stderr, with a machine-readable reset hint if available.
stop_reasonshould not claimstop_sequencefor a request rejected before inference.
Actual
subtype:"success", exit 0, error prose on stdout in both modes; failure is detectable only via is_error (JSON mode) or string-matching stdout.
Impact
Any wrapper, CI job, or native-messaging bridge that trusts exit codes or subtype silently ships an API error string as if it were model output. In a native-messaging bridge we maintain, this surfaced to end users as a "success" popup containing no result.
Related (not duplicates)
- #76867 — headless
dontAskdenied tool → exit 0,is_error:false, subtype"success" - #74761 — exit 0 while the agent loop is mid-task
Same "headless outcome misreported" family; this report has a distinct trigger (API-level error) and a distinct contradiction (is_error:true and subtype:"success" and exit 0).
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗