この記事の対象読者

  • 外部APIを呼び出すコードを書いている人
  • 「たまに失敗する」処理に悩んでいる人
  • 障害が連鎖して全体が落ちた経験がある人
  • マイクロサービスを設計・運用している人

この記事では、リトライサーキットブレーカータイムアウトの3つの耐障害パターンを体系的に解説します。これらは単独でも強力ですが、組み合わせることで真価を発揮します。


なぜ耐障害設計が必要なのか

分散システムの現実

    graph LR
    subgraph ideal["理想"]
        A1["サービスA<br/>✅ 常に正常"]
        B1["サービスB<br/>✅ 常に正常"]
        C1["サービスC<br/>✅ 常に正常"]
        A1 --> B1 --> C1
    end

    subgraph reality["現実"]
        A2["サービスA<br/>✅ 正常"]
        B2["サービスB<br/>⚠️ たまに遅い<br/>ネットワーク断<br/>タイムアウト"]
        C2["サービスC<br/>❌ ときどき落ちる"]
        A2 --> B2 --> C2
    end

    style ideal fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
    style reality fill:#ffebee,stroke:#f44336,stroke-width:2px
    style A1 fill:#c8e6c9,stroke:#2e7d32
    style B1 fill:#c8e6c9,stroke:#2e7d32
    style C1 fill:#c8e6c9,stroke:#2e7d32
    style A2 fill:#c8e6c9,stroke:#2e7d32
    style B2 fill:#fff3e0,stroke:#f57c00
    style C2 fill:#ffcdd2,stroke:#c62828
  

何も対策しないと

    sequenceDiagram
    participant A as サービスA
    participant B as サービスB
    participant C as サービスC

    Note over C: 1. サービスCが遅くなる
    A->>B: リクエスト
    B->>C: リクエスト
    Note over B,C: 2. サービスBがCを待ち続ける
    Note over B: 3. スレッド枯渇
    A->>B: 別のリクエスト
    Note over A,B: 4. サービスAもBを待ち続ける
    Note over A: スレッド枯渇
    Note over A,B,C: 5. 全体が連鎖的に死ぬ 💀
  

これが「障害の連鎖」です。

3つの武器

パターン 役割 例え
タイムアウト 待ち時間を制限 「5秒待ってダメなら諦める」
リトライ 一時的な障害を乗り越える 「失敗したらもう一度」
サーキットブレーカー 連鎖障害を防ぐ 「壊れた相手に連絡しない」

第1部:タイムアウト

タイムアウトとは

タイムアウト は、処理の待ち時間に上限を設けることです。

    # タイムアウトなし(危険)
response = requests.get("https://slow-api.example.com/data")
# → 相手が応答しなければ永遠に待つ

# タイムアウトあり(安全)
response = requests.get("https://slow-api.example.com/data", timeout=5)
# → 5秒で諦める
  

なぜタイムアウトが必要か

    graph TB
    subgraph without["タイムアウトなし"]
        W1["リクエスト"]
        W2["待機..."]
        W3["待機..."]
        W4["待機..."]
        W5["スレッド/コネクション枯渇"]
        W6["他のリクエストも<br/>処理できなくなる"]

        W1 --> W2 --> W3 --> W4 --> W5 --> W6
    end

    subgraph with["タイムアウトあり"]
        T1["リクエスト"]
        T2["待機"]
        T3["タイムアウト"]
        T4["リソース解放"]
        T5["他のリクエストを<br/>処理可能"]

        T1 --> T2 --> T3 --> T4 --> T5
    end

    style without fill:#ffebee,stroke:#f44336,stroke-width:2px
    style with fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
    style W5 fill:#ffcdd2,stroke:#c62828
    style W6 fill:#ffcdd2,stroke:#c62828
    style T4 fill:#c8e6c9,stroke:#2e7d32
    style T5 fill:#c8e6c9,stroke:#2e7d32
  

