はじめに:「とりあえず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)と呼ばれ、少し待てば回復することが多いです。
リトライの目的
リトライの目的は:
- 可用性の向上:一時的な失敗を自動回復
- ユーザー体験の向上:ユーザーに「エラーです、もう一度試してください」と言わない
- 運用負荷の軽減:人手の介入なしに回復
ただし、間違ったリトライは状況を悪化させます。
第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〜2回で十分
- デプロイなら数分かかる
-
許容できる待ち時間
- ユーザーが待てるのは数秒
- バッチ処理なら数分待てる
-
リトライのコスト
- 相手サーバーへの負荷
- 自分のリソース消費
回数の目安
| シナリオ | 推奨回数 | 理由 |
|---|---|---|
| ユーザー向け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章:まとめ
リトライ設計の原則
-
リトライすべきエラーを選ぶ
- 一時的な失敗のみリトライ
- 認証エラー、クライアントエラーはリトライしない
-
適切な回数と時間を設定する
- ユースケースに応じて調整
- 「回数」より「最大時間」で考える
-
指数バックオフ + ジッターを使う
- サーバーに回復時間を与える
- サンダリングハード問題を防ぐ
-
冪等性を確保する
- 同じ操作を2回実行しても結果が同じになるように
- 冪等キーを使う
-
サーキットブレーカーと組み合わせる
- 長時間の障害では即座にエラーを返す
- 回復を検知して自動復帰
-
フォールバックを用意する
- 全て失敗した時の代替手段
- 縮退運転でサービス継続
最終的な設計例
# 推奨設定の例
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回」ではなく、なぜその設計なのかを説明できるようにしましょう。適切なリトライ設計は、システムの可用性を大きく向上させます。
関連記事
- なぜ「キュー」を入れると人間の仕事が減るのか — 非同期処理とリトライの関係
- 【保存版】ログ設計完全ガイド — リトライのログをどう設計するか
- 本番環境でやらかした時のメンタルの守り方 — リトライでも直らなかった時のために