[BUG] Headless `claude -p`: API errors (e.g. rate limit) return `subtype:"success"` + exit code 0 — error text only in `result`/stdout

Status Open
Maintainer reply None cached
Activity 1 comment · opened Jul 20, 2026

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": true in the same result object; the only truthful failure detail is the prose string in result ("API Error: Rate limit reached"). stop_reason is "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_KEY in 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

  1. Get any model into a state where the API rejects the request (rate limit is the easy one).
  2. printf 'hi' | claude -p --output-format json --model <limited-model>; echo $?
  3. Observe subtype:"success", is_error:true, exit 0.
  4. printf 'hi' | claude -p --model <limited-model> 1>/dev/null; echo $? — observe the error vanishes (it was on stdout) and exit is still 0.

Expected

  • subtype reflects the failure (e.g. "error_api" / "error_rate_limit"), consistent with is_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_reason should not claim stop_sequence for 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 dontAsk denied 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).

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