タイムアウトの種類

1. コネクションタイムアウト

TCP接続の確立までの制限時間

    import requests

# コネクションタイムアウト: 3秒
# 読み取りタイムアウト: 10秒
response = requests.get(
    "https://api.example.com/data",
    timeout=(3, 10)  # (connect, read)
)
  

2. 読み取り(リード)タイムアウト

データ受信の制限時間

    import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)  # 10秒
sock.connect(("example.com", 80))
data = sock.recv(1024)  # 10秒以内にデータが来なければタイムアウト
  

3. リクエスト全体のタイムアウト

接続からレスポンス完了までの総時間

    import httpx

# 全体で30秒
with httpx.Client(timeout=30.0) as client:
    response = client.get("https://api.example.com/data")

# より細かく設定
timeout = httpx.Timeout(
    connect=5.0,      # 接続
    read=10.0,        # 読み取り
    write=5.0,        # 書き込み
    pool=5.0          # プールからの取得
)
  

適切なタイムアウト値の決め方

考慮すべき要素

    タイムアウト値 =
    通常時の応答時間(p99)
    + バッファ(20-50%)
    + 許容できる待ち時間
  

実測に基づく設定

    import time
import statistics

# 100回リクエストして応答時間を測定
response_times = []
for _ in range(100):
    start = time.time()
    requests.get("https://api.example.com/data", timeout=30)
    response_times.append(time.time() - start)

# 統計を取る
print(f"平均: {statistics.mean(response_times):.3f}s")
print(f"p50: {statistics.median(response_times):.3f}s")
print(f"p99: {statistics.quantiles(response_times, n=100)[98]:.3f}s")
print(f"最大: {max(response_times):.3f}s")

# 結果例:
# 平均: 0.150s
# p50: 0.120s
# p99: 0.450s
# 最大: 1.200s

# → タイムアウト値: 0.450 * 1.5 ≈ 0.7秒 or 1秒
  

ユースケース別の目安

ユースケース 推奨タイムアウト
ヘルスチェック 1-3秒
社内API 3-10秒
外部API(決済等) 10-30秒
ファイルアップロード 60-300秒
バッチ処理 処理内容による

データベース接続のタイムアウト

MySQL

    import mysql.connector

conn = mysql.connector.connect(
    host="localhost",
    user="root",
    password="password",
    database="mydb",
    connection_timeout=10,    # 接続タイムアウト
    read_timeout=30,          # 読み取りタイムアウト
    write_timeout=30          # 書き込みタイムアウト
)
  
    -- クエリ単位のタイムアウト
SET SESSION MAX_EXECUTION_TIME = 30000;  -- 30秒(ミリ秒)
SELECT * FROM large_table WHERE ...;
  

PostgreSQL

    import psycopg2

conn = psycopg2.connect(
    host="localhost",
    dbname="mydb",
    user="user",
    password="password",
    connect_timeout=10,       # 接続タイムアウト
    options="-c statement_timeout=30000"  # クエリタイムアウト(ミリ秒)
)
  

HTTPクライアント別の設定

Python requests

    import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

session = requests.Session()

# タイムアウトをデフォルト化
class TimeoutHTTPAdapter(HTTPAdapter):
    def __init__(self, *args, timeout=10, **kwargs):
        self.timeout = timeout
        super().__init__(*args, **kwargs)

    def send(self, request, **kwargs):
        kwargs.setdefault('timeout', self.timeout)
        return super().send(request, **kwargs)

session.mount('http://', TimeoutHTTPAdapter(timeout=10))
session.mount('https://', TimeoutHTTPAdapter(timeout=10))

# これでtimeout指定なしでも10秒でタイムアウト
response = session.get("https://api.example.com/data")
  

Node.js axios

    const axios = require('axios');

const client = axios.create({
  timeout: 10000,  // 10秒
  // または個別に設定
  // timeoutErrorMessage: 'Request timed out'
});

