Claude Code「エラーが発生しました」の裏側を実測した — 500は3分粘り、529は3秒で諦める

目次
  1. 測り方: localhost に偽の Anthropic API を立てる
  2. 実測: エラー種別で全然違う諦め方
  3. 意外な点: 529 overloaded は3秒で諦める
  4. 制御できること: 環境変数の実効
  5. 「待つべきか」の判断基準

「エラーが発生しました」が出たとき、私が知りたいのは原因の一覧ではなく、いま自分は待つべきか、叩き直すべきかである。この画面が出るまでに Claude Code が裏でどれだけ粘ったのかを知らないままだと、その判断は勘に頼るしかない。待つ意味があるのは「まだリトライ中」のときだけで、「もう全滅したあと」の表示を眺めて待つのは無駄である。

そこで Anthropic API のふりをするローカルサーバーを立て、claude -p をそこに向けて、エラー種別ごとの実挙動を数えた。

測り方: localhost に偽の Anthropic API を立てる

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

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

やったことは単純で、ANTHROPIC_BASE_URL=http://127.0.0.1:8799 に向けて claude -p "Reply with the word ok." を打つだけである。偽サーバーは Python の http.server で、シナリオ別に固定のエラー応答を返し、受けたリクエストを 時刻・パス・stream有無・model 付きで記録する。認証は ANTHROPIC_API_KEY にダミー値を渡した(宛先が localhost なので、キーが外に出る経路は無い)。

まず配線確認として、常に正常応答を返すシナリオを流した。

$ ./run_case.sh ok
fake-api scenario=ok port=8799 log=server-ok.log
...
fake-ok
CLAUDE_EXIT=0
=== server-ok.log ===
1	1790600699.957	POST	/v1/messages?beta=true	stream=1
2	1790600699.983	POST	/v1/messages?beta=true	stream=1
# exit=0 (1628 ms)

返事として偽サーバーの固定文字列 fake-ok がそのまま出たので、以後の計測はすべて「サーバー側のログ行数 = Claude Code が実際に発行した HTTP リクエスト数」として読める。1回の claude -p で POST が2本飛ぶ(後のログでは nmsg=1 と nmsg=2 のペアになっている)こともこの時点で分かる。

実測: エラー種別で全然違う諦め方

全部のシナリオを並べると、一様ではないことが見える。

$ python3 analyze2.py
../logs/002-smoke.txt [server-ok.log]: requests=2 span=0.0s
../logs/003-err-500.txt [server-err-500.log]: requests=21 span=172.3s
   gaps>=0.3s: 0.6 1.2 2.2 4.2 9.6 0.3 18.0 0.7 33.7 3.7 33.4 1.9 30.6 8.3 23.9
../logs/004-err-400.txt [server-err-400.log]: requests=3 span=0.0s
../logs/005-recover.txt [server-recover.log]: requests=4 span=0.6s
   gaps>=0.3s: 0.5
../logs/006-err-429.txt [server-err-429.log]: requests=22 span=184.0s
   gaps>=0.3s: 0.6 1.1 2.3 4.5 8.7 1.1 18.1 0.8 32.1 1.4 31.4 7.0 31.0 6.8 28.0 8.7
../logs/007-max-retries.txt [server-err-500.log]: requests=6 span=1.7s
   gaps>=0.3s: 0.5 1.1
../logs/008-err-529.txt [server-err-529.log]: requests=4 span=1.7s
   gaps>=0.3s: 0.6 1.1
../logs/009-stall.txt [server-stall.log]: requests=24 span=181.9s
   gaps>=0.3s: 0.5 1.1 2.0 4.8 8.7 1.2 14.9 1.4 34.6 1.5 34.3 4.1 32.5 6.2 33.6
../logs/010-hang-timeout.txt [server-hang.log]: requests=6 span=31.8s
   gaps>=0.3s: 15.5 16.1
../logs/011-err-529-hdr.txt [server-err-529.log]: requests=4 span=2.3s
   gaps>=0.3s: 1.0 1.2
