Claude Code のAPIエラーは subtype:success で返る

目次
  1. 測り方: 偽APIサーバーに向けて3経路を分ける
  2. APIエラーは全部 subtype:“success” で返る
  3. api_error_status が null になる側 — サーバーに届かない失敗
  4. error_max_turns と「JSON自体が出ない」エラー
  5. 一番危ないのは exit 0 の中断
  6. 判定は exit code ではなく is_error → api_error_status の順で見る

claude -p をスクリプトや CI に組み込んで最初に気づくのは、エラーの判定材料が見た目より少ないことだ。exit code は 0 と 1 の2値しかなく、stderr はエラーの本筋とは関係ない警告で埋まることがある。しかも実際に測ってみると、中断された実行が exit 0 で何も出さずに終わるケースまであった。

知りたいのは「Claude Code のエラー状況が、呼び出し側にどの信号で届くか」である。故障モードを localhost の偽 Anthropic API で再現し、exit code・stdout の JSON・stderr の3経路に分けて記録した。

測り方: 偽APIサーバーに向けて3経路を分ける

環境は Claude Code 2.1.267 / Node v22.21.1 / macOS 26.6.1。

$ sh -c 'claude --version; node --version; python3 -V; sw_vers | head -3'
2.1.267 (Claude Code)
v22.21.1
Python 3.14.7
ProductName:		macOS
ProductVersion:		26.6.1
BuildVersion:		25G76
# exit=0 (130 ms)

ANTHROPIC_BASE_URL=http://127.0.0.1:8799 に向けて claude -p を打つ。偽サーバーは Python の http.server で、シナリオ別に固定応答を返し、受けたリクエストを時刻・パス・メッセージ数・ボディのバイト数付きで記録する。認証は ANTHROPIC_API_KEY にダミー値を渡した(宛先が localhost なのでキーが外に出る経路は無い)。claude の stdout と stderr は別ファイルに分けてから表示する。

まず正常系の基準を取る。常に正常応答を返すシナリオで --output-format json を付けて流すと、こうなった。

$ sh run_case.sh ok --output-format json 'Reply with the word ok.'
fake-api scenario=ok port=8799 log=server-ok.log
CLAUDE_EXIT=0
=== stdout ===
...
=== stderr ===
Ignoring 96 permissions.allow entries from .claude/settings.local.json: this workspace has not been trusted. Run Claude Code interactively here once and accept the trust dialog, or set projects["<HOME>/Desktop/dev/ai-article-maker"].hasTrustDialogAccepted: true in <WORKDIR>/.claude-config/.claude.json.
=== server-ok.log ===
1	1790689071.240	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	nmsg=1 bytes=4079
  -> resp status=200
2	1790689071.281	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	nmsg=2 bytes=80172
  -> resp status=200
# exit=0 (1496 ms)

stdout の result JSON は1行で1507文字あるので省略した(verify/logs/032-analyze2.txt)。見るべきフィールドは次節で抜き出す。ここで先に確認しておきたいのは stderr の方で、正常終了なのにワークスペース信頼の警告が出ている。つまり「stderr が空かどうか」は成功判定に使えない。サーバー側ログを見ると、1回の claude -p で POST が2本飛ぶ(nmsg=1 と nmsg=2 のペアで、初回の投げと会話の継続に対応する)ことと、短い質問でも2回目のリクエストのボディが80172バイト(システムプロンプトとツール定義が毎回乗る)あることも分かる。

APIエラーは全部 subtype:“success” で返る

エラー系シナリオを流して各フィールドを抜き出したのがこれである。

