はじめに:「とりあえず3回リトライ」の危険性

こんなコードを書いたことはありませんか?

    for i in range(3):
    try:
        response = call_api()
        break
    except:
        time.sleep(1)
  

「とりあえず3回リトライ、1秒待つ」。動くことは動きます。でも、これには多くの問題が潜んでいます。

  • なぜ3回?根拠は?
  • なぜ1秒?短すぎない?
  • 全てのエラーでリトライすべき?
  • 相手のサーバーに負荷をかけていない?

この記事では、本当に意味のあるリトライ設計を解説します。


第1章:なぜリトライが必要なのか

分散システムでは失敗が「普通」

単一のプログラムなら、関数呼び出しは(ほぼ)確実に成功します。でも、ネットワークを介した通信では:

    graph LR
    A[クライアント] -->|リクエスト| B[ネットワーク]
    B -->|リクエスト| C[サーバー]
    C -->|レスポンス| B
    B -->|レスポンス| A

    D[失敗ポイント] -.-> B
    E[失敗ポイント] -.-> C

    style D fill:#f44336,stroke:#333,color:#fff
    style E fill:#f44336,stroke:#333,color:#fff
  

失敗する理由:

  • ネットワークの瞬断
  • サーバーの一時的な過負荷
  • タイムアウト
  • 接続数の上限
  • デプロイ中の一時的なダウン

これらは一時的な失敗(Transient Failure)と呼ばれ、少し待てば回復することが多いです。

リトライの目的

リトライの目的は:

  1. 可用性の向上:一時的な失敗を自動回復
  2. ユーザー体験の向上:ユーザーに「エラーです、もう一度試してください」と言わない
  3. 運用負荷の軽減:人手の介入なしに回復

ただし、間違ったリトライは状況を悪化させます


第2章:リトライすべきエラー・すべきでないエラー

リトライすべきエラー(Retryable)

エラーの種類 理由
タイムアウト 接続タイムアウト、読み取りタイムアウト サーバーが一時的に遅いだけかもしれない
接続エラー Connection refused, Connection reset サーバーが再起動中かもしれない
一時的なサーバーエラー 503 Service Unavailable サーバーが過負荷で一時的に拒否している
レート制限 429 Too Many Requests 時間を空ければ回復する

リトライすべきでないエラー(Non-Retryable)

エラーの種類 理由
認証エラー 401 Unauthorized, 403 Forbidden 何度試しても同じ結果
クライアントエラー 400 Bad Request, 404 Not Found リクエストが間違っている
ビジネスロジックエラー 「残高不足」「在庫なし」 状況が変わらない限り同じ結果
永続的なサーバーエラー 500でサーバーがクラッシュ リトライで直らない

判断のフローチャート

    flowchart TD
    Start[エラー発生] --> Q1{HTTPステータスは?}

    Q1 -->|4xx| Q2{401/403?}
    Q2 -->|Yes| NO1[リトライ不可<br/>認証エラー]
    Q2 -->|No| Q3{429?}
    Q3 -->|Yes| YES1[リトライ可<br/>待ち時間を長くして]
    Q3 -->|No| NO2[リトライ不可<br/>クライアントエラー]

    Q1 -->|5xx| Q4{503?}
    Q4 -->|Yes| YES2[リトライ可<br/>サーバー一時停止]
    Q4 -->|No| Q5{500で冪等?}
    Q5 -->|Yes| YES3[リトライ可<br/>慎重に]
    Q5 -->|No| NO3[リトライ不可<br/>副作用の重複リスク]

    Q1 -->|タイムアウト| YES4[リトライ可]
    Q1 -->|接続エラー| YES5[リトライ可]

    style NO1 fill:#f44336,stroke:#333,color:#fff
    style NO2 fill:#f44336,stroke:#333,color:#fff
    style NO3 fill:#f44336,stroke:#333,color:#fff
    style YES1 fill:#4caf50,stroke:#333,color:#fff
    style YES2 fill:#4caf50,stroke:#333,color:#fff
    style YES3 fill:#4caf50,stroke:#333,color:#fff
    style YES4 fill:#4caf50,stroke:#333,color:#fff
    style YES5 fill:#4caf50,stroke:#333,color:#fff
  

第3章:リトライ回数の設計

「3回」に根拠はあるか?

よく見る「3回リトライ」には、実は明確な根拠がないことが多いです。

考慮すべき要素:

  1. 想定される障害の長さ

    • 瞬断なら1〜2回で十分
    • デプロイなら数分かかる
  2. 許容できる待ち時間

    • ユーザーが待てるのは数秒
    • バッチ処理なら数分待てる
  3. リトライのコスト

    • 相手サーバーへの負荷
    • 自分のリソース消費

回数の目安