../logs/012-watchdog-529.txt [server-err-529.log]: requests=15 span=73.6s
   gaps>=0.3s: 0.5 1.0 2.2 4.7 8.6 0.8 18.3 0.7 36.4
# exit=0 (17 ms)
21requests50022requests42924requests接続断4requests5293requests400
エラーを表示するまでに発行されたHTTPリクエスト数

500 Internal server error を返し続けた場合、最終的に claude -p が出したのはこのメッセージで、終了コードは 1 だった。

$ ./run_case.sh err-500
fake-api scenario=err-500 port=8799 log=server-err-500.log
...
API Error: 500 Internal server error. This is a server-side issue, usually temporary — try again in a moment. If it persists, check your inference gateway (127.0.0.1:8799).
CLAUDE_EXIT=1
...
# exit=1 (173462 ms)

その裏でサーバーは 21 本の POST を受けていて、最初から最後まで 172.3 秒かかっている。リクエスト間隔を見ると 0.6 秒 → 1.2 秒 → 2.2 秒 → 4.2 秒 → 9.6 秒 → 18.0 秒 → 33.7 秒 …と指数関数的に伸び、33.7 秒あたりで頭打ちになる(verify/logs/015-analyze2.txt の gaps 行)。つまり「API Error: 500」が画面に出た時点で、Claude Code は裏で約3分、指数バックオフのリトライをやり切ったあとである。

429 rate_limit_error もほぼ同じ挙動で、22 本・184.0 秒かけてから API Error: Request rejected (429) と出た(verify/logs/006-err-429.txt)。レスポンスの途中で接続を切るケース(SSE のイベント途中で RST)も同じく粘り、24 本・181.9 秒ののち API Error: Connection to the API was lost (ECONNRESET) になった(verify/logs/009-stall.txt)。このログの末尾には stream=0 のリクエストが混ざっていて、最後の試行では非ストリーミングのフォールバックも試しているのが読み取れる。

対照的に速いのが 400 invalid_request_error で、3 本をほぼ同時に撃って即終了した。

$ ./run_case.sh err-400
fake-api scenario=err-400 port=8799 log=server-err-400.log
...
API Error: 400 input is malformed
CLAUDE_EXIT=1
=== server-err-400.log ===
1	1790600927.884	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5
2	1790600927.918	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5
3	1790600927.919	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5
# exit=1 (1130 ms)

タイムスタンプは 1790600927.884 から 1790600927.919 までで、3本全部が1秒未満に収まっている。リクエスト自体を直さないと直らない種類のエラーは、そもそもリトライしない設計だと分かる。

意外な点: 529 overloaded は3秒で諦める

500 と 429 が3分粘るのに、529 overloaded_error は全く違う振る舞いをした。4 本しかリクエストせず、1.7 秒で終了する。

$ ./run_case.sh err-529
fake-api scenario=err-529 port=8799 log=server-err-529.log
...
API Error: Repeated 529 Overloaded errors. The API is at capacity — this is usually temporary. Try again in a moment. If it persists, check your inference gateway (127.0.0.1:8799).
CLAUDE_EXIT=1
=== server-err-529.log ===
1	1790601224.436	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=1
2	1790601224.470	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=2
3	1790601225.065	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=2
4	1790601226.178	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=2
# exit=1 (2735 ms)

「Repeated 529」と言いつつ、実際には2往復しかしていない。本家 API の 529 応答に何か特別なヘッダが載っていてリトライを促しているのかもしれないと考え、retry-after: 1 と x-should-retry: true を付けて返すシナリオも試したが、結果は変わらず 4 本・2.3 秒だった(verify/logs/011-err-529-hdr.txt)。少なくともこのバージョン・この認証形態では、529 は「粘らない」側に分類されている。

正直に言うと、これは公式ドキュメントの記述(一時的な障害は指数バックオフで最大10回リトライ、とエラーリファレンスにある)と食い違って見える。ドキュメントは claude.ai サブスクリプションでのサインインを前提にした記述が混じるので、APIキー認証との違い、あるいは本物の 529 応答との違いかもしれない。確かめられるのは手元の偽サーバーに対する挙動までなので、「同じ“一時的エラー”でも 529 は別の経路を通る」という観測事実として書く。

