- 設計
サーキットブレーカーとは|3状態の実装とリトライ可否の一覧表
サーキットブレーカーとは、エラーが続いている呼び出し先への呼び出しを一定時間止め、すぐにエラー(または代わりの応答)を返す仕組みです。 リトライが「もう一度試せば通る」ことを期待するのに対し、サーキットブレーカーは「しばらく通らない」と判断して呼び出しそのものを省きます。2つは役割が違うので、重ねて使います。
この記事では、3つの状態(Closed・Open・Half-Open)を動くコードと実行ログで確かめ、しきい値の決め方を数値で示します。あわせて、リトライしてよいエラーと操作の一覧表、回数と間隔をタイムアウト予算から逆算する表を載せます。待ち時間の計算式とジッターの種類は短く触れるだけにし、「どこでリトライをやめ、どこで遮断するか」に絞ります。
確認環境(2026-10-06):Ubuntu 24.04、Python 3.11.15(標準ライブラリのみ)。Mermaid の図は @mermaid-js/mermaid-cli 12.0.0(mermaid 12.1.0)と Chromium 141 で描画できることを確かめました。HTTP の定義は RFC 9110・RFC 6585、状態の定義は Microsoft Learn の Circuit Breaker pattern、既定値は Resilience4j のドキュメント、SQLSTATE は PostgreSQL 16 のドキュメントを同日に開いて確認しています。
この記事の結論
- リトライの回数と間隔は、呼び出し元が待てる時間(タイムアウト予算)から逆算します。「1回のタイムアウト×回数+待機の合計」が予算を超えない組み合わせを選びます。
- リトライしてよいのは、一時的なエラー(408・429・502・503・504、接続エラー)かつ、やり直しても結果が変わらない操作です。POST は冪等キーがあるときだけ対象にします。
- サーキットブレーカーは3状態。Closed(通す)→ Open(止める)→ Half-Open(少しだけ試す)→ Closed に戻ります。
- しきい値は混雑時のエラー率の10倍前後(混雑時5%なら50%)、判定は10〜20件たまってからが出発点です。平常時5%・窓10件・しきい値20%では、計算上は1日に1万回以上開いてしまいます。
- 重ねる順は、外側から 全体期限 → リトライ → サーキットブレーカー → 1回のタイムアウト。遮断されたらリトライをやめます。
リトライしてよいエラーの早見表(HTTPステータス別)
| ステータス | 意味 | リトライ | 補足 |
|---|---|---|---|
| 408 Request Timeout | サーバーがリクエストを待ちきれなかった | する | 送り直しで通ることが多い |
| 429 Too Many Requests | 一定時間内の回数が多すぎる | する(待ってから) | Retry-After があればその秒数だけ待つ |
| 500 Internal Server Error | サーバー側の想定外の状態 | 冪等な操作だけ | 処理が途中まで進んだ可能性がある |
| 502 Bad Gateway | 中継サーバーが不正な応答を受けた | 冪等な操作だけ | 再起動や切り替えの直後に出やすい |
| 503 Service Unavailable | 一時的な過負荷・メンテナンス | する(待ってから) | Retry-After があれば従う |
| 504 Gateway Timeout | 中継サーバーが上流の応答を待ちきれなかった | 冪等な操作だけ | 上流では処理が続いている場合がある |
| 400 / 422 | リクエストの内容に誤りがある | しない | 同じ内容を送っても同じ結果になる |
| 401 / 403 | 認証・権限が足りない | しない | トークンを更新してから1回だけ送り直す形にする |
| 404 | 対象が無い | しない | 作成直後の反映待ちなど、理由が分かっているときだけ個別に扱う |
| 409 Conflict | 対象の現在の状態と衝突している | しない | 最新の状態を読み直してから、処理全体をやり直す |
| 501 Not Implemented | その機能に対応していない | しない | 待っても変わらない |
冪等(べきとう)とは、同じリクエストを何度送っても結果が1回送ったときと同じになる性質です。RFC 9110 は GET・HEAD・PUT・DELETE・OPTIONS を冪等なメソッドとし、冪等なメソッドは自動で繰り返してよいとしています。Retry-After は秒数または日時の2つの形式で返ります。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「サーキットブレーカーとは|3状態の実装とリトライ可否の一覧表」
例外・データベースのエラー別の早見表
Python の標準ライブラリの例外名で整理します。他の言語でも「接続できなかった」「応答を待ちきれなかった」「相手が見つからない」の分け方は同じです。
| 例外・エラー | 起きる場面 | リトライ | 理由 |
|---|---|---|---|
ConnectionRefusedError |
接続先のポートが開いていない | する | リクエストは届いていない |
ConnectionResetError / BrokenPipeError |
通信の途中で接続が切れた | 冪等な操作だけ | 届いたかどうかが分からない |
TimeoutError(接続時) |
接続が確立する前に時間切れ | する | リクエストは届いていない |
TimeoutError(読み取り時) |
応答を待っている間に時間切れ | 冪等な操作だけ | 相手は処理を続けている場合がある |
socket.gaierror |
ホスト名を解決できない | 1〜2回まで | 設定の誤りなら待っても変わらない |
ssl.SSLCertVerificationError |
証明書を検証できない | しない | 設定や期限の問題で、待っても変わらない |
SQLSTATE 40001(直列化エラー) |
トランザクションどうしの競合 | する | トランザクション全体をやり直す |
SQLSTATE 40P01(デッドロック検出) |
互いにロックを待ち合った | する | トランザクション全体をやり直す |
SQLSTATE 23505(一意制約) |
同じ値がすでにある | 原則しない | 続く条件であることが多く、個別に判断する |
PostgreSQL のドキュメントは、40001 は必ず、40P01 もやり直すことを勧め、「どの SQL を発行するかを決める処理も含めて、トランザクション全体をやり直す」としています。1文だけを送り直すのではなく、読み直しから始めます。
リトライしてよい操作・控える操作の一覧
エラーの種類と同じくらい、操作の性質が大事です。
| 操作の例 | リトライ | 整え方 |
|---|---|---|
| 一覧・詳細の取得(GET) | してよい | そのまま |
| ID を指定した上書き更新(PUT) | してよい | 全項目を送る形にする |
| ID を指定した削除(DELETE) | してよい | 2回目の 404 を成功として扱う |
| 「在庫を10にする」(絶対値の更新) | してよい | そのまま |
| 「在庫を1減らす」(差分の更新) | 控える | 冪等キーか、更新前の版番号を条件に付ける |
| 新規登録(POST) | 控える | 冪等キーを付ければ対象にできる |
| メール・通知の送信 | 控える | 送信キューに1回だけ積み、送信側で重複を除く |
| 採番を伴う処理 | 控える | 番号を先に確保してから本体を登録する |
| 外部への依頼で取り消しが難しいもの | 控える | 状態の照会で結果を確かめてから判断する |
メソッドとステータスからリトライするかを決める関数を、短いコードにすると次のとおりです。
# retryable.py : メソッドとステータスからリトライするかを決める
IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"} # RFC 9110 9.2.2
RETRY_STATUS = {408, 429, 500, 502, 503, 504}
SAFE_EVEN_IF_NOT_IDEMPOTENT = {429, 503} # サーバーが処理を受け付けていないと分かる応答
def should_retry(method, status, has_idempotency_key=False):
if status not in RETRY_STATUS:
return False
if method in IDEMPOTENT or has_idempotency_key:
return True
return status in SAFE_EVEN_IF_NOT_IDEMPOTENT
for m, s, k in [("GET", 503, False), ("GET", 404, False), ("POST", 503, False), ("POST", 500, False),
("POST", 500, True), ("POST", 504, False), ("PUT", 504, False), ("DELETE", 429, False),
("POST", 409, True), ("GET", 401, False)]:
print(f"{m:<6} {s} 冪等キー={'あり' if k else 'なし'} -> {'リトライする' if should_retry(m, s, k) else 'リトライしない'}")
GET 503 冪等キー=なし -> リトライする
GET 404 冪等キー=なし -> リトライしない
POST 503 冪等キー=なし -> リトライする
POST 500 冪等キー=なし -> リトライしない
POST 500 冪等キー=あり -> リトライする
POST 504 冪等キー=なし -> リトライしない
PUT 504 冪等キー=なし -> リトライする
DELETE 429 冪等キー=なし -> リトライする
POST 409 冪等キー=あり -> リトライしない
GET 401 冪等キー=なし -> リトライしない
POST の 503 を対象にしているのは「受け付けていない」と読める応答だからです。中継サーバーが 503 を返す構成では、上流に届いていた可能性も残るので、心配なら 429 だけに絞ります。
サーキットブレーカーの3状態(Closed・Open・Half-Open)
| 状態 | 呼び出し | 次の状態へ移る条件 |
|---|---|---|
| Closed(閉じている=通す) | そのまま通す。結果を記録する | 直近のエラー率がしきい値以上 → Open |
| Open(開いている=止める) | 呼び出さずに、すぐエラーを返す | 決めた待機時間が過ぎる → Half-Open |
| Half-Open(半開き=試す) | 決めた数だけ通す | すべて成功 → Closed、1件でもエラー → Open |
名前は電気のブレーカーに由来します。回路が「閉じている」と電気が流れるので、Closed が平常の状態です。日本語の感覚と逆に感じやすいところです。
stateDiagram-v2
[*] --> Closed
Closed --> Open: エラー率がしきい値以上
Open --> HalfOpen: 待機時間が経過
HalfOpen --> Closed: 試験呼び出しが規定回数成功
HalfOpen --> Open: 試験呼び出しがエラー
Closed --> Closed: 成功・しきい値未満のエラー
Open --> Open: 呼び出さずにすぐ返す
Open の間は呼び出し先に負荷をかけないので、相手が立ち直る時間をつくれます。呼び出す側も、時間切れを待たずにすぐ次の処理(代わりの応答、キャッシュ、案内文の表示)へ進めます。
Python の最小実装(標準ライブラリのみ)
直近 window 件の結果を覚えておき、min_calls 件以上たまった時点でエラー率を見ます。時計を引数で差し替えられるようにしてあるので、テストでは待たずに時間を進められます。
# breaker.py : サーキットブレーカーの最小実装(標準ライブラリのみ)
import time
from collections import deque
class CircuitOpenError(Exception):
"""遮断中のため、呼び出しを行わずに返したことを表す"""
class CircuitBreaker:
CLOSED, OPEN, HALF_OPEN = "CLOSED", "OPEN", "HALF_OPEN"
def __init__(self, window=10, min_calls=5, error_rate=0.5,
open_seconds=30.0, half_open_calls=2, clock=time.monotonic):
self.window = deque(maxlen=window) # 直近の結果(True=エラー)
self.min_calls = min_calls # この件数たまるまで判定しない
self.error_rate = error_rate # エラー率のしきい値
self.open_seconds = open_seconds # OPEN のまま待つ時間
self.half_open_calls = half_open_calls # HALF_OPEN で通す試験呼び出しの数
self.clock = clock
self.state = self.CLOSED
self.opened_at = 0.0
self.trial_ok = 0
self.on_change = lambda old, new, why: None
def _move(self, new, why):
old, self.state = self.state, new
if new == self.OPEN:
self.opened_at = self.clock()
if new in (self.CLOSED, self.HALF_OPEN):
self.window.clear()
self.trial_ok = 0
self.on_change(old, new, why)
def call(self, func, *args, **kwargs):
if self.state == self.OPEN:
if self.clock() - self.opened_at < self.open_seconds:
raise CircuitOpenError("circuit is open")
self._move(self.HALF_OPEN, f"{self.open_seconds:g}秒経過")
try:
result = func(*args, **kwargs)
except Exception:
self._record(error=True)
raise
self._record(error=False)
return result
def _record(self, error):
if self.state == self.HALF_OPEN:
if error:
self._move(self.OPEN, "試験呼び出しがエラー")
else:
self.trial_ok += 1
if self.trial_ok >= self.half_open_calls:
self._move(self.CLOSED, f"試験呼び出しが{self.trial_ok}回成功")
return
self.window.append(error)
n, errors = len(self.window), sum(self.window)
if n >= self.min_calls and errors / n >= self.error_rate:
self._move(self.OPEN, f"直近{n}件中{errors}件がエラー")
この実装は仕組みを追うための最小形で、1つのスレッドから順に呼ぶ前提です。複数スレッドから使うときは call と _record の状態更新を threading.Lock で囲み、Half-Open で同時に通す数も数えます。
呼び出し先を止めて、状態の移り変わりを見る
1秒に1回、60秒間呼び出します。呼び出し先は t=3〜39秒の間だけ 503 を返します。
# demo.py : 呼び出し先を t=3〜39秒のあいだ止めて、状態の移り変わりを記録する
from breaker import CircuitBreaker, CircuitOpenError
now = 0.0 # 再現しやすいよう、時計は自前で進める
def clock(): return now
def backend():
if 3 <= now < 40: # この間だけエラーを返す
raise ConnectionError("503 Service Unavailable")
return "200 OK"
cb = CircuitBreaker(window=10, min_calls=5, error_rate=0.5,
open_seconds=15, half_open_calls=2, clock=clock)
events = []
cb.on_change = lambda old, new, why: events.append(f" >>> {old} -> {new}({why})")
reached = blocked = 0
run = [] # 連続した遮断は1行にまとめて表示する
def flush():
if run:
print(f"t={run[0]:>2}〜{run[-1]}s 遮断(呼び出さずに返す)×{len(run)}")
run.clear()
for i in range(60): # 1秒に1回、60秒間呼び出す
now = float(i)
try:
r = cb.call(backend); reached += 1
line = f"t={i:>2}s {r}"
except CircuitOpenError:
blocked += 1; run.append(i); continue
except ConnectionError as e:
reached += 1
line = f"t={i:>2}s エラー {e}"
flush(); print(line)
while events: print(events.pop(0))
flush()
print(f"呼び出し先に届いた回数: {reached} / 60、遮断して返した回数: {blocked}")
$ python3 demo.py
t= 0s 200 OK
t= 1s 200 OK
t= 2s 200 OK
t= 3s エラー 503 Service Unavailable
t= 4s エラー 503 Service Unavailable
t= 5s エラー 503 Service Unavailable
>>> CLOSED -> OPEN(直近6件中3件がエラー)
t= 6〜19s 遮断(呼び出さずに返す)×14
t=20s エラー 503 Service Unavailable
>>> OPEN -> HALF_OPEN(15秒経過)
>>> HALF_OPEN -> OPEN(試験呼び出しがエラー)
t=21〜34s 遮断(呼び出さずに返す)×14
t=35s エラー 503 Service Unavailable
>>> OPEN -> HALF_OPEN(15秒経過)
>>> HALF_OPEN -> OPEN(試験呼び出しがエラー)
t=36〜49s 遮断(呼び出さずに返す)×14
t=50s 200 OK
>>> OPEN -> HALF_OPEN(15秒経過)
t=51s 200 OK
>>> HALF_OPEN -> CLOSED(試験呼び出しが2回成功)
t=52s 200 OK
…(省略)…
t=59s 200 OK
呼び出し先に届いた回数: 18 / 60、遮断して返した回数: 42
読み取れることは3つです。
- 止まっていた37秒間に呼び出し先へ届いたのは5回だけ(最初の3回と、試験呼び出し2回)。残りは呼び出さずに返しています。
- 開いたのは3件目のエラー(t=5)です。直前の成功3件が窓に残っていたので、エラー2件の時点では 2/5=40% でしきい値に届きませんでした。
- 呼び出し先は t=40 に戻りましたが、Closed に戻ったのは t=51 です。Open の待機時間の分だけ、復帰に気づくのが遅れます。
しきい値の決め方:平常時のエラー率から計算する
しきい値が低すぎると平常時にも開き、高すぎると止まっているのに開きません。平常時のエラー率で「たまたま開く」確率を二項分布で計算し、十分小さくなる組み合わせを選びます。
# threshold.py : 平常時のエラー率から、しきい値と最小件数を決める
from math import comb, ceil
def p_trip(n, k, p):
"""n 件中 k 件以上がエラーになる確率(二項分布)"""
return sum(comb(n, i) * p**i * (1 - p)**(n - i) for i in range(k, n + 1))
print("窓の件数 しきい値 必要なエラー数 | 平常時1%で開く確率 | 平常時5%で開く確率 | 全滅時に開くまで")
for n in (5, 10, 20, 50):
for rate in (0.2, 0.5):
k = ceil(n * rate)
print(f"{n:>6}件 {rate:>6.0%} {k:>10}件 | {p_trip(n, k, 0.01):>17.2e} | {p_trip(n, k, 0.05):>17.2e} | {k}件目")
print()
print("毎秒20件の呼び出しで、窓1つ=1判定とみなしたときの1日あたりの誤作動の目安")
per_day = 20 * 86400
for n, rate in ((5, 0.2), (10, 0.2), (10, 0.5), (20, 0.5)):
k = ceil(n * rate)
for p in (0.01, 0.05):
print(f"窓{n:>2}件・しきい値{rate:.0%}・平常時{p:.0%}: {per_day / n * p_trip(n, k, p):>12.4f} 回/日")
窓の件数 しきい値 必要なエラー数 | 平常時1%で開く確率 | 平常時5%で開く確率 | 全滅時に開くまで
5件 20% 1件 | 4.90e-02 | 2.26e-01 | 1件目
5件 50% 3件 | 9.85e-06 | 1.16e-03 | 3件目
10件 20% 2件 | 4.27e-03 | 8.61e-02 | 2件目
10件 50% 5件 | 2.42e-08 | 6.37e-05 | 5件目
20件 20% 4件 | 4.26e-05 | 1.59e-02 | 4件目
20件 50% 10件 | 1.69e-15 | 1.13e-08 | 10件目
50件 20% 10件 | 7.13e-11 | 1.59e-04 | 10件目
50件 50% 25件 | 9.93e-37 | 1.10e-19 | 25件目
毎秒20件の呼び出しで、窓1つ=1判定とみなしたときの1日あたりの誤作動の目安
窓 5件・しきい値20%・平常時1%: 16937.8388 回/日
窓 5件・しきい値20%・平常時5%: 78181.3080 回/日
窓10件・しきい値20%・平常時1%: 737.1994 回/日
窓10件・しきい値20%・平常時5%: 14884.7079 回/日
窓10件・しきい値50%・平常時1%: 0.0042 回/日
窓10件・しきい値50%・平常時5%: 11.0056 回/日
窓20件・しきい値50%・平常時1%: 0.0000 回/日
窓20件・しきい値50%・平常時5%: 0.0010 回/日
この計算は「エラーが互いに独立に起きる」「窓を重ならない区切りで数える」という単純な前提の目安です。それでも傾向ははっきり出ます。
- 窓が小さく、しきい値が低いと、平常時でも頻繁に開きます。 窓5件・20% は、エラー1件で開く設定です。
- しきい値50%なら、窓を10件から20件にするだけで、平常時5%でも1日0.001回まで下がります。
- 完全に止まったときに開くまでの件数は「窓×しきい値」です。毎秒20件なら、窓20件・50% でも10件=0.5秒で開きます。窓を広げても、検知の遅れはわずかです。
値を決める5つの手順(数値は例)
| 手順 | 決めること | 例 |
|---|---|---|
| 1 | 平常時のエラー率と、混雑時のエラー率をログから出す | 平常時1%、混雑時5% |
| 2 | しきい値を、混雑時のエラー率の10倍前後に置く | 50% |
| 3 | 最小件数を、上の表で誤作動が1日1回を下回る件数にする | 20件 |
| 4 | Open の待機時間を、呼び出し先の再起動や切り替えにかかる時間より少し長くする | 再起動20秒 → 30秒 |
| 5 | Half-Open の試験呼び出しの数を決める | 2〜5件 |
呼び出しが少ない経路(1分に数件)では、件数の窓がなかなか埋まりません。その場合は「直近60秒」のように時間で区切る窓にして、最小件数を小さめ(5件など)にします。
参考:Resilience4j の既定値
Java の Resilience4j のドキュメントに載っている既定値です。上の考え方と同じく、しきい値50%・最小件数は大きめになっています。
| 設定名 | 既定値 | 意味 |
|---|---|---|
failureRateThreshold |
50(%) | エラー率のしきい値 |
slidingWindowType |
COUNT_BASED | 件数で区切る窓(時間で区切る窓も選べる) |
slidingWindowSize |
100 | 窓の大きさ |
minimumNumberOfCalls |
100 | 判定を始める最小件数 |
waitDurationInOpenState |
60000(ミリ秒) | Open のまま待つ時間 |
permittedNumberOfCallsInHalfOpenState |
10 | Half-Open で通す数 |
slowCallDurationThreshold |
60000(ミリ秒) | 遅い呼び出しとみなす時間 |
slowCallRateThreshold |
100(%) | 遅い呼び出しの割合のしきい値 |
同ドキュメントには、最小件数が10のとき「9件すべてがエラーでも、10件記録されるまでは開かない」とあります。既定値の100件のままだと、呼び出しの少ない経路ではなかなか判定が始まらないので、呼び出しの頻度に合わせて見直します。
リトライの回数と間隔はタイムアウト予算から逆算する
待ち時間は「初期間隔×倍率^(回数−1)」を上限で頭打ちにし、乱数でばらつかせる(ジッター)のが基本です。ここで決めたいのは式そのものではなく、合計が予算に収まるかです。
最悪の所要時間 = 1回のタイムアウト × 試行回数 + 待機の上限の合計 <= 呼び出し元が待てる時間
予算10秒、1回のタイムアウト2秒で、組み合わせごとに計算します。
# budget.py : リトライの組み合わせごとに「最悪の所要時間」を出し、予算に収まるかを判定する
def waits(base, factor, cap, attempts):
"""試行回数 attempts 回のあいだに入る待機(attempts-1 回)の上限を返す"""
return [min(cap, base * factor ** i) for i in range(attempts - 1)]
def worst_case(per_try_timeout, base, factor, cap, attempts):
w = waits(base, factor, cap, attempts)
return per_try_timeout * attempts + sum(w), w
BUDGET = 10.0 # 呼び出し元が待てる時間(秒)
PER_TRY = 2.0 # 1回の試行のタイムアウト(秒)
print(f"予算 {BUDGET} 秒 / 1回のタイムアウト {PER_TRY} 秒")
print("初期間隔 倍率 上限 回数 | 待機の内訳 | 待機計 | 最悪の合計 | 判定")
for base, factor, cap in [(0.1, 2, 2.0), (0.2, 2, 2.0), (0.5, 2, 2.0), (1.0, 2, 4.0), (0.5, 3, 5.0)]:
for attempts in (2, 3, 4, 5):
total, w = worst_case(PER_TRY, base, factor, cap, attempts)
ws = "+".join(f"{x:g}" for x in w)
mark = "収まる" if total <= BUDGET else "超える"
print(f"{base:>6g}s x{factor} {cap:g}s {attempts}回 | {ws:<21} | {sum(w):>5.1f}s | {total:>8.1f}s | {mark}")
print()
print("予算から回数を逆算(各組み合わせで収まる最大回数)")
for per_try in (0.5, 1.0, 2.0, 3.0):
row = []
for base, factor, cap in [(0.1, 2, 2.0), (0.5, 2, 2.0), (1.0, 2, 4.0)]:
n = 1
while worst_case(per_try, base, factor, cap, n + 1)[0] <= BUDGET:
n += 1
row.append(f"初期{base:g}s→{n}回")
print(f"1回のタイムアウト {per_try:g}s : " + " / ".join(row))
予算 10.0 秒 / 1回のタイムアウト 2.0 秒
初期間隔 倍率 上限 回数 | 待機の内訳 | 待機計 | 最悪の合計 | 判定
0.1s x2 2s 2回 | 0.1 | 0.1s | 4.1s | 収まる
0.1s x2 2s 3回 | 0.1+0.2 | 0.3s | 6.3s | 収まる
0.1s x2 2s 4回 | 0.1+0.2+0.4 | 0.7s | 8.7s | 収まる
0.1s x2 2s 5回 | 0.1+0.2+0.4+0.8 | 1.5s | 11.5s | 超える
0.2s x2 2s 2回 | 0.2 | 0.2s | 4.2s | 収まる
0.2s x2 2s 3回 | 0.2+0.4 | 0.6s | 6.6s | 収まる
0.2s x2 2s 4回 | 0.2+0.4+0.8 | 1.4s | 9.4s | 収まる
0.2s x2 2s 5回 | 0.2+0.4+0.8+1.6 | 3.0s | 13.0s | 超える
0.5s x2 2s 2回 | 0.5 | 0.5s | 4.5s | 収まる
0.5s x2 2s 3回 | 0.5+1 | 1.5s | 7.5s | 収まる
0.5s x2 2s 4回 | 0.5+1+2 | 3.5s | 11.5s | 超える
0.5s x2 2s 5回 | 0.5+1+2+2 | 5.5s | 15.5s | 超える
1s x2 4s 2回 | 1 | 1.0s | 5.0s | 収まる
1s x2 4s 3回 | 1+2 | 3.0s | 9.0s | 収まる
1s x2 4s 4回 | 1+2+4 | 7.0s | 15.0s | 超える
1s x2 4s 5回 | 1+2+4+4 | 11.0s | 21.0s | 超える
0.5s x3 5s 2回 | 0.5 | 0.5s | 4.5s | 収まる
0.5s x3 5s 3回 | 0.5+1.5 | 2.0s | 8.0s | 収まる
0.5s x3 5s 4回 | 0.5+1.5+4.5 | 6.5s | 14.5s | 超える
0.5s x3 5s 5回 | 0.5+1.5+4.5+5 | 11.5s | 21.5s | 超える
予算から回数を逆算(各組み合わせで収まる最大回数)
1回のタイムアウト 0.5s : 初期0.1s→7回 / 初期0.5s→5回 / 初期1s→4回
1回のタイムアウト 1s : 初期0.1s→6回 / 初期0.5s→4回 / 初期1s→3回
1回のタイムアウト 2s : 初期0.1s→4回 / 初期0.5s→3回 / 初期1s→3回
1回のタイムアウト 3s : 初期0.1s→3回 / 初期0.5s→2回 / 初期1s→2回
表から分かるのは、合計を左右するのは待機よりも「1回のタイムアウト×回数」だということです。待機の合計は20行のうち14行で3秒以下ですが、タイムアウト2秒×5回だけで10秒になります。回数を増やしたいときは、まず1回のタイムアウトを見直します。
決める順番は次のとおりです。
- 呼び出し元が待てる時間(予算)を確かめる。利用者が画面の前で待つ処理なら数秒、夜間の一括処理なら数分、というように決まります。
- 1回のタイムアウトを、呼び出し先の平常時の応答時間(99パーセンタイル)より少し長く置く。
- 表の式で、予算に収まる最大回数を出す。迷ったら3回(最初の1回+やり直し2回)から始めます。
- 実行時にも残り時間を見て、次の試行が予算を超えるならそこでやめる。
重ねる順番:全体期限 → リトライ → サーキットブレーカー → 1回のタイムアウト
外側から順に、次のように包みます。
| 層(外→内) | 役割 | 決める値 |
|---|---|---|
| 全体期限 | 利用者を待たせる上限を守る | 予算(例:5秒) |
| リトライ | 一時的なエラーをやり直す | 回数・初期間隔・倍率・上限 |
| サーキットブレーカー | 続くエラーを検知して止める | しきい値・最小件数・待機時間 |
| 1回のタイムアウト | 応答が無い試行を切り上げる | 接続・読み取りの秒数 |
| 呼び出し | — | — |
サーキットブレーカーをリトライの内側に置くのは、試行1回ごとの結果を数えさせるためです。1回のタイムアウトをさらに内側に置くのは、時間切れもエラーとして数えさせるためです。そして、遮断の例外(CircuitOpenError)を受け取ったらリトライせずにすぐ返します。Microsoft Learn のパターン解説も、サーキットブレーカー越しにリトライする形と、遮断が返ったらリトライをやめることを勧めています。
止まっている呼び出し先に、利用者のリクエストが20件続けて来た場合を比べます。
# layered.py : タイムアウト → リトライ → サーキットブレーカー の順に重ねる
import random
from breaker import CircuitBreaker, CircuitOpenError
now = 0.0
def clock(): return now
def sleep(sec):
global now; now += sec
calls = 0
def backend(timeout): # 止まっている呼び出し先。timeout 秒待って時間切れになる
global calls; calls += 1
sleep(timeout)
raise TimeoutError(f"timed out after {timeout}s")
def call_with_retry(cb, attempts=3, base=0.2, factor=2, cap=2.0, per_try=1.0, budget=5.0):
start = clock()
for n in range(1, attempts + 1):
try:
return cb.call(backend, per_try) if cb else backend(per_try)
except CircuitOpenError:
return "遮断中: 代わりの応答を返す" # 遮断はリトライしない
except TimeoutError:
wait = random.uniform(0, min(cap, base * factor ** (n - 1)))
if n == attempts or clock() - start + wait + per_try > budget:
return "あきらめる: エラーを返す"
sleep(wait)
random.seed(1)
for label, cb in (("リトライだけ", None),
("リトライ+サーキットブレーカー",
CircuitBreaker(window=10, min_calls=5, error_rate=0.5, open_seconds=30, clock=clock))):
now, calls = 0.0, 0
results = {}
for req in range(20): # 利用者のリクエスト20件
t0 = clock()
r = call_with_retry(cb)
results.setdefault(r, []).append(clock() - t0)
print(f"[{label}] 呼び出し先に届いた回数: {calls}、全体の所要: {now:.1f}秒")
for r, ts in results.items():
print(f" {r}: {len(ts)}件(1件あたり平均 {sum(ts)/len(ts):.2f}秒)")
[リトライだけ] 呼び出し先に届いた回数: 60、全体の所要: 66.4秒
あきらめる: エラーを返す: 20件(1件あたり平均 3.32秒)
[リトライ+サーキットブレーカー] 呼び出し先に届いた回数: 5、全体の所要: 5.5秒
あきらめる: エラーを返す: 1件(1件あたり平均 3.25秒)
遮断中: 代わりの応答を返す: 19件(1件あたり平均 0.12秒)
リトライだけだと、止まっている相手に 20件×3回=60回届き、利用者は毎回3秒あまり待ちます。サーキットブレーカーを挟むと、届くのは5回、3件目以降の利用者は待たずに応答を受け取ります。時間はコード内の時計を進めて計っているので、実行はすぐ終わります。
リトライとサーキットブレーカーの違いと使い分け
| 観点 | リトライ | サーキットブレーカー |
|---|---|---|
| ねらい | 一時的なエラーを乗り切る | 続くエラーのときに呼び出しを省く |
| 前提 | もう一度試せば通る | しばらく通らない |
| 見る範囲 | 1つのリクエスト | 同じ呼び出し先への全リクエスト |
| 状態 | 持たない(その場で完結) | 持つ(3状態を共有する) |
| 呼び出し先の負荷 | 増える(最大で回数倍) | 減る |
| 利用者の待ち時間 | 伸びる | 縮む(すぐ返る) |
| 向くエラー | 瞬断、切り替え直後、回数制限 | 停止、過負荷、長い応答遅れ |
| 決める値 | 回数・間隔・上限・予算 | しきい値・最小件数・待機時間・試験数 |
どちらを使うかは、次の表で決められます。
| 条件 | 選ぶもの |
|---|---|
| エラーが数秒以内におさまることが多い | リトライ |
| 呼び出し先が止まると、こちらのスレッドや接続が埋まる | サーキットブレーカー(+1回のタイムアウト) |
| 呼び出しが多く(毎秒数件以上)、相手が共有の資源 | 両方 |
| 呼び出しが1日数回の一括処理 | リトライだけ(窓が埋まらないため) |
| やり直すと二重になる操作で、冪等キーも無い | どちらも入れず、結果の照会で確かめる |
| 止まっている間も画面を出したい(代わりの値がある) | サーキットブレーカー+代わりの応答 |
サーキットブレーカーは呼び出し先ごとに1つ持ちます。全体で1つにまとめると、ある1つの相手のエラーで、元気な相手への呼び出しまで止まります。
設定値の記入シート(タブ区切り)
表計算ソフトに貼って、呼び出し先ごとに1行ずつ埋めます。値は記入例です。
| 呼び出し先 | 予算 | 1回のタイムアウト | 回数 | 初期間隔 | 倍率 | 上限 | 最悪の合計 | リトライ対象 | しきい値 | 最小件数 | Open待機 | 試験数 | 遮断中の応答 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 在庫照会API | 5秒 | 1秒 | 3 | 0.2秒 | 2 | 2秒 | 3.6秒 | 408/429/502/503/504・接続エラー | 50% | 20 | 30秒 | 2 | 前回の値を表示 |
| 通知送信API | 10秒 | 2秒 | 3 | 0.5秒 | 2 | 2秒 | 7.5秒 | 429/503のみ(POST) | 50% | 10 | 60秒 | 2 | キューに残して後で送る |
| 郵便番号検索 | 3秒 | 1秒 | 2 | 0.2秒 | 2 | 1秒 | 2.2秒 | 408/429/502/503/504・接続エラー | 50% | 10 | 30秒 | 2 | 手入力に切り替える |
呼び出し先 予算 1回のタイムアウト 回数 初期間隔 倍率 上限 最悪の合計 リトライ対象 しきい値 最小件数 Open待機 試験数 遮断中の応答
在庫照会API 5秒 1秒 3 0.2秒 2 2秒 3.6秒 408/429/502/503/504・接続エラー 50% 20 30秒 2 前回の値を表示
通知送信API 10秒 2秒 3 0.5秒 2 2秒 7.5秒 429/503のみ(POST) 50% 10 60秒 2 キューに残して後で送る
郵便番号検索 3秒 1秒 2 0.2秒 2 1秒 2.2秒 408/429/502/503/504・接続エラー 50% 10 30秒 2 手入力に切り替える
「最悪の合計」は budget.py の式(1回のタイムアウト×回数+待機の上限の合計)で出した値です。
確かめて分かったこと
- 開くまでのエラー数は、直前の成功の数で変わります。 窓10件・最小5件・50% の設定で、成功3件のあとにエラーが続くと、開いたのは2件目ではなく3件目でした。
- 復帰に気づくまで、最大で Open の待機時間だけ遅れます。 実行例では、呼び出し先が戻ってから Closed に戻るまで11秒かかりました。待機時間を長くするほど、相手を休ませられる代わりに復帰が遅れます。
- 窓の大きさは、検知の速さより誤作動の少なさに効きます。 窓10件→20件で、平常時5%のときの誤作動の目安は1日11回から0.001回になり、完全に止まったときの検知は5件目から10件目に変わるだけでした。
- 所要時間を決めるのは待機より1回のタイムアウトです。 予算10秒・タイムアウト2秒では、待機をどう選んでも5回は収まりませんでした。
- 遮断の例外をリトライ対象から外すことが要点です。 外さないと、遮断で即座に返った応答に対しても待機が入り、すぐ返せる利点が薄れます。
- RFC 9110 に 429 の定義はありません。 429 は RFC 6585 で定義され、
Retry-Afterを付けてよいとされています。
よくある質問
Q. サーキットブレーカーとリトライの違いは? A. リトライは1つのリクエストをやり直す仕組み、サーキットブレーカーは同じ呼び出し先への呼び出し全体を一時的に止める仕組みです。前者は一時的なエラー、後者は続くエラーに向き、リトライの内側にサーキットブレーカーを置いて併用します。
Q. リトライ回数は何回が適切ですか? A. 決まった正解は無く、「1回のタイムアウト×回数+待機の合計」が呼び出し元の待てる時間に収まる最大回数が上限です。出発点は3回(最初の1回+やり直し2回)で、予算が短ければ2回にします。
Q. Half-Open とは何ですか? A. Open の待機時間が過ぎたあと、少数の呼び出しだけを通して回復を確かめる状態です。通した呼び出しが規定回数成功すれば Closed に戻り、1件でもエラーなら Open に戻って、もう一度待ちます。
Q. POST はリトライできないのですか? A. 冪等キー(同じ依頼であることを示す一意な値)を付ければリトライできます。キーが無い場合は、サーバーが受け付けていないと分かる 429 などに限り、時間切れのときは結果を照会してから判断します。
Q. サーキットブレーカーが開いている間、利用者には何を返しますか? A. 代わりの応答をあらかじめ決めておきます。直前に取得した値、既定値、「ただいま混み合っています」という案内、あとで処理するためのキューへの登録などが候補です。
まとめ
- サーキットブレーカーは Closed・Open・Half-Open の3状態で、続くエラーを検知して呼び出しを止め、少数の試験呼び出しで復帰を確かめます。
- リトライの回数と間隔は、タイムアウト予算から逆算します。合計を左右するのは1回のタイムアウト×回数です。
- リトライの対象は、一時的なエラー×やり直しても結果が変わらない操作に限ります。POST は冪等キーがあるときだけです。
- しきい値は混雑時のエラー率の10倍前後、最小件数は10〜20件から始め、二項分布で誤作動の頻度を確かめます。
- 重ねる順は 全体期限 → リトライ → サーキットブレーカー → 1回のタイムアウト。遮断が返ったらリトライをやめます。
- サーキットブレーカーは呼び出し先ごとに1つ持ち、遮断中に返す応答を先に決めておきます。
参考・出典
- RFC 9110 HTTP Semantics(9.2.2 Idempotent Methods、10.2.3 Retry-After、15.5・15.6 のステータスコード)https://www.rfc-editor.org/rfc/rfc9110.html
- RFC 6585 Additional HTTP Status Codes(4. 429 Too Many Requests)https://www.rfc-editor.org/rfc/rfc6585.html
- Microsoft Learn「Circuit Breaker pattern」https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker
- Resilience4j「CircuitBreaker」https://resilience4j.readme.io/docs/circuitbreaker
- PostgreSQL 16 Documentation「13.5. Serialization Failure Handling」https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「サーキットブレーカーとは|3状態の実装とリトライ可否の一覧表」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。