Claude Media
Claude Codeのcurlスクリプトにリトライと時間切れを足す手順

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つのフラグは何を受け持つか

先に役割を並べます。

curlの役割

堅牢化に使う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セットにしてください。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. curl.se/docs/manpage.html
  2. code.claude.com/docs/en/tools-reference
  3. code.claude.com/docs/en/permissions
  4. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn