タイムアウト・リトライ・サーキットブレーカー|外部依存の障害に強いシステム設計

kento_morota 17分で読めます
社内のAI・IT活用、技術がわかる相手に無料相談。 相談 →
目次

「外部の決済APIが遅くなっただけで、自社サービス全体が止まった」「リトライを入れたら、相手のAPIから『アクセスが多すぎる』と怒られた」「同じ注文が2回登録された」――外部サービスや他のマイクロサービスに依存するシステムでは、相手の不調が自分の障害になります。そして、その被害の大きさは「相手がどれだけ壊れたか」ではなく「自分がどう呼び出していたか」で決まります。

この記事では、タイムアウト未設定がなぜ全体障害につながるのかを仕組みから説明し、接続タイムアウトと読み取りタイムアウトの違い、指数バックオフとジッターを使った正しいリトライ、リトライしてよい操作と危険な操作の見極め、サーキットブレーカーの3つの状態、バルクヘッドによる隔離までを解説します。Node.jsのfetch・axios、Pythonのrequestsでの実装例を載せているので、明日から自分のコードに適用できます。

タイムアウト未設定が全体障害を招く仕組み

「待つ」こと自体がリソースを消費する

アプリケーションサーバーは、同時に処理できるリクエスト数に上限があります。スレッドベースなら「ワーカースレッド数」、Node.jsのようなイベント駆動でも「同時に開いているコネクション数」や「コネクションプールの上限」が実質的な上限です。この上限の考え方は同時接続数とワーカー数の見積もりで解説しています。

外部APIを呼ぶ処理にタイムアウトがなければ、相手が応答を返さないとき、そのリクエストを処理しているワーカーは永遠に待ち続けます。1件なら問題になりませんが、相手が遅い状態が続けば、新しいリクエストが来るたびにワーカーが1つずつ「待ち」に消費され、数分でワーカーがすべて埋まります。この時点で、外部APIとは無関係な処理(トップページの表示、ログイン)まで、ワーカー待ちで応答できなくなります。これが「外部の一部の不調が自社の全体障害になる」典型的な仕組みです。

多くのHTTPクライアントはデフォルトで無限に待つ

厄介なのは、主要なHTTPクライアントの多くがデフォルトでタイムアウトを設定していない、あるいは非常に長いことです。Pythonのrequestsはタイムアウトを指定しなければ無期限に待ちます。Node.jsのaxiosもtimeoutのデフォルトは0(無制限)です。Node.js標準のfetch(undici)はヘッダー受信までに300秒という上限がありますが、5分間ワーカーが埋まり続けるのは実質的に無限と変わりません。「設定していない」は「設定されていない」と同じだと考えてください。

接続タイムアウトと読み取りタイムアウトの違い

タイムアウトは1つの値ではなく、通信の段階ごとに分かれています。少なくとも次の2つを区別して設定します。

種類何を待つ時間か超過する典型的な原因目安
接続タイムアウト(connect)TCP接続(3ウェイハンドシェイク)が確立するまで相手ホストがダウン、ファイアウォールで遮断、DNS解決の遅延数秒(1〜5秒)。健全な相手なら通常ミリ秒で終わる
読み取りタイムアウト(read)接続後、データ(レスポンス)が届くまでの無通信時間相手のアプリが処理に時間をかけている、相手のDBが詰まっている相手の処理時間の想定+余裕。API種別ごとに変える
全体タイムアウト(total)リクエスト開始からレスポンス完了まで大きなレスポンスをゆっくり返し続けている場合などユーザーを待たせられる限界(Webなら数秒〜十数秒)

接続タイムアウトを短くできるのは、「接続に時間がかかる」状況のほとんどが「相手が存在しない・届かない」ケースだからです。一方、読み取りタイムアウトは相手の処理時間に依存するため、「軽い参照APIは3秒、重い帳票生成APIは60秒」のように呼び出し先ごとに決めます。TCPの接続の仕組みはTCPの3ウェイハンドシェイクとTIME_WAITを参照してください。

実装例:requests / axios / fetch

# Python requests:(接続, 読み取り) のタプルで指定する
import requests