制御できること: 環境変数の実効

ここまでは既定値の話である。待ち時間を変えられる環境変数も実際に試した。

CLAUDE_CODE_MAX_RETRIES=2 を付けて同じ 500 シナリオを流すと、リクエストは 6 本・1.7 秒で止まった(verify/logs/007-max-retries.txt)。既定では 21 本・172.3 秒だったので、CI やスクリプトで「さっさと失敗してほしい」場合にこの変数は効く。

サーバーが応答を一切返さないハングシナリオには API_TIMEOUT_MS=15000 を付けた。リクエスト間隔が 15.5 秒と 16.1 秒になっていて、タイムアウト値がそのまま1試行の待ち時間として効いている。

$ ./run_case.sh hang API_TIMEOUT_MS=15000 CLAUDE_CODE_MAX_RETRIES=2
fake-api scenario=hang port=8799 log=server-hang.log
...
Request timed out
CLAUDE_EXIT=1
=== server-hang.log ===
1	1790601439.748	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=1
2	1790601439.772	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=2
3	1790601455.313	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=2
4	1790601455.362	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=1
5	1790601471.509	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=2
6	1790601471.557	POST	/v1/messages?beta=true	stream=1	model=claude-opus-5	max_tokens=64000	nmsg=1
# exit=1 (47965 ms)

そして速攻で諦めた 529 について、CLAUDE_CODE_RETRY_WATCHDOG=1 を試した。75 秒で強制終了させたが、その間に 15 本のリクエストが指数バックオフで伸び続けていた(verify/logs/012-watchdog-529.txt)。既定では 2 往復で諦める 529 が、この変数ひとつで「粘る側」に変わる。無人ジョブで容量系のエラーを待ち続けたい場合に、そのまま使える挙動だった。

なお、リトライが成功した場合、ユーザー側からは一切見えない。最初の2本だけ 500 を返して以後は正常応答するシナリオでは、0.6 秒のうちに4本のリクエストが流れ、出力は普通に fake-ok、終了コード 0 だった(verify/logs/005-recover.txt)。一時的な失敗は表示すらされずに吸収されている。

「待つべきか」の判断基準

測ったことから逆算すると、エラー表示を見た時点での打ち手は種類で分かれる。

500・429・接続断が表示されたときは、もう諦めたあとだ。 Claude Code は表示までに 20 本超のリクエストと 172.3〜184.0 秒の指数バックオフをやり切っている。その場で待ち続けても何も起きないので、再実行するか、繰り返すならゲートウェイ・ステータスを見に行く番である。メッセージが check your inference gateway (127.0.0.1:8799) と宛先を名指ししてくれるので、本家 API ならステータスページ、自前ゲートウェイならその運用者を疑えばよい。

529は2往復で諦める。 こちらの環境では4リクエスト・1.7秒で終了するので、表示が出てもほぼ即再実行の価値がある。容量エラーなので相手が回復していれば次で通るし、無人処理なら CLAUDE_CODE_RETRY_WATCHDOG=1 で粘らせる選択肢もある。「Repeated 529」という文言に騙されて長時間待つ必要はない。

400・接続拒否は自分側を疑う。 400 はリトライなしの即死で、リクエストの中身が悪い。接続拒否は a firewall or proxy may be blocking it (ConnectionRefused) と出て、プロキシ設定や宛先の問題である(verify/logs/013-refused.txt)。この2つを「サーバー障害」と読み違えて待つのが一番の無駄である。

限界も書いておく。測ったのは 2.1.267 の claude -p を ANTHROPIC_API_KEY 認証で偽サーバーに向けた挙動で、claude.ai サブスクリプションの OAuth サインインや、本物の Anthropic 応答(特に 529)では振る舞いが違う可能性がある。ただ、「エラー表示の裏で何回撃ち直したか」をサーバー側で数えるという手法自体は、どの環境でも同じように再現できる。気になる人は ANTHROPIC_BASE_URL を localhost に向けて数えればよい。それが分かれば、「エラーが発生しました」の1行は、怖いメッセージではなく「リトライ済み回数の報告書」として読める。

関連書籍