本文へスキップ
hdknr blog

外部境界の耐障害性とサーキットブレーカー

レートリミット・過負荷といった「待てば直る」障害に対する指数バックオフとサーキットブレーカーの設計。半開スロットのリーク、待つべきでない retry-after、制御機能が no-op で失敗する問題まで

概要

外部 API を叩く自律システムで、レートリミット(HTTP 429)や過負荷(529)のような**「待てば直る」種類の障害**を扱うための設計。要点は、個々のコンポーネントを degrade-first(失敗しても落ちずに縮退する)に書くだけでは足りず、系全体に冷却機構が無いと雪崩れるという点にある。

冷却が無いまま即時 retry すると、次の順で全滅する。

  1. 即時 retry がまた 429 を踏む
  2. 並列で走っている他のワーカーも同時に 429 を踏む
  3. 冷却されないまま後続ジョブも同じ壁に当たる
  4. 全部失敗して、朝には出力が 1 件も残っていない

エラー分類を「その他」で済ませない

よくある穴は、エラー分岐が timeout と認証エラーの 2 種類しかなく、429 も 529 も「その他の rc≠0」に落ちて待機なしで再投入されること。レートリミットは待機を要求する障害なので、timeout と同じ扱いにしてはいけない。

検知は「実際に返ってくる文字列」から起こす。CLI 経由で叩く場合、API のエラー本文が stderr ではなく stdout に出ることがある(Claude Code のヘッドレス実行では exit code 0 で返る報告もある)ため、stderr だけ見ていると診断情報が消える。

待ち時間の表現は一定しないので、retry-after: Ntry again in N seconds の両方を拾えるようにしておく。

retry-after は尊重する。ただし「長すぎるものは待たない」

素朴に実装すると「retry-after が読めたらその秒数だけ sleep」になる。数十秒ならそれで正しいが、サブスクリプション上限のリセットは数時間先になることがある。

長時間 sleep するジョブがスケジューラのワーカを占有すると、その間に発火すべきだった他のジョブがキューで待たされ、猶予時間(APScheduler なら misfire_grace_time、デフォルト 1 秒)を超えたものから順に skip される。レートリミットを避けようとして無関係な定期処理まで巻き添えで止まる、という本末転倒が起きる。

方針: 一定時間(例: 300 秒)を超える retry-after は「待つ」のではなく「ブレーカーを OPEN にして degrade する」

待てる範囲は待ち、待てない範囲は諦めて系を止めない方を選ぶ。retry-after が読めない場合は base × 2^n(上限つき)を equal jitter で散らす。jitter は並列ワーカーが同じタイミングで再突入する thundering herd を避けるためで、AWS の “Exponential Backoff And Jitter” が出典。

状態遷移

CLOSED    ──(一定窓で N 回ヒット)──▶ OPEN
OPEN      ──(冷却後、最初の 1 本)──▶ HALF_OPEN
HALF_OPEN ──(成功)──▶ CLOSED
HALF_OPEN ──(失敗)──▶ OPEN(冷却を倍化、上限つき)

ゲートは最下層 1 箇所に置く

外部 API を叩く経路が複数あっても、たいていは最下層の 1 関数を通っている。ブレーカーの判定と結果記録はその関数の中だけに置く。 呼び出し側それぞれにゲートを配ると、必ずどれかが漏れるか、二重に取得して自分で自分をブロックする。

最大の罠: 半開スロットのリークによる永久ロック

HALF_OPEN は「1 本だけ通す」ために in-flight フラグを立て、結果を記録するときに解放する。つまり ゲートを通ったのに結果を記録せずに抜ける経路があると、フラグが立ちっぱなしになる

そうなると、レートリミットが解消してもブレーカーは永久に全部の呼び出しを拒否し続ける。 冷却が明けても半開の枠が埋まったままなので誰も試行できない。障害が直っているのにシステムだけが止まり続ける、最悪の壊れ方である。

漏れやすいのは「バイナリが見つからない」のような、レートリミットと無関係な例外の経路。修正はゲート通過後を丸ごと try で囲み、どの経路で抜けても必ず結果を記録して枠を解放する。

breaker.before_call(label=label)
try:
    ...  # 実行と結果の記録
except Exception:
    breaker.record_other_failure(label=label)  # 締め忘れ防止
    raise

検証: 制御機能は「壊れる」より「効かない」で失敗する

制御機能の失敗モードは例外ではなく no-op(一度も発動しない)である。単体テストが全部緑でも、本番のライブ入力ではゲート条件に一度も合致せず、機能が数週間死んでいることがある——コードは正しく、しかし機能していない状態。

対策は 3 段構え。

  1. 配線テスト — 単体テストは機構が正しいことの証明であって、配線されていることの証明ではないsubprocess.run 相当のレベルに実際のエラー応答を注入し、呼び出し側から通しで確認する(429 でバックオフして復帰する/連続で OPEN する/長すぎる待ちでは一切 sleep しない/1 件の失敗が他を巻き込まない/既存の timeout 分岐を壊していない)
  2. 発動率の計器0 件でも必ずメトリクス行を出す。これが無いと「シグネチャが実際の文字列と食い違って一度も発動していない」と「平和なので発動していない」が区別できない。計器行が出ること自体もテストで固定する(計器はコメントアウトされやすい)
  3. ミューテーション — 検知関数を常に False を返すよう潰して走らせ、テストが赤くなるか確かめる。テストが緑なことより、機能を殺したときにテストが赤くなることのほうが情報量が多い

関連ページ

ソース記事