// リクエスト
try {
  const response = await client.get('https://api.example.com/data');
} catch (error) {
  if (error.code === 'ECONNABORTED') {
    console.log('Timeout!');
  }
}
  

Go

    package main

import (
    "context"
    "net/http"
    "time"
)

func main() {
    // クライアントレベルのタイムアウト
    client := &http.Client{
        Timeout: 10 * time.Second,
    }

    // または context で制御
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    resp, err := client.Do(req)
    if err != nil {
        // context.DeadlineExceeded ならタイムアウト
    }
}
  

第2部:リトライ

リトライとは

リトライ は、失敗した処理を再試行することです。

    # 単純なリトライ
for attempt in range(3):
    try:
        response = requests.get("https://api.example.com/data")
        break
    except requests.RequestException:
        if attempt == 2:
            raise
        time.sleep(1)
  

なぜリトライが必要か

ネットワークや外部サービスには 一時的な障害(Transient Fault) があります:

    一時的な障害の例:
- ネットワークの瞬断
- サーバーの一時的な過負荷
- DNS解決の失敗
- コネクションプールの枯渇
- ガベージコレクションによる一時停止
  

これらは少し待てば解消することが多い。

リトライすべき場合・すべきでない場合

リトライすべき

    ✅ ネットワークエラー(タイムアウト、接続拒否)
✅ 5xx エラー(502, 503, 504)
✅ 429 Too Many Requests(レート制限)
✅ データベースのデッドロック
✅ 一時的なリソース不足
  

リトライすべきでない

    ❌ 4xx エラー(400, 401, 403, 404)
   → クライアント側の問題、リトライしても無駄

❌ ビジネスロジックのエラー
   → 「在庫なし」をリトライしても在庫は増えない

❌ 認証エラー
   → トークンを更新しないと意味がない

❌ 冪等でない操作(注意が必要)
   → 二重課金のリスク
  

リトライ戦略

1. 即時リトライ

    # 最もシンプル(ただし危険)
for attempt in range(3):
    try:
        return call_api()
    except Exception:
        if attempt == 2:
            raise
  

問題: 障害中のサーバーにリクエストが集中(雪崩効果)

2. 固定間隔リトライ

    import time

def retry_fixed(func, max_retries=3, delay=1.0):
    for attempt in range(max_retries):
        try:
            return func()
        except Exception as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(delay)  # 固定で1秒待つ
  

問題: 全クライアントが同じタイミングでリトライ

3. 指数バックオフ(Exponential Backoff)

    import time
import random

def retry_exponential_backoff(func, max_retries=5, base_delay=1.0, max_delay=60.0):
    """
    指数バックオフでリトライ
    1回目: 1秒
    2回目: 2秒
    3回目: 4秒
    4回目: 8秒
    ...
    """
    for attempt in range(max_retries):
        try:
            return func()
        except Exception as e:
            if attempt == max_retries - 1:
                raise

            # 指数的に増加する待ち時間
            delay = min(base_delay * (2 ** attempt), max_delay)
            time.sleep(delay)
  

4. 指数バックオフ + ジッター(推奨)

    import time
import random

def retry_with_jitter(func, max_retries=5, base_delay=1.0, max_delay=60.0):
    """
    指数バックオフ + ランダムなジッター
    → 複数クライアントのリトライタイミングを分散
    """
    for attempt in range(max_retries):
        try:
            return func()
        except RetryableError as e:
            if attempt == max_retries - 1:
                raise

            # 指数バックオフ
            delay = min(base_delay * (2 ** attempt), max_delay)

            # ジッター(0〜delay の範囲でランダム)
            jitter = random.uniform(0, delay)
            actual_delay = delay + jitter

            print(f"Attempt {attempt + 1} failed. Retrying in {actual_delay:.2f}s")
            time.sleep(actual_delay)
  

ジッターの種類

    # Full Jitter(推奨)