resp = requests.get(
    "https://api.example.com/orders/123",
    timeout=(3.0, 10.0),   # 接続3秒、読み取り10秒
)
resp.raise_for_status()
// axios:timeoutは全体の上限(接続と読み取りを分けられない)
import axios from "axios";
import http from "node:http";
import https from "node:https";

const client = axios.create({
  baseURL: "https://api.example.com",
  timeout: 10_000,                                  // 全体10秒
  httpAgent:  new http.Agent({ keepAlive: true, timeout: 3_000 }),  // ソケット無通信3秒
  httpsAgent: new https.Agent({ keepAlive: true, timeout: 3_000 }),
});

// fetch(Node.js 18+ / ブラウザ):AbortSignalで全体タイムアウト
const res = await fetch("https://api.example.com/orders/123", {
  signal: AbortSignal.timeout(10_000),  // 10秒で AbortError(TimeoutError)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);

リトライ:指数バックオフとジッター、そして「してよい操作」の見極め

即時リトライは相手にとどめを刺す

失敗の直後に同じリクエストを送り直しても、相手がまだ過負荷なら同じ結果になり、しかも相手の負荷を増やします。自社のサーバーが10台あれば、10台が同時に失敗し、同時にリトライし、相手に10倍の負荷をかける「リトライストーム」になります。そこで、待ち時間を回数ごとに倍にする指数バックオフを使います。さらに、待ち時間にランダムなずれ(ジッター)を加えて、複数のクライアントのリトライが同じ瞬間に集中しないようにします。

// fetch + 指数バックオフ + フルジッター(Node.js)
async function fetchWithRetry(url, options = {}, { retries = 3, baseMs = 500, maxMs = 8000 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      const res = await fetch(url, { ...options, signal: AbortSignal.timeout(10_000) });
      // 5xx と 429 はリトライ対象、4xx(429以外)は即座に失敗
      if (res.status < 500 && res.status !== 429) return res;
      if (attempt >= retries) return res;
      // Retry-After ヘッダーがあれば尊重
      const ra = Number(res.headers.get("retry-after"));
      var waitMs = ra ? ra * 1000 : Math.random() * Math.min(maxMs, baseMs * 2 ** attempt);
    } catch (e) {
      // タイムアウト・接続失敗はリトライ対象
      if (attempt >= retries) throw e;
      var waitMs = Math.random() * Math.min(maxMs, baseMs * 2 ** attempt);
    }
    await new Promise((r) => setTimeout(r, waitMs));
  }
}

ここでの「フルジッター」は、0から上限までの一様乱数で待つ方式で、AWSが推奨している手法です。上限を8秒程度に抑えているのは、ユーザーが待っているHTTPリクエストの中で数十秒もリトライし続けるのは現実的でないからです。長時間のリトライが必要な処理は、バックグラウンドジョブに逃がします(バックグラウンドジョブの設計)。

リトライしてよい操作、危険な操作

リトライは「もう一度実行しても結果が変わらない(冪等な)操作」にだけ安全に適用できます。

  • 安全:GET、HEAD、OPTIONS。PUTとDELETEも仕様上は冪等(同じ状態に上書き/削除済みなら何もしない)。
  • 危険:POSTによる「作成」「送信」「課金」。タイムアウトは「相手に届かなかった」ではなく「レスポンスが返ってこなかった」だけであり、相手側では処理が完了している可能性があります。ここでリトライすると二重注文・二重課金になります。

POSTをリトライしたい場合は、リクエストに冪等キーIdempotency-Keyヘッダーなど)を付け、相手側で「同じキーの2回目は1回目の結果を返す」処理があることを確認します。Stripeなど主要な決済APIはこれに対応しています。対応していないAPIに対しては、リトライではなく「状態を問い合わせてから判断する」(注文IDで検索し、存在しなければ再送)という方法を取ります。

また、HTTPステータスで判断する場合、429(Too Many Requests)と503は相手が「後で来て」と言っているのでリトライ対象、400・401・403・404・422は何度送っても変わらないのでリトライしてはいけません。Retry-Afterヘッダーがあれば、それに従うのがマナーです。レート制限側の仕組みはAPIレート制限の設計と実装で解説しています。