シナリオ 推奨回数 理由
ユーザー向けAPI 2〜3回 待ち時間の制限
バックエンド間通信 3〜5回 多少待てる
バッチ処理 5〜10回 時間に余裕がある
重要な処理(決済等) 回数より「時間」で制限 確実に完了させたい

「回数」より「時間」で考える

回数ではなく、**「最大何秒まで試すか」**で設計する方が現実的です。

    MAX_RETRY_DURATION = 30  # 最大30秒まで試す
start_time = time.time()

while time.time() - start_time < MAX_RETRY_DURATION:
    try:
        response = call_api()
        break
    except RetryableError:
        wait_time = calculate_backoff()
        time.sleep(wait_time)
  

第4章:待ち時間(バックオフ)の設計

固定間隔の問題

    # 悪い例:固定間隔
for i in range(3):
    try:
        response = call_api()
        break
    except:
        time.sleep(1)  # 毎回1秒
  

問題点:

  • サーバーが過負荷の時、1秒後にまた叩くと状況が悪化
  • 多くのクライアントが同時にリトライすると、同じタイミングで再度殺到

指数バックオフ(Exponential Backoff)

リトライごとに待ち時間を2倍にしていく方式です。

    1回目:1秒後
2回目:2秒後
3回目:4秒後
4回目:8秒後
5回目:16秒後
  
    def exponential_backoff(attempt, base=1, max_delay=60):
    delay = min(base * (2 ** attempt), max_delay)
    return delay
  

メリット:

  • サーバーに回復する時間を与える
  • リトライが進むほど負荷が減る

ジッター(Jitter)の追加

指数バックオフだけでは、サンダリングハード問題が起きます。

    サーバー障害発生
1000クライアントが同時に失敗
全員が1秒後にリトライ
サーバーに1000リクエストが殺到
また全員失敗
全員が2秒後にリトライ
無限ループ...
  

これを防ぐために、**ランダムな遅延(ジッター)**を加えます。

    import random

def exponential_backoff_with_jitter(attempt, base=1, max_delay=60):
    delay = min(base * (2 ** attempt), max_delay)
    jitter = random.uniform(0, delay * 0.5)  # 0〜50%のランダム
    return delay + jitter
  

バックオフ戦略の比較

    graph TD
    subgraph "固定間隔"
        A1[1秒] --> A2[1秒] --> A3[1秒] --> A4[1秒]
    end

    subgraph "指数バックオフ"
        B1[1秒] --> B2[2秒] --> B3[4秒] --> B4[8秒]
    end

    subgraph "指数バックオフ + ジッター"
        C1[1.2秒] --> C2[2.7秒] --> C3[5.1秒] --> C4[9.8秒]
    end
  

推奨設定

    RETRY_CONFIG = {
    "max_attempts": 5,
    "base_delay": 1.0,      # 初回の待ち時間(秒)
    "max_delay": 30.0,      # 最大待ち時間(秒)
    "exponential_base": 2,  # 倍率
    "jitter": True,         # ジッターを有効化
}
  

第5章:冪等性(べきとうせい)とリトライ

冪等性とは

同じ操作を何度実行しても、結果が変わらない性質。

    冪等な操作:
- GET /users/123      → 何度呼んでも同じ結果
- PUT /users/123      → 何度呼んでも同じ状態になる
- DELETE /users/123   → 1回目で削除、2回目以降は「存在しない」

冪等でない操作:
- POST /orders        → 呼ぶたびに新しい注文が作成される
- POST /payments      → 呼ぶたびに決済が実行される
  

なぜ冪等性が重要か

    sequenceDiagram
    participant C as クライアント
    participant S as サーバー
    participant DB as データベース

    C->>S: POST /orders (注文作成)
    S->>DB: INSERT order
    DB-->>S: OK
    S--xC: タイムアウト(レスポンスが届かない)

    Note over C: 「失敗した」と判断してリトライ

    C->>S: POST /orders (同じ内容でリトライ)
    S->>DB: INSERT order
    DB-->>S: OK
    S-->>C: 201 Created

    Note over DB: 注文が2件作成されてしまった!
  

実は1回目は成功していたのに、レスポンスが返ってくる前にタイムアウトした。リトライにより、同じ注文が2件作成されてしまいます。

冪等性を確保する方法

方法1:冪等キー(Idempotency Key)

クライアントがユニークなキーを生成し、リクエストに含めます。

    import uuid

headers = {
    "Idempotency-Key": str(uuid.uuid4())
}

# 同じキーで複数回リクエストしても、1回だけ処理される
response = requests.post("/orders", headers=headers, json=order_data)
  

サーバー側は、同じキーのリクエストが来たら:

  • 処理済みなら、保存していた結果を返す
  • 未処理なら、処理して結果を保存する

方法2:条件付き更新

    -- 悪い例:単純なUPDATE