delay = random.uniform(0, min(cap, base * 2 ** attempt))

# Equal Jitter
temp = min(cap, base * 2 ** attempt)
delay = temp / 2 + random.uniform(0, temp / 2)

# Decorrelated Jitter
delay = min(cap, random.uniform(base, delay * 3))
  

実践的なリトライ実装

デコレータパターン

    import functools
import time
import random
from typing import Type, Tuple

def retry(
    max_retries: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 60.0,
    exceptions: Tuple[Type[Exception], ...] = (Exception,),
    on_retry: callable = None
):
    """
    リトライデコレータ

    @retry(max_retries=5, exceptions=(ConnectionError, TimeoutError))
    def call_external_api():
        ...
    """
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None

            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)

                except exceptions as e:
                    last_exception = e

                    if attempt == max_retries - 1:
                        raise

                    delay = min(base_delay * (2 ** attempt), max_delay)
                    jitter = random.uniform(0, delay * 0.1)
                    sleep_time = delay + jitter

                    if on_retry:
                        on_retry(attempt + 1, e, sleep_time)

                    time.sleep(sleep_time)

            raise last_exception

        return wrapper
    return decorator

# 使用例
@retry(
    max_retries=5,
    base_delay=1.0,
    exceptions=(requests.RequestException,),
    on_retry=lambda a, e, t: print(f"Retry {a}: {e}, waiting {t:.2f}s")
)
def fetch_user(user_id: int):
    response = requests.get(f"https://api.example.com/users/{user_id}", timeout=10)
    response.raise_for_status()
    return response.json()
  

Tenacity ライブラリ(推奨)

    from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    before_sleep_log
)
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

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

# 非同期版
@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=60)
)
async def call_api_async():
    async with httpx.AsyncClient() as client:
        response = await client.get("https://api.example.com/data")
        response.raise_for_status()
        return response.json()
  

リトライと冪等性

冪等でない操作の問題

    # 危険:決済処理
def process_payment(order_id, amount):
    response = payment_api.charge(order_id, amount)
    return response

# リトライすると二重課金の可能性!
# 1回目: 成功(課金された)
# レスポンスがタイムアウト
# 2回目: また成功(二重課金!)
  

解決策1:冪等キー

    import uuid

def process_payment_idempotent(order_id, amount):
    # 冪等キーを生成(同じ注文なら同じキー)
    idempotency_key = f"payment-{order_id}"

    response = payment_api.charge(
        order_id=order_id,
        amount=amount,
        idempotency_key=idempotency_key  # APIが対応している必要あり
    )
    return response
  

解決策2:ローカルで重複チェック

    def process_payment_with_check(order_id, amount, redis_client):
    lock_key = f"payment_lock:{order_id}"

    # 既に処理中/処理済みかチェック
    if redis_client.exists(lock_key):
        raise AlreadyProcessedError(f"Payment for order {order_id} already in progress")

    # ロックを取得(5分で期限切れ)
    redis_client.setex(lock_key, 300, "processing")

    try:
        result = payment_api.charge(order_id, amount)
        redis_client.setex(lock_key, 86400, "completed")  # 24時間保持
        return result
    except Exception as e:
        redis_client.delete(lock_key)  # 失敗したらロック解除
        raise
  

第3部:サーキットブレーカー

サーキットブレーカーとは

サーキットブレーカー は、障害が発生しているサービスへのリクエストを一時的に遮断するパターンです。

電気のブレーカーと同じ発想:異常を検知したら回路を切る

    graph TB
    subgraph without["サーキットブレーカーなし"]
        W1["リクエスト"]
        W2["障害中のサービス"]
        W3["タイムアウト"]
        W4["リトライ"]
        W5["タイムアウト..."]
        W6["リソース枯渇<br/>連鎖障害"]

        W1 --> W2 --> W3 --> W4 --> W5 --> W6
    end

    subgraph with["サーキットブレーカーあり"]
        T1["リクエスト"]
        T2["このサービスは<br/>壊れている"]
        T3["即座にエラーを返す"]
        T4["リソースを守る<br/>復旧を待つ"]

        T1 --> T2 --> T3 --> T4
    end

    style without fill:#ffebee,stroke:#f44336,stroke-width:2px
    style with fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
    style W6 fill:#ffcdd2,stroke:#c62828
    style T4 fill:#c8e6c9,stroke:#2e7d32
  