$ sh -c 'for f in 002-ok-json 025-err-400-real 004-err-401-json 005-err-404-model 006-err-413-json 007-err-529-json 008-malformed-json 010-refused-json2 020-no-auth 014-maxturns2; do echo "== $f"; grep -m1 "^{" ../logs/$f.txt | jq -c "{subtype, is_error, has_status: has(\"api_error_status\"), api_error_status, terminal_reason, errors, result: (.result // \"\" | .[0:60])}"; done'
== 002-ok-json
{"subtype":"success","is_error":false,"has_status":true,"api_error_status":null,"terminal_reason":"completed","errors":null,"result":"fake-ok"}
== 025-err-400-real
{"subtype":"success","is_error":true,"has_status":true,"api_error_status":400,"terminal_reason":"api_error","errors":null,"result":"API Error: 400 input is malformed"}
== 004-err-401-json
{"subtype":"success","is_error":true,"has_status":true,"api_error_status":401,"terminal_reason":"api_error","errors":null,"result":"Invalid API key · Fix external API key"}
== 005-err-404-model
{"subtype":"success","is_error":true,"has_status":true,"api_error_status":404,"terminal_reason":"api_error","errors":null,"result":"There's an issue with the selected model (totally-fake-model"}
...
== 008-malformed-json
{"subtype":"success","is_error":true,"has_status":true,"api_error_status":null,"terminal_reason":"api_error","errors":null,"result":"API Error: API returned an empty or malformed response (HTTP"}
== 010-refused-json2
{"subtype":"success","is_error":true,"has_status":true,"api_error_status":null,"terminal_reason":"api_error","errors":null,"result":"API Error: Connection refused — a firewall or proxy may be b"}
== 020-no-auth
{"subtype":"success","is_error":true,"has_status":true,"api_error_status":null,"terminal_reason":"api_error","errors":null,"result":"Not logged in · Please run /login"}
== 014-maxturns2
{"subtype":"error_max_turns","is_error":true,"has_status":false,"api_error_status":null,"terminal_reason":"max_turns","errors":["Reached maximum number of turns (3)"],"result":""}
# exit=0 (26 ms)