UPDATE accounts SET balance = balance - 1000 WHERE id = 123

-- 良い例:条件付きUPDATE
UPDATE accounts
SET balance = balance - 1000, version = version + 1
WHERE id = 123 AND version = 5
  

バージョンが変わっていたら更新しない。これにより、同じ操作を2回実行しても1回だけ反映されます。

方法3:状態遷移の設計

    # 悪い例:直接状態を変更
order.status = "shipped"

# 良い例:遷移可能かチェック
if order.status == "paid":
    order.status = "shipped"
else:
    raise InvalidStateTransition()
  

第6章:サーキットブレーカー

リトライだけでは足りない場合

リトライは「一時的な失敗」に対処するものです。でも、サーバーが完全にダウンしている場合はどうでしょう?

    サーバー完全停止
クライアントがリトライ(5回、30秒)
タイムアウト
次のリクエストが来る
またリトライ(5回、30秒)
無限に待たされる...
  

これを防ぐのがサーキットブレーカーです。

サーキットブレーカーの仕組み

    stateDiagram-v2
    [*] --> Closed: 初期状態

    Closed --> Open: 失敗が閾値を超えた
    Open --> HalfOpen: 一定時間経過
    HalfOpen --> Closed: 成功
    HalfOpen --> Open: 失敗

    note right of Closed: 通常運転\nリクエストを通す
    note right of Open: 遮断状態\n即座にエラーを返す
    note right of HalfOpen: 試験状態\n1リクエストだけ試す
  

3つの状態:

状態 動作 遷移条件
Closed(閉) 通常通りリクエストを送信 失敗率が閾値を超えたらOpenへ
Open(開) リクエストを送らず即エラー 一定時間経過でHalf-Openへ
Half-Open(半開) 1リクエストだけ試す 成功→Closed、失敗→Open

サーキットブレーカーの実装例

    import time
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class CircuitBreaker:
    def __init__(
        self,
        failure_threshold=5,      # 何回失敗でOpenにするか
        recovery_timeout=30,      # Open→Half-Openまでの秒数
        success_threshold=2,      # Half-Open→Closedに必要な成功数
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.success_threshold = success_threshold

        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.success_count = 0
        self.last_failure_time = None

    def call(self, func, *args, **kwargs):
        if self.state == CircuitState.OPEN:
            if self._should_try_recovery():
                self.state = CircuitState.HALF_OPEN
            else:
                raise CircuitOpenError("Circuit is open")

        try:
            result = func(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise

    def _should_try_recovery(self):
        return time.time() - self.last_failure_time >= self.recovery_timeout

    def _on_success(self):
        if self.state == CircuitState.HALF_OPEN:
            self.success_count += 1
            if self.success_count >= self.success_threshold:
                self.state = CircuitState.CLOSED
                self.failure_count = 0
                self.success_count = 0

    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()

        if self.state == CircuitState.HALF_OPEN:
            self.state = CircuitState.OPEN
            self.success_count = 0
        elif self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN
  

リトライとサーキットブレーカーの組み合わせ

    circuit_breaker = CircuitBreaker()

def call_with_retry_and_circuit_breaker():
    for attempt in range(MAX_ATTEMPTS):
        try:
            return circuit_breaker.call(call_api)
        except CircuitOpenError:
            # サーキットが開いている場合は即座にフォールバック
            return fallback_response()
        except RetryableError:
            if attempt < MAX_ATTEMPTS - 1:
                time.sleep(exponential_backoff(attempt))
            else:
                raise
  

第7章:フォールバック戦略

リトライが全て失敗した時

リトライを尽くしても失敗することはあります。その時、どうするか?

フォールバックのパターン

パターン1:デフォルト値を返す

    def get_user_recommendations(user_id):
    try:
        return recommendation_service.get(user_id)
    except ServiceUnavailable:
        # 推薦サービスが落ちていたら人気商品を返す
        return get_popular_items()
  

パターン2:キャッシュを返す

    def get_exchange_rate(currency):
    try:
        rate = exchange_service.get_rate(currency)
        cache.set(f"rate:{currency}", rate)
        return rate
    except ServiceUnavailable:
        # 最新レートが取れなければキャッシュを返す
        cached_rate = cache.get(f"rate:{currency}")
        if cached_rate:
            return cached_rate
        raise
  

パターン3:縮退運転(Graceful Degradation)

    def process_order(order):
    # 在庫サービスが落ちていても、注文は受け付ける
    try:
        inventory_service.reserve(order.items)
    except ServiceUnavailable:
        # 在庫チェックはスキップ、後で手動確認
        order.needs_inventory_check = True
        notify_operations_team(order)

    # 注文自体は作成する
    order_repository.save(order)
    return order
  

パターン4:キューに入れて後で処理

    def send_notification(user_id, message):
    try:
        notification_service.send(user_id, message)
    except ServiceUnavailable:
        # 送信キューに入れて後で再試行
        notification_queue.enqueue({
            "user_id": user_id,
            "message": message,
            "retry_after": datetime.now() + timedelta(minutes=5)
        })
  

フォールバックの選び方

状況 推奨フォールバック
最新データが必須でない キャッシュを返す
機能がなくても致命的でない 縮退運転
処理を遅延しても問題ない キューに入れる
代替データがある デフォルト値を返す
どれも該当しない エラーを返す(正直に)

第8章:言語別実装例

Python(tenacity)

    from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
)
import requests

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=30),
    retry=retry_if_exception_type((requests.Timeout, requests.ConnectionError)),
)
def call_api():
    response = requests.get("https://api.example.com/data", timeout=10)
    response.raise_for_status()
    return response.json()
  