3つの状態

    graph TB
    Closed["Closed<br/>正常"]
    Open["Open<br/>遮断"]
    HalfOpen["Half-Open<br/>試験的に許可"]

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

    style Closed fill:#4caf50,color:#fff,stroke:#2e7d32
    style Open fill:#f44336,color:#fff,stroke:#c62828
    style HalfOpen fill:#ff9800,color:#fff,stroke:#e65100
  
状態 動作
Closed 通常通りリクエストを通す。失敗をカウント
Open リクエストを即座に拒否(試行しない)
Half-Open 一部のリクエストだけ通して様子を見る

状態遷移のトリガー

    Closed → Open:
  - 直近N回のうちM回失敗
  - 直近N秒間の失敗率がX%を超えた
  - 連続でN回失敗

Open → Half-Open:
  - 一定時間(例:30秒)経過

Half-Open → Closed:
  - 試験リクエストが成功

Half-Open → Open:
  - 試験リクエストが失敗
  

Pythonでの実装

シンプルな実装

    import time
from enum import Enum
from threading import Lock

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

class CircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,
        success_threshold: int = 2,
        timeout: float = 30.0
    ):
        self.failure_threshold = failure_threshold  # Open になる失敗回数
        self.success_threshold = success_threshold  # Closed に戻る成功回数
        self.timeout = timeout                      # Open から Half-Open までの時間

        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.success_count = 0
        self.last_failure_time = None
        self.lock = Lock()

    def call(self, func, *args, **kwargs):
        with self.lock:
            if self.state == CircuitState.OPEN:
                if self._should_try_reset():
                    self.state = CircuitState.HALF_OPEN
                    self.success_count = 0
                else:
                    raise CircuitBreakerOpenError("Circuit breaker is OPEN")

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

    def _should_try_reset(self) -> bool:
        """Open状態からHalf-Openに移行すべきか"""
        if self.last_failure_time is None:
            return False
        return time.time() - self.last_failure_time >= self.timeout

    def _on_success(self):
        with self.lock:
            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
            elif self.state == CircuitState.CLOSED:
                self.failure_count = 0  # 成功したらリセット

    def _on_failure(self):
        with self.lock:
            self.last_failure_time = time.time()

            if self.state == CircuitState.HALF_OPEN:
                self.state = CircuitState.OPEN
            elif self.state == CircuitState.CLOSED:
                self.failure_count += 1
                if self.failure_count >= self.failure_threshold:
                    self.state = CircuitState.OPEN

class CircuitBreakerOpenError(Exception):
    pass

# 使用例
circuit_breaker = CircuitBreaker(
    failure_threshold=5,
    success_threshold=2,
    timeout=30.0
)

def call_external_api():
    return circuit_breaker.call(
        lambda: requests.get("https://api.example.com/data", timeout=5)
    )

try:
    response = call_external_api()
except CircuitBreakerOpenError:
    # サーキットブレーカーが開いている → フォールバック処理
    return get_cached_data()
except requests.RequestException:
    # 通常のエラー
    raise
  

pybreaker ライブラリ

    import pybreaker

# サーキットブレーカーを作成
breaker = pybreaker.CircuitBreaker(
    fail_max=5,              # 5回失敗でOpen
    reset_timeout=30,        # 30秒後にHalf-Open
    exclude=[ValueError],    # この例外は失敗としてカウントしない
)