サーキットブレーカー:壊れている相手を「呼ばない」

タイムアウトとリトライだけでは足りない理由

タイムアウトを設定すれば無限に待つことはなくなりますが、相手が完全にダウンしているとき、すべてのリクエストが「10秒待って失敗」を繰り返します。ユーザーは毎回10秒待たされ、相手には無駄なリクエストが送られ続け、復旧の妨げにもなります。「相手が壊れているとわかっているなら、最初から呼ばずに即座に失敗する(あるいは代替の応答を返す)」のがサーキットブレーカーです。家庭の分電盤のブレーカーと同じで、異常を検知したら回路を遮断し、しばらくしてから慎重に復旧を試します。

3つの状態

状態動作遷移条件
Closed(閉・正常)すべてのリクエストを相手に送る。失敗を数える一定期間内の失敗率(例:50%)または失敗回数が閾値を超えたらOpenへ
Open(開・遮断)相手を呼ばず、即座にエラー(またはフォールバック値)を返す一定時間(例:30秒)経過したらHalf-Openへ
Half-Open(半開・試行)少数のリクエストだけ相手に送って様子を見る成功すればClosedへ戻る。失敗すればOpenへ戻る

Node.jsではoppossum、Pythonではpybreakerなどのライブラリで実装できます。フォールバック(遮断中に返す代替値)を用意できるかどうかが設計のポイントで、「おすすめ商品APIが落ちていたら人気商品リストを返す」「為替レートAPIが落ちていたらキャッシュした最終値を使う」のように、機能を縮退させてでもページを返す設計にすると、依存先の障害がユーザーには「少し物足りない画面」程度で済みます。

// opossum によるサーキットブレーカー(Node.js)
import CircuitBreaker from "opossum";