JavaScript/TypeScript(async-retry)

    import retry from 'async-retry';
import fetch from 'node-fetch';

async function callApi() {
  return await retry(
    async (bail, attempt) => {
      const response = await fetch('https://api.example.com/data');

      // リトライ不可のエラーはbailで即終了
      if (response.status === 401) {
        bail(new Error('Unauthorized'));
        return;
      }

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      return response.json();
    },
    {
      retries: 5,
      factor: 2,        // 指数の底
      minTimeout: 1000, // 最小待ち時間
      maxTimeout: 30000,// 最大待ち時間
      randomize: true,  // ジッター
    }
  );
}
  

Go(標準ライブラリ)

    package main

import (
    "math"
    "math/rand"
    "net/http"
    "time"
)

func callWithRetry(url string, maxAttempts int) (*http.Response, error) {
    var lastErr error

    for attempt := 0; attempt < maxAttempts; attempt++ {
        resp, err := http.Get(url)
        if err == nil && resp.StatusCode < 500 {
            return resp, nil
        }

        lastErr = err

        // 指数バックオフ + ジッター
        delay := math.Min(float64(time.Second)*math.Pow(2, float64(attempt)), 30)
        jitter := rand.Float64() * delay * 0.5
        time.Sleep(time.Duration(delay+jitter) * time.Second)
    }

    return nil, lastErr
}
  

第9章:リトライ設計チェックリスト

設計時に確認すること

    □ リトライすべきエラーとすべきでないエラーを区別しているか
□ 最大リトライ回数(または最大時間)を設定しているか
□ 指数バックオフを使っているか
□ ジッターを追加しているか
□ 冪等性を確保しているか(または冪等キーを使っているか)
□ サーキットブレーカーを検討したか
□ フォールバック戦略を決めているか
□ リトライのログを出力しているか
□ リトライ回数のメトリクスを取得しているか
  

よくある間違い

間違い 正しい対応
全てのエラーでリトライ リトライ可能なエラーのみ
固定間隔でリトライ 指数バックオフ + ジッター
無限リトライ 最大回数または最大時間を設定
冪等性を考慮していない 冪等キーまたは条件付き更新
リトライのログがない 失敗と回復をログに記録

第10章:まとめ

リトライ設計の原則

  1. リトライすべきエラーを選ぶ

    • 一時的な失敗のみリトライ
    • 認証エラー、クライアントエラーはリトライしない
  2. 適切な回数と時間を設定する

    • ユースケースに応じて調整
    • 「回数」より「最大時間」で考える
  3. 指数バックオフ + ジッターを使う

    • サーバーに回復時間を与える
    • サンダリングハード問題を防ぐ
  4. 冪等性を確保する

    • 同じ操作を2回実行しても結果が同じになるように
    • 冪等キーを使う
  5. サーキットブレーカーと組み合わせる

    • 長時間の障害では即座にエラーを返す
    • 回復を検知して自動復帰
  6. フォールバックを用意する

    • 全て失敗した時の代替手段
    • 縮退運転でサービス継続

最終的な設計例

    # 推奨設定の例
RETRY_CONFIG = {
    "max_attempts": 5,
    "max_duration_seconds": 30,
    "base_delay_seconds": 1.0,
    "max_delay_seconds": 10.0,
    "exponential_base": 2,
    "jitter_factor": 0.5,
    "retryable_exceptions": [Timeout, ConnectionError, ServiceUnavailable],
    "retryable_status_codes": [429, 503, 504],
}

CIRCUIT_BREAKER_CONFIG = {
    "failure_threshold": 5,
    "recovery_timeout_seconds": 30,
    "success_threshold": 2,
}
  

リトライは、分散システムにおける基本的なレジリエンスパターンです。

「とりあえず3回」ではなく、なぜその設計なのかを説明できるようにしましょう。適切なリトライ設計は、システムの可用性を大きく向上させます。


関連記事