# リスナーで状態変化を監視
class LoggingListener(pybreaker.CircuitBreakerListener):
    def state_change(self, cb, old_state, new_state):
        print(f"Circuit breaker state: {old_state.name} -> {new_state.name}")

    def failure(self, cb, exc):
        print(f"Circuit breaker failure: {exc}")

breaker.add_listener(LoggingListener())

# デコレータとして使用
@breaker
def call_api():
    response = requests.get("https://api.example.com/data", timeout=5)
    response.raise_for_status()
    return response.json()

# 使用
try:
    data = call_api()
except pybreaker.CircuitBreakerError:
    # サーキットブレーカーがOpen
    data = get_fallback_data()
  

フォールバック戦略

サーキットブレーカーが開いている時、どうするか?

1. キャッシュを返す

    def get_user_data(user_id):
    try:
        return circuit_breaker.call(lambda: fetch_from_api(user_id))
    except CircuitBreakerOpenError:
        # キャッシュから返す
        cached = cache.get(f"user:{user_id}")
        if cached:
            return cached
        raise ServiceUnavailableError("Service unavailable and no cache")
  

2. デフォルト値を返す

    def get_recommendations(user_id):
    try:
        return circuit_breaker.call(lambda: recommendation_api.get(user_id))
    except CircuitBreakerOpenError:
        # デフォルトのおすすめを返す
        return get_default_recommendations()
  

3. 代替サービスを使う

    def get_exchange_rate(currency):
    try:
        return primary_breaker.call(lambda: primary_api.get_rate(currency))
    except CircuitBreakerOpenError:
        # セカンダリAPIにフォールバック
        return secondary_breaker.call(lambda: secondary_api.get_rate(currency))
  

4. 機能を無効化

    def get_product_with_reviews(product_id):
    product = product_api.get(product_id)

    try:
        reviews = reviews_breaker.call(lambda: reviews_api.get(product_id))
        product['reviews'] = reviews
    except CircuitBreakerOpenError:
        # レビュー機能を無効化(商品は表示)
        product['reviews'] = []
        product['reviews_unavailable'] = True

    return product
  

第4部:3つを組み合わせる

完全な実装パターン

    import time
import random
from typing import Callable, Any, Optional
from dataclasses import dataclass
from enum import Enum
import requests

@dataclass
class ResilienceConfig:
    # タイムアウト
    timeout: float = 10.0

    # リトライ
    max_retries: int = 3
    base_delay: float = 1.0
    max_delay: float = 30.0

    # サーキットブレーカー
    failure_threshold: int = 5
    success_threshold: int = 2
    breaker_timeout: float = 30.0

class ResilientClient:
    """
    タイムアウト + リトライ + サーキットブレーカーを統合したクライアント
    """

    def __init__(self, config: ResilienceConfig = None):
        self.config = config or ResilienceConfig()
        self.circuit_breaker = CircuitBreaker(
            failure_threshold=self.config.failure_threshold,
            success_threshold=self.config.success_threshold,
            timeout=self.config.breaker_timeout
        )

    def call(
        self,
        func: Callable,
        fallback: Optional[Callable] = None,
        *args,
        **kwargs
    ) -> Any:
        """
        耐障害性を持ったAPI呼び出し

        1. サーキットブレーカーをチェック
        2. タイムアウト付きでリクエスト
        3. 失敗したらリトライ
        4. 最終的に失敗したらフォールバック
        """

        # サーキットブレーカーが開いている場合
        if self.circuit_breaker.is_open():
            if fallback:
                return fallback()
            raise CircuitBreakerOpenError("Circuit breaker is open")

        last_exception = None

        for attempt in range(self.config.max_retries):
            try:
                # サーキットブレーカー経由で呼び出し
                result = self.circuit_breaker.call(func, *args, **kwargs)
                return result

            except CircuitBreakerOpenError:
                # サーキットブレーカーが開いた
                if fallback:
                    return fallback()
                raise

            except RetryableError as e:
                last_exception = e

                if attempt < self.config.max_retries - 1:
                    delay = self._calculate_delay(attempt)
                    print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay:.2f}s")
                    time.sleep(delay)

        # 全リトライ失敗
        if fallback:
            return fallback()
        raise last_exception

    def _calculate_delay(self, attempt: int) -> float:
        """指数バックオフ + ジッター"""
        delay = min(
            self.config.base_delay * (2 ** attempt),
            self.config.max_delay
        )
        jitter = random.uniform(0, delay * 0.1)
        return delay + jitter

