Claude Codeのcurlスクリプトにリトライと時間切れを足す手順
素のcurlスクリプトは、遅い応答で固まり、4xx・5xxでも成功扱いで終わります。--retry・--max-time・--fail-with-bodyの効き方を実測し、Claude Codeに書かせる決め方も示します。
Claude Codeに「このAPIを叩くシェルスクリプトを書いて」と頼むと、出てくるのはcurl -s URLにヘッダーを足しただけの1行になりがちです。動きはしますが、サーバーが応答を返さなければ止まり続け、404や503が返っても終了コードは0です。呼び出し側のスクリプトやCIは、失敗に気づけません。
堅牢にする部品は、curl側に4つあります。--fail-with-bodyは失敗を終了コードに出し、--max-timeは1回の待ちに上限を付け、--retryは一時的な失敗をやり直し、--outputは本文の行き先を分けます。この記事では、それぞれの効き方を手元のcurlで試した結果とあわせて示し、Claude Codeに書かせて検証させる手順までつなげます。
4つのフラグは何を受け持つか
先に役割を並べます。
堅牢化に使う4つの部品
--fail-with-body
HTTPステータスが400以上のとき、本文は出力したまま終了コード22で終わります。
--max-time
1回の転送に許す秒数の上限です。超えると終了コード28になります。
--retry
一時的な失敗を指定回数までやり直します。既定は0回です。
--output
本文をファイルに書きます。ステータスは
--write-outで別に受け取れます。
curlは既定でHTTPのステータスコードを失敗とみなしません。404が完全に返ってきても、転送としては成功なので終了コードは0です。ここを変えるのが--fail系のオプションで、--fail-with-bodyは7.76.0で追加されました。
--failと--fail-with-bodyの違いを実測する
どちらも400以上で終了コード22を返します。差は、サーバーが返したエラー本文を残すかどうかです。ローカルの簡易サーバーに400を返させ、curl 8.7.1で両方を試しました。
curl -sS --fail-with-body -o b.json -w '%{response_code}' http://127.0.0.1:8765/bad
# 終了コード 22 / ステータス 400 / b.json に {"error":"x"}
curl -sS --fail -o b.json -w '%{response_code}' http://127.0.0.1:8765/bad
# 終了コード 22 / ステータス 400 / b.json は作られないどちらも標準エラーにはcurl: (22) The requested URL returned error: 400が出ます。--failではb.jsonが作られず、--fail-with-bodyでは{"error":"x"}が残りました。APIのエラー本文には、どのフィールドが不正だったかが入っていることが多く、原因の切り分けに使えます。スクリプトでは--fail-with-bodyのほうが調べやすい形です。
2つは同時に指定できません。マニュアルは相互排他としています。--failにも穴があり、認証まわりの401と407では失敗扱いにならずにすり抜けることがあると書かれています。認証エラーを必ず拾いたいスクリプトでは、終了コードだけに頼らず、--write-outで受け取ったステータスも見ます。
時間の上限は2種類ある
待ちの上限には、段階の違う2つのオプションがあります。
| オプション | 制限する範囲 | 超えたとき |
|---|---|---|
--connect-timeout | 制限する範囲接続の確立まで(DNS解決とTCP・TLSのハンドシェイクが終わるまで) | 超えたとき接続が終わらなければ終了 |
--max-time(-m) | 制限する範囲1回の転送の全体 | 超えたとき終了コード28 |
--connect-timeoutは接続できてしまえば効きません。サーバーには繋がったのに応答本文がいつまでも来ない状況は、--max-timeでしか止められません。両方を指定するのが基本です。
--max-timeを1秒にして、5秒待たせるエンドポイントを叩くと、次のようになりました。
curl -sS --fail-with-body --max-time 1 -o b.json -w '%{response_code}' http://127.0.0.1:8765/slow
# curl: (28) Operation timed out after 1003 milliseconds with 0 bytes received
# 終了コード 28 / ステータス 000タイムアウトのときは応答自体が無いため、%{response_code}は000になります。ステータスが000なら、HTTPレベルの失敗ではなく通信の失敗だと分かります。
--retryが拾う失敗と拾わない失敗
--retry <回数>で再試行を有効にします。マニュアルによると、やり直しの対象になる一時的なエラーは次のとおりです。
- タイムアウト
- FTPの4xx応答
- HTTPの408・429・500・502・503・504・522・524
401や404のような恒久的な失敗は対象外です。やり直しても結果が変わらないので、当然の設計です。接続拒否(ECONNREFUSED)も、既定では一時的なエラーに数えられません。--retry-connrefusedを足すと対象になり、--retry-all-errorsよりも範囲の狭い選択肢です。待ち時間は1秒から始まり、リトライのたびに倍になり、上限は10分です。5回なら待ちだけで1+2+4+8+16=31秒かかります。--retry-delayで秒数を指定すると、この倍々の増やし方は無効になります。
サーバーがRetry-Afterヘッダーを返したときは、curlはその値に従います。待ち時間をサーバー側が決められるので、予想より長く待たされることがあります。それを抑えるのが--retry-max-timeです。
ここで--max-timeとの関係を押さえます。--retryを使うと、--max-timeのカウンターはリトライのたびにリセットされます。--max-time 30 --retry 3は、最大で4回×30秒の待ちを含むことになり、全体の上限ではありません。全体の上限は--retry-max-timeが受け持ちます。
2回503を返してから200を返すエンドポイントで、リトライが働くかを試しました。
curl -sS --fail-with-body --retry 3 --retry-delay 1 \
-o b.json -w '%{response_code}' http://127.0.0.1:8765/flaky
# 終了コード 0 / ステータス 200 / b.json に {"ok":true}最終的な結果だけが返るので、スクリプト側は途中の503を意識しなくて済みます。--retry-max-timeの挙動には、もう1つ注意点があります。リトライの開始前に経過時間を確認するだけなので、すでに始まった転送は上限を過ぎても最後まで走ります。1回ごとの打ち切りには--max-timeが必要で、2つを組み合わせて初めて全体の最悪時間が決まります。
--max-time 30 --retry-max-time 60なら、新しい試行は経過60秒までにしか始まらず、始まった試行も30秒で切れます。理屈の上では、最悪でも約90秒で終わる計算です。
本文・ステータス・エラーを別々に受け取る
curl ... > out.jsonとリダイレクトで書くと、本文とエラー文が混ざったり、リトライ時に重複して書かれたりします。出力先は--outputで指定し、ステータスは--write-outで標準出力に出し、curlのエラーメッセージは標準エラーをファイルへ逃がします。
#!/usr/bin/env bash
set -u
url=$1
out_dir=${2:-./out}
mkdir -p "$out_dir"
body="$out_dir/body.json"
err="$out_dir/curl.err"
status=$(curl --silent --show-error --fail-with-body \
--connect-timeout 5 --max-time 30 \
--retry 3 --retry-max-time 60 \
--output "$body" --write-out '%{response_code}' \
"$url" 2>"$err")
rc=$?
case $rc in
0) echo "ok $status -> $body" ;;
22) echo "HTTP $status, 本文は $body" >&2 ;;
28) echo "タイムアウト: $(cat "$err")" >&2 ;;
*) echo "curl 終了コード $rc: $(cat "$err")" >&2 ;;
esac
exit "$rc"このスクリプトをローカルのサーバーに向けて動かすと、200のときは終了コード0、400のときは22で本文がbody.jsonに残り、接続できないポートでは7で標準エラーに理由が出ました。status=$(...)の直後にrc=$?を置くと、代入元のcurlの終了コードが取れます。間に別のコマンドを挟まないでください。
--silentだけだとエラー文も消えるため、--show-errorを併せて指定します。これで進捗メーターは消え、エラーだけが標準エラーに出ます。
終了コードの読み分け
呼び出し側で分岐したくなるのは、次の値です。意味はcurlのマニュアルの終了コード一覧に従っています。
| 終了コード | 意味 | 対処の方向 |
|---|---|---|
| 6 | 意味ホスト名を解決できない | 対処の方向URL・DNS・プロキシを見る |
| 7 | 意味ホストに接続できない | 対処の方向ポート・ファイアウォール・サービス停止を見る。--retryでは再試行されず、待てば繋がるなら--retry-connrefused |
| 22 | 意味HTTPで400以上(--fail系を使った場合) | 対処の方向body.jsonのエラー本文を読む |
| 28 | 意味タイムアウト | 対処の方向--max-timeの値か、サーバー側の遅延を見る |
| 52 | 意味サーバーが何も応答しなかった | 対処の方向リトライ対象にするか検討する |
| 56 | 意味ネットワークデータの受信に失敗した | 対処の方向通信の途中切断を疑う |
22が出たときは、同じコマンドを繰り返しても結果は変わりません。28・52・56は一時的な通信不良のことがあり、やり直す価値があります。ただし--retryの自動対象はタイムアウトと上記のHTTPコードに限られるので、52や56を拾いたいときは--retry-all-errorsが候補になります。
--retry-all-errorsは最後の手段にする
マニュアルは--retry-all-errorsを「sledgehammer(大槌)」と呼び、既定では使わないよう勧めています。重複データを送受信するおそれがあるためです。--outputでファイルに書く場合は、リトライ前に失敗した部分の出力が消されます。一方、>のリダイレクトやパイプへ流したデータは巻き戻されず、重複して受け取ることがあります。
4xxまで含めてやり直したいなら、--retry-all-errorsと--failの組み合わせが案内されています。POSTのように副作用のあるリクエストでは、同じ要求が二重に届いても安全か(冪等かどうか)を先に確かめてください。冪等でないAPIには、--retryの既定の対象だけを使うほうが無難です。
Claude Codeに書かせるときの決め方
ここまでの部品は、Claude Codeが毎回思い出すとは限りません。次の4点を先に決めておくと、生成されるスクリプトがぶれません。
CLAUDE.mdに規約を書いておく
プロジェクトのCLAUDE.mdに、curlの呼び方の規約を置きます。例えば次の形です。
## curl の規約
- 外部APIを叩く curl には必ず --connect-timeout 5 --max-time 30 を付ける
- HTTPエラーを検知するため --fail-with-body を使い、--fail は使わない
- 本文は --output、ステータスは --write-out '%{response_code}' で受ける
- リトライは --retry 3 --retry-max-time 60。副作用のある POST には付けない
- 終了コードを握りつぶさない(`|| true` を付けない)最後の1行は見落としやすい点です。生成されたスクリプトのcurl ... || trueで、せっかくの--fail-with-bodyが無効になります。
Bashツールの制限時間より短く収める
Claude Codeが実行するBashコマンドには制限時間があり、何も指定しなければ2分、上限は10分です。しかも制限時間に達したコマンドは、止められずにバックグラウンドへ移されます(sleepで始まるコマンドは除く)。--max-timeが無いcurlが固まると、Claudeの作業は進んでいるように見えて、裏でプロセスだけが残ります。
--max-time 30 --retry-max-time 60なら最悪でも約90秒なので、2分の既定に収まります。--max-time 30 --retry 3だけだと、待ち時間を含めて最大127秒(4×30+1+2+4)になり、既定の2分を超える可能性があります。なお、--bareで起動した場合やCLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1を設定した場合は自動のバックグラウンド化が無効になり、制限時間に達したコマンドはそのまま停止します。
許可ルールはcurlのフラグでなくスクリプトに掛ける
Bash(curl http://github.com/ *)のように引数で絞る書き方は、オプションの順序違い、httpsへの変更、リダイレクト、変数経由のURLですり抜けられます。引数で制限をかける許可ルールはもろい、というのがパーミッション解説の説明です。
そこで、上のスクリプトをscripts/api-call.shのようなラッパーにまとめ、許可ルールはBash(./scripts/api-call.sh *)に掛ける運用が一案です。タイムアウトやリトライの値がラッパーに固定されるので、Claudeが生のcurlを毎回書く場面そのものが減ります。許可ルールの考え方はcurl -Iだけ許可する手順にも例があります。
検証もClaudeにやらせる
書かせたら、失敗する条件でも動かして確認させます。例えば次のように頼みます。
scripts/api-call.sh を、存在しないパスと接続できないポートの両方に対して実行し、
終了コードと out/ に残ったファイルを報告して期待する結果は、存在しないパスなら終了コード22でbody.jsonにエラー本文が残ること、接続できないポートなら7でcurl.errに理由が出ることです。ここがずれていれば、--fail-with-bodyが抜けているか、|| trueが付いています。
つまずきやすい点
落とし穴
接続拒否(7)は--retry 3でも再試行されない
サービスが止まっていてポートが閉じていると、curlは終了コード7で即座に終わります。--retryは接続拒否を一時的なエラーとみなさないためです。デプロイ直後の起動待ちのように、少し待てば繋がる場面では--retry-connrefusedを足します。
- 生成されたスクリプトの先頭に
set -eがあると、status=$(curl ...)が失敗した時点でスクリプトごと終わり、caseの分岐に届きません。上のスクリプトがset -uだけなのはこのためで、set -eを使うならstatus=$(curl ...) || rc=$?の形で受けます - 自動リトライの対象HTTPコードのうち4xxは408と429だけです。トークン期限切れの401や、権限不足の403はリトライでは直りません
Claude Code自身のAPI呼び出しのリトライ(429や529が続く場合)は別の仕組みで、curlのフラグでは制御できません。Claude Code側の挙動はClaude Codeで529 Overloadedが続くときで、api_retryイベントをstream-jsonで拾う方法はClaude Codeのapi_retryをstream-jsonで拾うで扱っています。claude -pを呼ぶシェルスクリプトも、終了コード0以外を失敗として分岐できる点は同じです。入口はclaude -pオプションの解説にあります。
まとめ
curlスクリプトの堅牢化は、「失敗を終了コードに出す」「待ちに上限を付ける」「一時的な失敗だけやり直す」「結果の行き先を分ける」の4点に尽きます。Claude Codeに任せるときは、フラグの値をCLAUDE.mdかラッパースクリプトに固定し、Bashツールの2分という既定に最悪時間が収まるように--max-timeと--retry-max-timeを組みます。書かせた後は、成功の例だけでなく、400・接続不可・遅延の3通りで動かして終了コードを確かめるところまでを1セットにしてください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。