async function getRecommendations(userId) {
  const res = await fetch(`https://reco.example.com/users/${userId}`, {
    signal: AbortSignal.timeout(2_000),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

const breaker = new CircuitBreaker(getRecommendations, {
  timeout: 2_500,                 // これを超えたら失敗とみなす
  errorThresholdPercentage: 50,   // 直近の失敗率50%でOpen
  volumeThreshold: 10,            // 最低10件集まってから判定
  resetTimeout: 30_000,           // 30秒後にHalf-Open
});

// 遮断中・失敗時は人気商品を返して画面を成立させる
breaker.fallback(() => popularItemsCache.get());
breaker.on("open", () => logger.warn("reco API circuit opened"));

export const recommendations = (userId) => breaker.fire(userId);

バルクヘッド:1つの依存先の障害を他に波及させない

船の隔壁(バルクヘッド)は、1区画に浸水しても船全体が沈まないように仕切る構造です。システムでも同じ発想で、「依存先ごとにリソースを分けて、1つの依存先が詰まっても他の処理に使えるリソースが残る」ようにします。具体的には次の形を取ります。

  • 外部APIごとに専用のコネクションプール・同時実行数の上限を設ける(例:決済APIは同時20件まで、それ以上は即座に「混雑中」を返す)
  • 重要度の異なる処理を別のワーカープール(またはコンテナ)で動かす。決済処理と、おすすめ表示を同じワーカーで処理しない
  • 非同期ジョブでは、外部API呼び出しの多いジョブ専用のキューとワーカーを分ける
# Python:依存先ごとにセマフォで同時実行数を制限する(バルクヘッド)
import asyncio, httpx

payment_limit = asyncio.Semaphore(20)   # 決済APIは同時20件まで
reco_limit    = asyncio.Semaphore(50)   # おすすめAPIは同時50件まで

async def call_payment(client: httpx.AsyncClient, payload: dict):
    if payment_limit.locked():
        raise RuntimeError("payment API busy")   # 待たずに即座に失敗させる
    async with payment_limit:
        r = await client.post("https://pay.example.com/charge", json=payload,
                              timeout=httpx.Timeout(connect=3.0, read=15.0, write=5.0, pool=1.0))
        r.raise_for_status()
        return r.json()

タイムアウト・リトライ・サーキットブレーカー・バルクヘッドは、それぞれ違う問題を解いています。タイムアウトは「待ちすぎ」を、リトライは「一時的な失敗」を、サーキットブレーカーは「壊れた相手を呼び続けること」を、バルクヘッドは「波及」を防ぎます。4つ揃って初めて、外部依存の障害に強い設計になります。

トラブル事例:配送業者APIの遅延で注文画面が全滅した

症状

ECサイトで、注文確認画面を開くと30秒以上待たされたあとエラーになる状態が1時間続きました。商品一覧や商品詳細ページも徐々に遅くなり、最終的にトップページまで応答しなくなりました。

原因

注文確認画面では、配送業者のAPIを呼んで配達予定日を表示していました。この呼び出しはrequestsでtimeoutを指定しておらず、配送業者側の障害で応答が返らなくなった際、Gunicornのワーカー(8プロセス)が1つずつ「待ち」に消費されていきました。すべてのワーカーが配送APIを待つ状態になった時点で、配送APIと無関係なトップページも処理できなくなりました。さらに、フロントエンドのJavaScriptが「エラーなら3秒後に再取得」というリトライを入れていたため、ワーカーの枯渇が加速していました。

対処

  1. 配送APIの呼び出しに接続3秒・読み取り5秒のタイムアウトを設定した。
  2. サーキットブレーカーを導入し、遮断中は配達予定日を「確認中」と表示して注文自体は完了できるようにした。
  3. フロントエンドの即時リトライを、指数バックオフ+最大2回に変更した。
  4. 外部API呼び出しを共通のHTTPクライアントラッパー経由に統一し、タイムアウト未指定ではそもそも呼べないようにした。
# 共通ラッパー:タイムアウト未指定を禁止する
import requests

DEFAULT_TIMEOUT = (3.0, 10.0)

class ExternalClient:
    def __init__(self, base_url: str, timeout=DEFAULT_TIMEOUT):
        self.base_url = base_url
        self.timeout = timeout
        self.session = requests.Session()

    def get(self, path: str, **kw):
        kw.setdefault("timeout", self.timeout)   # 明示的に None を渡しても上書きしない設計にする
        if kw["timeout"] is None:
            raise ValueError("timeout=None is not allowed")
        return self.session.get(self.base_url + path, **kw)

変更後、同じ配送業者の障害が再度起きた際は、注文確認画面に「配達予定日は確認中」と出ただけで、注文数への影響はほぼありませんでした。

まとめ

外部依存の障害に強いシステムは、相手が壊れないことを期待するのではなく、「相手は必ず壊れる」前提で呼び出し側を設計することで作られます。すべての外部呼び出しに接続・読み取りのタイムアウトを設定し、リトライは冪等な操作に限って指数バックオフとジッターで行い、429や503は待ってから、4xxは待たずに諦める。壊れている相手はサーキットブレーカーで呼ばずにフォールバックを返し、依存先ごとにバルクヘッドでリソースを仕切って波及を止める。この4つは互いに補い合う関係なので、まずタイムアウトから、順に揃えていってください。

外部API連携やマイクロサービス間通信の耐障害性を見直したい、既存コードのタイムアウト・リトライを棚卸ししたい、という場合はHarmonic Societyのシステム開発・インフラ支援にご相談ください。

#タイムアウト#リトライ#サーキットブレーカー#耐障害性

Harmonic Society

この記事の内容、自社の業務でも活かせそうですか?

ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。

共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

関連記事

Related / 9 articles

  1. プログラミング

    DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用

    DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。

  2. プログラミング

    WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド

    WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。

  3. プログラミング

    セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説

    CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。

  4. プログラミング

    クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計

    クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。

  5. プログラミング

    秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除

    APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。

  6. プログラミング

    開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御

    開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。

  7. プログラミング

    ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方

    NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。

  8. プログラミング

    ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方

    ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。

  9. プログラミング

    localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決

    localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。

Harmonic Society

「読んで終わり」にせず、自社の業務で試してみませんか?

AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。

無料・30分・オンラインOK|1営業日以内に返信します