class RetryableError(Exception):
    """リトライ可能なエラー"""
    pass

# 使用例
client = ResilientClient(ResilienceConfig(
    timeout=5.0,
    max_retries=3,
    failure_threshold=5,
    breaker_timeout=30.0
))

def fetch_user(user_id: int) -> dict:
    response = requests.get(
        f"https://api.example.com/users/{user_id}",
        timeout=5.0
    )
    if response.status_code >= 500:
        raise RetryableError(f"Server error: {response.status_code}")
    response.raise_for_status()
    return response.json()

def get_cached_user(user_id: int) -> dict:
    return cache.get(f"user:{user_id}") or {"id": user_id, "name": "Unknown"}

# 呼び出し
user = client.call(
    func=lambda: fetch_user(123),
    fallback=lambda: get_cached_user(123)
)
  

処理フロー

    flowchart TD
    Start["リクエスト"]
    CB["サーキットブレーカーチェック<br/>Open なら即座にフォールバック"]
    Timeout["タイムアウト付きでリクエスト<br/>5秒で打ち切り"]
    Success["返却"]
    CheckRetry["リトライ可能?"]
    Backoff["指数バックオフ<br/>+ ジッター<br/>で待機"]
    Retry["リトライ実行<br/>最大3回まで"]
    Fallback["フォールバック<br/>or エラー"]

    Start --> CB
    CB -->|"Closed/Half-Open"| Timeout
    CB -->|"Open"| Fallback
    Timeout -->|"成功"| Success
    Timeout -->|"失敗"| CheckRetry
    CheckRetry -->|"Yes"| Backoff
    CheckRetry -->|"No"| Fallback
    Backoff --> Retry
    Retry --> Timeout

    style Start fill:#e1f5fe
    style CB fill:#fff3e0
    style Timeout fill:#e8f5e9
    style Success fill:#4caf50,color:#fff
    style CheckRetry fill:#fff3e0
    style Backoff fill:#e1f5ff
    style Retry fill:#ffccbc
    style Fallback fill:#ffebee
  

第5部:監視とアラート

監視すべきメトリクス

リトライ

    from prometheus_client import Counter, Histogram

retry_total = Counter(
    'api_retry_total',
    'Total number of retries',
    ['service', 'endpoint', 'attempt']
)

retry_success = Counter(
    'api_retry_success_total',
    'Retries that eventually succeeded',
    ['service', 'endpoint']
)

# リトライ時に記録
retry_total.labels(service='user-api', endpoint='/users', attempt='2').inc()
  

サーキットブレーカー

    from prometheus_client import Gauge, Counter

circuit_state = Gauge(
    'circuit_breaker_state',
    'Current state of circuit breaker (0=closed, 1=open, 2=half-open)',
    ['service']
)

circuit_state_changes = Counter(
    'circuit_breaker_state_changes_total',
    'Number of circuit breaker state changes',
    ['service', 'from_state', 'to_state']
)

# 状態変化時に記録
circuit_state.labels(service='payment-api').set(1)  # Open
circuit_state_changes.labels(
    service='payment-api',
    from_state='closed',
    to_state='open'
).inc()
  

タイムアウト

    timeout_total = Counter(
    'api_timeout_total',
    'Total number of timeouts',
    ['service', 'endpoint']
)

request_duration = Histogram(
    'api_request_duration_seconds',
    'Request duration in seconds',
    ['service', 'endpoint'],
    buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0]
)
  

