レートリミットで止まった Claude Code を PROGRESS.md+自動リトライで復活させる
前回、出力トークンの見張り番を作った話を書きました。今回はその隣にある小道具 ―― レートリミットで止まった claude --continue -p を自動でリトライして再開させる resume-on-ratelimit.sh の話です。
結論から書きます。このスクリプトの既定値は WAIT_MINUTES=5 / MAX_RETRIES=20。単純計算だと最大待機は100分で、Claude Codeのレート上限がリセットされる5時間ブロックに対して桁が1つ足りません。さらに手元で検証したところ、set -euo pipefail と EXIT=$? の組み合わせのせいで、肝心のリトライループが一度も実行されずスクリプトごと落ちるという穴も見つかりました。実測してから公開してよかった一本です。
困りごと:レートリミットに張り付いていられない
長時間タスクをClaude Codeにやらせていると、途中でレートリミットに当たって止まることがあります。復旧手段は単純で、claude --continue でセッションを引き継いで再開するだけなのですが、問題はいつ上限が明けるか分からないこと。5分おきに手でターミナルを覗きに行く運用は、当然続きません。
かといって無限リトライを書いて放置すると、今度は「本当にレートリミットなのか、それとも別のエラーで壊れているのか」を区別せずに回り続けるスクリプトになりがちです。この2つ ―― 待たせすぎない・区別せず回しすぎない ―― のバランスを取るために書いたのが resume-on-ratelimit.sh です。
resume-on-ratelimit.sh の中身
全文はこれだけです。
#!/usr/bin/env bash
set -euo pipefail
WAIT_MINUTES=${WAIT_MINUTES:-5}
MAX_RETRIES=${MAX_RETRIES:-20}
TASK="${1:-PROGRESS.mdを読んで中断した作業を続けて。作業済みなら何もしない。}"
RETRY=0
notify() {
osascript -e "display notification \"$1\" with title \"Claude Code\"" 2>/dev/null || true
}
while [ $RETRY -lt $MAX_RETRIES ]; do
if [ $RETRY -eq 0 ]; then
claude --dangerously-skip-permissions --continue -p "$TASK"
EXIT=$?
else
claude --dangerously-skip-permissions --continue -p "PROGRESS.mdを読んで中断した作業を続けて。"
EXIT=$?
fi
if [ $EXIT -eq 0 ]; then
notify "Claude Code: 作業完了"
exit 0
fi
RETRY=$((RETRY + 1))
notify "Claude Code: レートリミット。${WAIT_MINUTES}分後に再開します"
sleep $((WAIT_MINUTES * 60))
done
notify "Claude Code: 最大リトライ超過。手動確認してください"
exit 1
やっていることは3つだけです。
claude --continue -pを叩く- 終了コードが0でなければ「レートリミットとみなして」
WAIT_MINUTES分待ってリトライ MAX_RETRIES回失敗したら諦めて通知して終わる
PROGRESS.mdを「中断票」として使う
肝は、初回以降のリトライで投げるプロンプトが常に固定文言だという点です。
claude --dangerously-skip-permissions --continue -p "PROGRESS.mdを読んで中断した作業を続けて。"
タスク固有の情報はプロンプトに載せません。載せる先は PROGRESS.md 側です。長時間タスクを始める時点で、Claude Code自身に「今どこまで進んだか」「次に何をやる予定か」を PROGRESS.md に書かせておく、という規約にしておけば、リトライ時のプロンプトはこの1文で毎回使い回せます。状態をプロンプトではなくファイルに逃がす設計にしたことで、リトライ処理自体は驚くほど単純になりました。
MAX_RETRIESガードとmacOS通知
MAX_RETRIES=20 は「無限に粘らない」ためのガードです。上限に達したら exit 1 して notify で通知し、そこから先は人間の判断に戻します。通知は osascript 一発だけで、認証もライブラリも要りません。
notify() {
osascript -e "display notification \"$1\" with title \"Claude Code\"" 2>/dev/null || true
}
検証して見つけた穴:set -e でリトライが一度も走らない
ここからが今回のオチです。冒頭のset -euo pipefailと、ループ内のclaude ...; EXIT=$?という書き方の組み合わせを、実際にbashで再現してみました。
$ bash -c '
set -euo pipefail
RETRY=0
while [ $RETRY -lt 3 ]; do
echo "attempt $RETRY"
false # claude が非0終了するケースの代役
EXIT=$?
echo "EXIT=$EXIT"
RETRY=$((RETRY+1))
done
echo "loop finished normally"
'
attempt 0
$ echo $?
1
attempt 0 の1行だけ出て、EXIT=$? の行にも echo にも到達せず、スクリプトごと終了しました。理由はset -eの仕様どおりです。cmd; 次の文 という単純な並びでは、ifや&&/||の対象になっていない限り、cmdが非0を返した瞬間にシェルはそこで終了します。つまり claude ...; EXIT=$? は、claudeが失敗した瞬間にスクリプト自体が落ちて、その後のリトライ判定コードには一度も到達しません。レートリミットで一番よく起きるはずの「非0終了」が、皮肉にもこのリトライスクリプトを丸ごと殺す引き金になっています。
set -eは「非0終了で即終了」ですが、例外は if/while の条件式・&&/||の非最終項・!付きだけです。cmd; x=$?は例外に当たりません。終了コードを拾いたいならcmd || EXIT=$?のように失敗を||で受け止めるか、素直にset -eを外す必要があります。
実はこの教訓は、同じ用途で後に書いた autopilot.sh にはすでに反映されていました。あちらは冒頭が set -uo pipefail(-eなし)で、終了コードは
RESULT=$(... 2>&1) || RC=$?
という「失敗を||で受け止めてから変数に入れる」形になっています。resume-on-ratelimit.shを書いた時点ではこのパターンに気づいておらず、今回読み返して初めて実害に気づいた形です。直すなら、ループ内を
EXIT=0
claude --dangerously-skip-permissions --continue -p "$TASK" || EXIT=$?
に変えるだけで、set -eを残したままリトライが機能するようになります。
待機時間とレートリミットの窓のズレ
もう一点、数字を並べると見えてくる問題があります。既定値 WAIT_MINUTES=5 / MAX_RETRIES=20 で単純計算すると、待機だけで合計 5 × 20 = 100分。一方、前回の記事や実運用で見ているレートリミットは5時間ブロック単位です。5時間ブロックの終盤で詰まった場合、100分粘っても上限はまだ明けておらず、このスクリプトは上限が明ける前に「最大リトライ超過」を通知して諦めます。長時間タスクで使うときは WAIT_MINUTES か MAX_RETRIES を環境変数で上げて、少なくとも5時間分は待てるようにしておく必要があります。
踏んだ落とし穴
set -e+cmd; EXIT=$?でリトライループが一度も実行されずスクリプトごと落ちる →cmd || EXIT=$?で失敗を受け止める(実測で再現済み)WAIT_MINUTES=5 × MAX_RETRIES=20=最大100分では5時間ブロックの上限リセットに届かない → 長時間タスクでは環境変数で上げる- 非0終了を全部「レートリミット」として扱っている → 実際は認証切れやネットワーク断でも同じメッセージで無条件リトライされる
- リトライ時のプロンプトを固定文言にできたのはPROGRESS.md側に状態を逃したから → プロンプトに状態を積むと複雑になる典型例を避けられた
まとめ
resume-on-ratelimit.shは「非0終了→待機→--continueで再開」を繰り返すだけの単純なラッパー- 状態はプロンプトではなくPROGRESS.mdに逃がすことで、リトライの中身を固定文言1つに単純化できる
MAX_RETRIESは無限リトライを防ぐガード、notify()は認証不要なosascript一発- ただし実際に動かして検証すると、
set -eの仕様のせいでリトライ自体が発火しない穴があった。動くはずという思い込みではなく、実行して確かめることの価値がここにある - 待機の既定値100分は、実際のレートリミット窓(5時間)に対して短すぎる
次回は、このset -eの穴を実際に直したバージョンと、長時間ブロックに合わせた待機時間の調整を書く予定です。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!