サーキットブレーカーとは、エラーが続いている呼び出し先への呼び出しを一定時間止め、すぐにエラー(または代わりの応答)を返す仕組みです。 リトライが「もう一度試せば通る」ことを期待するのに対し、サーキットブレーカーは「しばらく通らない」と判断して呼び出しそのものを省きます。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. 呼び出し元が待てる時間(予算)を確かめる。利用者が画面の前で待つ処理なら数秒、夜間の一括処理なら数分、というように決まります。
  2. 1回のタイムアウトを、呼び出し先の平常時の応答時間(99パーセンタイル)より少し長く置く。
  3. 表の式で、予算に収まる最大回数を出す。迷ったら3回(最初の1回+やり直し2回)から始めます。
  4. 実行時にも残り時間を見て、次の試行が予算を超えるならそこでやめる。

重ねる順番:全体期限 → リトライ → サーキットブレーカー → 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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?