Prometheusアラート

    groups:
  - name: resilience_alerts
    rules:
      # サーキットブレーカーがOpen
      - alert: CircuitBreakerOpen
        expr: circuit_breaker_state == 1
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Circuit breaker is OPEN for {{ $labels.service }}"

      # リトライ率が高い
      - alert: HighRetryRate
        expr: |
          sum(rate(api_retry_total[5m])) by (service)
          / sum(rate(api_request_total[5m])) by (service) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High retry rate (>10%) for {{ $labels.service }}"

      # タイムアウトが多い
      - alert: HighTimeoutRate
        expr: |
          sum(rate(api_timeout_total[5m])) by (service)
          / sum(rate(api_request_total[5m])) by (service) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High timeout rate (>5%) for {{ $labels.service }}"
  

ダッシュボード

    ┌────────────────────────────────────────────────────────────┐
│                    API Health Dashboard                    │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  サーキットブレーカー状態                                   │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐                  │
│  │ User API │ │Payment API│ │ Email API │                  │
│  │  🟢 OK   │ │  🔴 OPEN │ │  🟡 HALF │                  │
│  └──────────┘ └──────────┘ └──────────┘                  │
│                                                            │
│  リトライ率(過去1時間)                                    │
│  ████████████░░░░░░░░ 12%  ← 要注意                       │
│                                                            │
│  タイムアウト率(過去1時間)                                │
│  ██░░░░░░░░░░░░░░░░░░ 2%   ← 正常                         │
│                                                            │
│  レイテンシ分布(p99)                                      │
│  User API:    ████ 450ms                                   │
│  Payment API: ████████████ 1200ms  ← 遅い                 │
│  Email API:   ██ 200ms                                     │
│                                                            │
└────────────────────────────────────────────────────────────┘
  

ベストプラクティス

設計時のチェックリスト

    □ 全ての外部呼び出しにタイムアウトを設定したか
□ リトライ対象のエラーを明確に定義したか
□ 冪等でない操作のリトライを防いでいるか
□ サーキットブレーカーのフォールバックを用意したか
□ メトリクスを収集しているか
□ アラートを設定したか
  

設定値の目安

項目 推奨値 備考
タイムアウト p99 × 1.5 実測に基づく
リトライ回数 2-3回 多すぎると遅延
リトライ間隔 1-2秒から開始 指数バックオフ
CB失敗閾値 5-10回 サービスによる
CBタイムアウト 30-60秒 復旧時間による

アンチパターン

アンチパターン 問題 対策
タイムアウトなし リソース枯渇 必ず設定
即時リトライ 雪崩効果 バックオフ
無限リトライ 永遠に終わらない 上限を設定
非冪等操作のリトライ 二重処理 冪等キー使用
フォールバックなし 全体停止 必ず用意

まとめ

3つのパターンの関係

    graph TB
    Timeout["タイムアウト<br/>「待ちすぎない」"]
    Retry["リトライ<br/>「もう一度試す」"]
    CB["サーキットブレーカー<br/>「しばらく諦める」"]
    Fallback["フォールバック<br/>「代替手段で対応」"]

    Timeout -->|"失敗したら"| Retry
    Retry -->|"失敗が続いたら"| CB
    CB -->|"必要なら"| Fallback

    style Timeout fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Retry fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style CB fill:#ffebee,stroke:#f44336,stroke-width:2px
    style Fallback fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
  

それぞれの役割

パターン 守るもの タイミング
タイムアウト 自分のリソース リクエスト中
リトライ 一時的障害の影響 失敗直後
サーキットブレーカー システム全体 障害が続くとき

心がけ

    1. 外部呼び出しは必ず失敗する前提で設計
2. 3つのパターンを組み合わせる
3. フォールバックを必ず用意
4. 監視とアラートで早期発見
5. 冪等性を確保してリトライ安全に
  

参考リンク