API エラーはすべて subtype:"success" の形で返る。失敗したのに subtype だけ見ると成功に見える、という構造である。本来 subtype は「成功/最大ターン超過」といった終了種別を表すフィールドのはずが、API エラーだけ例外になっている。これは想像上の話ではなく実害があって、Claude Code を CI から呼ぶ GitHub Actions 用ラッパーが「subtype:success かつ is_error:true を成功と誤判定してワークフローを緑にする」バグを修正している(claude-agent-sdk-python #1031 と api_error_status を追加した PR #923)。あちらは stream-json の話だが、--output-format json の素の CLI でも同じ形が出ることをここで確認した。

合否を見るのは is_error で、HTTP ステータスを見るのは api_error_status である。細かい分類は terminal_reason に入り、413 は api_error ではなく image_error だった。エラーの本文メッセージは result に入る。stderr には出ない(404 のときだけ [claude-code:unrecognized_model] という診断行が stderr に出たが、API エラー本文ではない)。

もう1つ予想外だったのは 401 で、サーバー側ログを数えると 22 リクエスト・185.8秒粘ったあとに Invalid API key · Fix external API key で終わっている(verify/logs/026-analyze.txt と verify/logs/032-analyze2.txt)。

$ python3 analyze.py
002-ok-json.txt: exit=0 subtype=success is_error=False api_error_status=None terminal=completed req=2 stderr_extra=0 wall_ms=1294
    result=fake-ok
025-err-400-real.txt: exit=1 subtype=success is_error=True api_error_status=400 terminal=api_error req=3 stderr_extra=0 wall_ms=1139
    result=API Error: 400 input is malformed
...
004-err-401-json.txt: exit=1 subtype=success is_error=True api_error_status=401 terminal=api_error req=22 stderr_extra=0 wall_ms=186897
    result=Invalid API key · Fix external API key
...
014-maxturns2.txt: exit=1 subtype=error_max_turns is_error=True api_error_status=<absent> terminal=max_turns req=4 stderr_extra=0 wall_ms=60886
    result=
    errors=['Reached maximum number of turns (3)']
012-bad-resume.txt: exit=1 subtype=<no-json> is_error=- api_error_status=- terminal=- req=0 stderr_extra=1 wall_ms=1398
    result=-
    errors=-
    stderr: No conversation found with session ID: 00000000-0000-0000-0000-0000000
...
019-sigint.txt: exit=0 subtype=<no-json> is_error=- api_error_status=- terminal=- req=2 stderr_extra=0 wall_ms=4066
    result=-
    errors=-
# exit=0 (21 ms)
186897ms401認証エラー2707ms5002907ms5292483ms接続拒否1139ms4001140ms4041146ms413
claude -p が終了するまでの時間(500・接続拒否は CLAUDE_CODE_MAX_RETRIES=2 で短縮済み)

「Invalid API key」と言い切る前に3分粘るのは、キー切れで動かした無人ジョブでは痛い挙動である。なお 500 と接続拒否の計測だけは CLAUDE_CODE_MAX_RETRIES=2 を付けて短縮してある(ログのコマンド行のとおり)。既定値での粘り方はリトライ実測の記事側を見てほしい。リトライ回数そのものは当ブログのリトライ実測の記事で種別ごとに測っている。

api_error_status が null になる側 — サーバーに届かない失敗

api_error_status が null のまま失敗するケースが3つあった。接続拒否、HTTP 200 なのに中身が壊れた応答、そして認証情報なしだ。

$ env CLAUDE_CODE_MAX_RETRIES=2 ANTHROPIC_BASE_URL=http://127.0.0.1:9 sh run_case.sh noserver --output-format json 'Reply with the word ok.'
CLAUDE_EXIT=1
=== stdout ===
...
=== stderr ===
...
=== server-noserver.log ===
(no server log)
# exit=1 (2483 ms)

接続拒否が null になるのは自然だが、注目は未ログインの方だ。CLAUDE_CONFIG_DIR を空のディレクトリにして API キーも渡さず実行すると、結果は Not logged in · Please run /login で terminal_reason は api_error —— ところがサーバーのログは空で、リクエストを一度も発行していない(verify/logs/020-no-auth.txt)。ローカルで即死した失敗が、呼び出し側からは「API エラー」の形に見える。つまり api_error_status:null は「サーバーに届かなかった全般」を意味し、HTTP エラーとの切り分けは値の有無でできるが、届かなかった理由の切り分けは result の先頭を読むしかない。

壊れた応答(HTTP 200 で JSON でない SSE を返す)も同じく null で、result には “API returned an empty or malformed response (HTTP 200)” と詳しい診断が入り、stderr にパース警告が4行出た(verify/logs/008-malformed-json.txt)。

ついでに大きな入力の行方も見た。1.15MiB のテキストを stdin から流したところ、クライアント側では弾かれず、ボディ1,204,055バイトのリクエストがそのままサーバーに届いた(verify/logs/028-huge-stdin2.txt / 032-analyze2.txt)。「プロンプトが大きすぎる」系のエラーは、少なくともこのサイズではサーバー側の仕事である。

error_max_turns と「JSON自体が出ない」エラー

subtype が success でない失敗もあった。偽サーバーが tool_use を返し続けるシナリオに --max-turns 3 --allowedTools Bash を付けて流すと、予定どおり打ち切られた。

$ env KILL_AFTER=60 sh run_case.sh tool-loop 'echo hello' --output-format json --max-turns 3 --allowedTools Bash
fake-api scenario=tool-loop port=8799 log=server-tool-loop.log
CLAUDE_EXIT=1
=== stdout ===
...
=== stderr ===
Ignoring 96 permissions.allow entries from .claude/settings.local.json: this workspace has not been trusted. Run Claude Code interactively here once and accept the trust dialog, or set projects["<HOME>/Desktop/dev/ai-article-maker"].hasTrustDialogAccepted: true in <WORKDIR>/.claude-config/.claude.json.
=== server-tool-loop.log ===
1	1790687871.352	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	nmsg=1
...
4	1790687871.443	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	nmsg=5
# exit=1 (60886 ms)

このケースだけ subtype:"error_max_turns" になり、メッセージは result ではなく errors 配列(["Reached maximum number of turns (3)"])に入った。api_error_status は値が null ではなくキー自体が無い(先の抽出で has_status:false。APIエラー系や成功時はキーがあって null)。同じ失敗でもフィールドの出方が揃っていない。

さらに奇妙なのは所要時間で、result の JSON は出ているのにプロセスが戻ってこない。60秒の watchdog が SIGTERM を送って終わらせた形だ。--max-turns 2 で120秒、stdin を /dev/null に閉じて30秒でも同じで、3回とも result 出力後にハーネスが止めるまで終了しなかった(verify/logs/017-maxturns-natural.txt / 018-maxturns-stdin-closed.txt)。原因は切り分けていないが、少なくとも stdin 待ちではない。tool_use を含む実行が max_turns で止まったとき、呼び出し側にタイムアウトが無いと張り付く可能性がある。

一方、CLI 側のエラーは JSON が一切出ない別の種類だった。存在しないセッション ID で --resume、存在しないファイルで --mcp-config、不正な値で --output-format を渡すと、いずれも stdout は空で、stderr に1〜2行出て exit 1。

$ sh run_case.sh ok --output-format json --resume 00000000-0000-0000-0000-000000000000 'Reply ok'
fake-api scenario=ok port=8799 log=server-ok.log
CLAUDE_EXIT=1
=== stdout ===
=== stderr ===
Ignoring 96 permissions.allow entries from .claude/settings.local.json: this workspace has not been trusted. Run Claude Code interactively here once and accept the trust dialog, or set projects["<HOME>/Desktop/dev/ai-article-maker"].hasTrustDialogAccepted: true in <WORKDIR>/.claude-config/.claude.json.
No conversation found with session ID: 00000000-0000-0000-0000-000000000000
=== server-ok.log ===
# exit=1 (1398 ms)

つまり stdout の JSON を jq に食わせるスクリプトは、「JSON が来ない」ケースを別枠で処理しないと空入力でパースエラーになる。

なお測定中に自分も1回やらかしていて、--mcp-config ./no-such-mcp.json の直後にプロンプトを置いたら、プロンプトが2個目の設定ファイル名として解釈されて MCP config file not found: <WORKDIR>/Reply ok と出た(verify/logs/013-bad-mcp.txt)。複数値を取るフラグは直後の位置引数を食う。--allowedTools で同じ罠が報告されていたが、--mcp-config も同型だった。

一番危ないのは exit 0 の中断

応答を返さないサーバーに向けて3秒後に kill -INT(Ctrl-C 相当)を送ると、こうなった。

$ env INT_AFTER=3 sh run_case.sh hang 'Reply with the word ok.' --output-format json
fake-api scenario=hang port=8799 log=server-hang.log
CLAUDE_EXIT=0
=== stdout ===
=== stderr ===
Ignoring 96 permissions.allow entries from .claude/settings.local.json: this workspace has not been trusted. Run Claude Code interactively here once and accept the trust dialog, or set projects["<HOME>/Desktop/dev/ai-article-maker"].hasTrustDialogAccepted: true in <WORKDIR>/.claude-config/.claude.json.
=== server-hang.log ===
1	1790688210.341	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	nmsg=1
2	1790688210.371	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	nmsg=2
# exit=0 (4066 ms)

exit 0、stdout 空、result JSON なし、エラーメッセージなし。サーバーにはリクエストが2本届いたまま途中で終わった。中断はexit 0で返るので、シェルの exit code だけを見る監視には「静かな成功」に見える。同じハング条件に SIGTERM を送ると exit 143 になった(verify/logs/027-sigterm-hang.txt)。timeout コマンドは既定で TERM を送るので、そのまま使えば 143 として検知できる。危ないのは -s INT を指定した場合や、手動の Ctrl-C に相当する割り込みで、そちらは exit 0 として検知をすり抜ける。ログに「何も出なかった」のか「途中で殺された」のかを残すなら、呼び出し側が自分で時刻と終了理由を書き残すしかない。

判定は exit code ではなく is_error → api_error_status の順で見る

今回の実測から逆算した判定手順はこうなる。

  1. exit code を見る。ただし 0 でも信用しない。 SIGINT での中断が exit 0・出力なしで返る。stdout が空なら中断か起動前エラーを疑う
  2. stdout に result JSON が来なければ CLI 側のエラー。 --resume・--mcp-config・--output-format など引数・設定系は stderr だけに出る
  3. JSON が来たら is_error を見る。subtype は見ない。 API エラーはすべて subtype:"success" + is_error:true で返る。errors 配列が埋まるのは max_turns のような実行側の打ち切りで、そのとき result は空になる
  4. 種類は api_error_status と terminal_reason で分ける。 HTTP エラーなら番号が入る。null のまま api_error なら接続拒否・壊れた応答・未ログインのいずれかで、切り分けは result の先頭(“API Error: Connection refused” / “Not logged in” 等)を見る
  5. 成果物を最後に見る。 これは今回測っていないが、権限拒否で何も作られなくても exit 0 が返ることは当ブログの claude -p の落とし穴の記事で確かめている

限界も書く。測ったのは 2.1.267 の claude -p を API キー認証で偽サーバーに向けた挙動で、対話モード・OAuth ログイン・本物の Anthropic 応答では違う可能性がある。api_error_status フィールド自体が v2.1.110 以降のものなので古いバージョンには無い。max_turns 後にプロセスが戻らない件は、私の環境と偽サーバーの組み合わせに起因する可能性も残る。

逆に言うと、ここまで揃えても「exit 0・stdout 空」を「成功だが無言」と「中断された」のどちらとも区別する手段は出力側に無い。無人ジョブでは外部側にも成否の手がかり(成果物の存在、タイムアウトの記録)を残すしかない。

それでも、故障モードを機械的に分けたいなら「exit code と stderr を信じる」のではなく「result JSON の is_error と api_error_status を読み、JSON が来ないケースと exit 0 で空のケースを別扱いする」が、この環境で確かめられた最小構成である。

関連書籍