自律型トレーディングシステムに指数バックオフとサーキットブレーカーを入れた話。個々のコンポーネントを degrade-first(失敗しても落ちずに縮退する)に書いていても、系全体に冷却機構がないと意味がなかった、という失敗の記録。
429 / 529 / usage limit の検知、retry-after を「どこまで尊重してどこから諦めるか」、ブレーカーをどの層に置くか、そして「その機構が実際に効いているか」をどう計測するかまでを、実装した順に書く。前提スタックは Python + APScheduler + claude --print CLI。
背景: 毎朝 40 回以上 CLI を叩く自律システム
個人で運用している自律型トレーディングシステム(日本株 + BTC)は、毎朝 JST 05:00 に銘柄ごとの分析パイプラインを走らせている。工程はテクニカル、センチメント、リスク、リサーチャー統合、ポートフォリオ最適化の 5 段。
実行は Anthropic の API を直接叩くのではなく claude --print CLI 経由。10 銘柄 × 4 エージェント = 1 回の日次実行で 40 回以上の CLI 呼び出しが走り、銘柄間は 3 並列、レビュー処理はさらに 3 チャンク並列で動く。
外部境界の耐障害性としては、運用で踏むたびに以下を積み上げてきた。
| 対策 | 入れた経緯 |
|---|---|
同時起動を Semaphore(3) に制限 | 一斉起動で API を殴って全滅した |
| timeout 時に 1.5 倍に延長して retry | 300 秒で timeout → retry も 300 秒で失敗、を繰り返していた |
| OAuth 失効の fail-fast プローブ + 自動リフレッシュ | トークン失効で 52 件が一斉に 401 になった |
これで十分だと思っていた。実際にはひとつ、構造的な穴が開いていた。
穴: 429 も 529 も「その他のエラー」に落ちていた
CLI 呼び出しのエラー判定はこの 2 つだけだった。
def _is_timeout_error(error: str | None) -> bool: ...
def _is_auth_error(error: str | None) -> bool: ...
つまり 429(レートリミット)も 529(overloaded)も「その他の rc≠0」に落ち、待機を一切挟まずそのまま再投入されていた。
これがなぜ怖いか。レートリミットは「待てば直る」種類の障害なのに、待たずに再投入すると:
- 即時 retry がまた 429 を踏む
- 並列で走っている他の銘柄も同時に 429 を踏む
- 冷却されないまま次のジョブ(レビュー、ポートフォリオ最適化)も同じ壁に当たる
- 全部失敗して、朝起きたら提案が 1 件も出ていない
AI エージェント運用の失敗談としてよく語られる「あるエージェントがレートリミットで詰まり、翌朝はパイプライン全体が停止していた」そのものだ。個々のコンポーネントは degrade-first に書かれていても、冷却機構がないと系全体としては雪崩れる。
実装: 検知 → バックオフ → ブレーカー
1. 検知: 429 / 529 / usage limit を実際に返ってくる文字列から拾う
CLI は API のエラー本文を stderr ではなく stdout にそのまま吐く(これ自体が過去に踏んだ罠で、stderr だけ見ていると診断情報が消える)。実際に観測される 3 パターン:
API Error: 429 {"type":"error","error":{"type":"rate_limit_error",
"message":"Number of request tokens has exceeded your per-minute rate limit..."}}
API Error: 529 {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}
Claude AI usage limit reached|1751234400
(1 番目は読みやすさのために折り返している。実際は 1 行。)
3 番目はサブスクリプションの上限で、パイプ区切りで reset の unix epoch が付いてくる。待ち時間の抽出は形が一定しないので、retry-after: N と try again in N seconds の両方を拾うようにした。これらのシグネチャを 1 箇所にまとめ、_is_timeout_error / _is_auth_error と並ぶ 3 つ目の分岐として置いた。
2. retry-after は尊重する。ただし「長すぎるものは待たない」
ここが今回いちばん考えどころだった。
素朴に実装すると「retry-after が読めたらその秒数だけ sleep する」になる。429 の retry-after: 27 ならそれで正しい。だが 3 番目の usage limit は reset が数時間先になることがある。
このシステムのジョブは、APScheduler の BlockingScheduler が単一プロセスで捌いている。長時間 sleep するジョブがエグゼキュータのワーカを占有すると、その間に発火すべきだった他のジョブはキューで待たされる。 そして待たされた分が misfire_grace_time を超えたものから順に skip されていく(APScheduler のデフォルトは 1 秒。このシステムでは 300 秒に伸ばしてある)。レートリミットを回避しようとして、価格収集もポジション監視も巻き添えで止まる、という本末転倒が起きる。
なので方針をこう決めた:
300 秒を超える retry-after は「待つ」のではなく「サーキットブレーカーを OPEN にして degrade する」
待てる範囲は待つ。待てない範囲は諦めて、系を止めない方を選ぶ。retry-after が読めない場合(529 では付いてこないことが多い)は 5s × 2^n(上限 120s)を equal jitter で散らす。jitter は並列ワーカーが同じタイミングで再突入する thundering herd(冷却明けに全ワーカーが同時に殺到する現象)を避けるため。
3. ブレーカー: HALF_OPEN では 1 本しか通さない
状態は 3 つ。CLOSED で 120 秒窓に 3 回ヒットすると OPEN、60 秒冷却して HALF_OPEN、成功で CLOSED に復帰し、失敗なら冷却時間を倍にして OPEN に戻る(上限 600 秒)。
HALF_OPEN では 同時に 1 本しか通さない。ここを緩めると、冷却明けに 3 並列のワーカーが一斉に突っ込んで即座にまた OPEN する。
OPEN 中の拒否は CircuitOpenError(RuntimeError 派生)として投げる。派生型にしたのは、既存の呼び出し側がすべて except Exception で縮退するように書かれているから(銘柄単位の error 結果、スクリーナーはルールベースへフォールバック)。新しい例外型のために全呼び出し側を書き換えると、それ自体が新しいクラッシュ経路になる。
4. ゲートは 1 箇所だけに置く
CLI を叩く経路を grep で全部数えたら 6 つあった。
- 分析パイプライン
- レビュー
- ポートフォリオ最適化
- スクリーナーの AI 選定
- 戦略探索
- 価格アラート評価
これらは全部、最下層の 1 関数を通っている。ブレーカーの判定と結果記録はその関数の中だけに置いた。 呼び出し側 6 箇所にゲートを配ると、必ずどれかが漏れるか、二重に取得して自分で自分をブロックする。
潰したバグ: 半開の試行枠がリークして「永久ロック」する
実装中に自分で踏んだ、一番いやらしいバグ。
HALF_OPEN は「1 本だけ通す」ために in-flight フラグを立てる。このフラグは呼び出しの結果を記録するときに解放される。つまり ゲートを通ったのに結果を記録せずに抜ける経路があると、フラグが立ちっぱなしになる。
そうなると何が起きるか。レートリミットが解消しても、ブレーカーは永久に全部の呼び出しを拒否し続ける。 冷却が明けても半開の枠が埋まったままなので、誰も試行できない。障害が直っているのにシステムだけが止まり続ける、最悪の壊れ方だ。
見落としていた経路は「CLI バイナリが見つからない」のような、レートリミットと無関係な例外だった。修正は単純で、ゲートを通った後の処理を丸ごと try で囲み、どの経路で抜けても必ず結果を記録して半開の枠を解放する。
breaker.before_call(label=label)
try:
... # CLI 実行と結果の記録
except Exception:
breaker.record_other_failure(label=label) # 締め忘れ防止
raise
回帰テストも入れた。「HALF_OPEN で想定外の例外が起きた後、次の冷却明けにちゃんと復帰できるか」 を固定している。
テスト: 制御機能は「壊れる」より「効かない」で失敗する
このプロジェクトには、過去の失敗から生まれたルールがある。
制御機能は「壊れる」より 「効かない(no-op)」で失敗しやすい。
以前、ピラミッディング(勝ちポジションの積み増し)機能が数週間まったく発動していなかったことがある。単体テストは全部緑だった。テストは好条件(regime="bull")を手で渡していた。だが本番のライブ入力はずっと regime="range" で、ゲート条件に一度も合致していなかった。コードは正しく、しかし機能していなかった。 発動率の計器(メトリクス出力)が無かったので、気づいたのは数週間後だった。
同じ轍を踏まないため、今回は (a) 配線テスト / (b) 発動率の計器 / (c) ミューテーションの 3 段構えにした。
(a) 配線テスト: subprocess にエラー応答を注入して通しで確認する
単体テストは「ブレーカーの状態遷移が正しいか」しか見ない。それは機構が正しいことの証明であって、配線されていることの証明ではない。
なので subprocess.run のレベルに実際のエラー応答を注入して、呼び出し側から通しで確認する。
- 429 を返す → バックオフして待つ → retry して成功する
- 429 が続く → 3 回目でブレーカーが OPEN → 以降は subprocess を起動すらしない
- usage limit(reset が数時間先)→ 一切 sleep せず、retry もせず、OPEN して抜ける
- 1 銘柄が 429 を踏んでも、他の銘柄は最後まで完走する
- timeout は従来どおりの延長 retry のまま(新しい分岐が既存分岐を壊していない)
(b) 発動率の計器: 0 件でも必ずメトリクス行を出す
分析パイプラインの完了時に、レートリミットが 0 件でも必ずこの 1 行を出す。
rate_limit metrics label=analysis-pipeline state=closed hits=0 rejected=0 opened=0 backoff_total=0.0s
0 件でも出すのが肝心なところ。この行が無いと、2 つの状態が区別できない。「検知分岐を足したが、シグネチャが実際の文字列と食い違っていて一度も発動していない」と、「平和なので発動していない」だ。運用ドキュメントにも「ジョブが不可解に失敗しているのに hits=0 のままならシグネチャの漏れを疑え」と書いた。
そしてこの計器行が出ること自体をテストで固定する。計器はコメントアウトされやすいので。
(c) ミューテーションテスト: 機能を殺してテストが赤くなるか確かめる
配線テストは初回実行で全部通った。通りすぎて逆に不安になったので、検知関数を常に False を返すよう潰して(=機能が no-op 化した状態を再現して)走らせてみた。
結果、配線テスト 9 件のうち過半が落ちた。残ったのは検知分岐に依存しない性質のテスト(ブレーカーを手で OPEN にした degrade テスト、timeout 非干渉テスト、0 件計器テスト)なので、通るのが正しい。
テストが緑なことより、機能を殺したときにテストが赤くなることのほうが情報量が多い。
まとめ
- レートリミットは「待てば直る」障害。待たずに retry すると系全体で雪崩れる
- retry-after は尊重する。ただし スケジューラのワーカを長時間占有する待ちは、待たずに degrade したほうが系は生き残る
- ブレーカーのゲートは最下層 1 箇所に置く。呼び出し側に配ると漏れる
- 半開の試行枠は必ず解放する。締め忘れると「障害が直っても止まったまま」になる
- 制御機能には (a) 呼び出し側からの配線テストと (b) 発動率の計器を必ず同梱する。単体テストが緑でも、本番のライブ入力で発動 0 件のまま何週間も過ぎることがある
- (c) テストが最初から全部通ったら、機能を殺してテストが落ちるか確かめる
最終的に、スイート全体 2852 件が緑。この機構が実運用で初めて火を噴く日が来たら、ログの rate_limit を grep すれば何が起きたか全部わかるはずだ。——そして「何も起きていない」ことも、ちゃんとわかる。

