【実務・中級編】 HTTPステータスコード 5xx (サーバーエラー) と再試行戦略 – ネットワーク基礎とWebセキュリティ実践ガイド

5xxの洗礼を生き抜け:Web API設計とインフラ運用の現場で役立つ「500・503・504」再試行戦略

夜中の3時、突然鳴り響くPagerDutyのアラート。ダッシュボードを開けば、そこには見慣れた、しかし胃が痛くなる数字並び――500 Internal Server Error、503 Service Unavailable、504 Gateway Timeout。

こんにちは、シニアネットワーク・インフラエンジニアの私です。
これまで数々の修羅場をくぐり抜け、大規模トラフィックの奔流に立ち向かってきましたが、サーバーエラー(5xx系)のハンドリングを甘く見たがために、些細な一時障害がシステム全体のカスケード障害(雪崩式ダウン)へと発展する瞬間を何度も目撃してきました。

クライアントから「エラーが出たから、とりあえず毎秒リトライさせました!」という実装が持ち込まれ、瀕死のバックエンドサーバーがさらにトドメを刺される……。そんな悲劇を現場から根絶するため、今回はRFCの仕様から、パケットの裏側で何が起きているのか、そして実務で即座に使える「指数バックオフ(Exponential Backoff)」を用いた正しい再試行戦略まで、徹底的に解説します。

—

1. 5xx系ステータスコードの正体:パケットの往来と発生要因

OSI参照モデルやTCP/IP階層モデルで言えば、5xx系エラーは最上位であるアプリケーション層(HTTP)の話ですが、それを生み出しているのはトランスポート層の輻輳や、インフラストラクチャの悲鳴です。

クライアントがTCP 3-wayハンドシェイクを完了させ、TLSの暗号化トンネルを掘り、渾身のGETやPOSTリクエストを送り出した後、サーバー側(またはその手前のリバースプロキシやAPI Gateway)で何らかの致命的な問題が発生したときにこのコードが返されます。

それぞれのステータスコードが語る「現場の悲鳴」を聞いてみましょう。

500 Internal Server Error:何が起きたか分からない、システムのパニック

  • 発生要因: アプリケーションコードの未処理例外(NullPointerExceptionやシンタックスエラーなど)、データベースの接続数枯渇、ディスク容量のパンクなど、サーバー内部での想定外の異常。
  • ネットワーク的な挙動: クライアントからのリクエストは正常に到達し、サーバーもそれを受理して処理を試みたものの、内部でクラッシュしたため HTTP/1.1 500 Internal Server Error のレスポンスヘッダーとエラーHTML/JSONを返してコネクションを切断(または維持)します。

503 Service Unavailable:キャパシティの限界と一時的なお休み

  • 発生要因: サーバーの過負荷(CPU/メモリの枯渇)、メンテナンス中、あるいはオートスケーリングが追いついていない状態。
  • ネットワーク的な挙動: ロードバランサー(ALBやNginxなど)がバックエンドのヘルスチェック失敗を検知した場合や、接続キュー(Listen Queue)があふれそうな場合に返されます。しばしば Retry-After ヘッダーが同封され、「何秒後にまた来てね」というサーバーからのメッセージが添えられます。

504 Gateway Timeout:沈黙のタイムアウト

  • 発生要因: リバースプロキシ(Nginx、API Gateway等)からバックエンドのアプリケーションサーバー(uWSGI、Node.js、Gunicorn等)へリクエストを転送したものの、指定された制限時間(proxy_read_timeoutなど)内にレスポンスが返ってこなかった状態。
  • ネットワーク的な挙動: ネットワークの断絶ではなく、バックエンドの処理が重すぎて(重いクエリ、外部APIの遅延など)プロキシ側がしびれを切らしてコネクションをブツ切りにし、クライアントに 504 を返します。

—

2. 闇雲なリトライは「DDoS攻撃」である

障害が発生した際、クライアントサイドで「エラーが出たら即座にリトライ」を実装していると、どうなるでしょうか。

サーバーが1秒間に1,000件の処理能力しか持たないところへ、一時的な負荷で1,500件のリクエストが押し寄せ、数台が 503 を吐き始めたとします。そこに、数千台のクライアントアプリが「1秒ごとに即座にリトライ」を仕掛けたら……。
リクエスト数は瞬時に倍増し、サーバーは完全なリソース枯渇に陥り、復旧のチャンスを完全に奪われます。これが「リトライストーム」であり、エンジニアが最も恐れる悪夢です。

だからこそ、「いつ、どのようなアルゴリズムで再試行すべきか」を設計段階で厳密に定義しなければなりません。

—

3. 実践:指数バックオフとジッター(Jitter)を用いた再試行戦略

賢いクライアントは、エラーに直面したとき「少しずつ待ち時間を延ばしながら(指数バックオフ)」、そして「他のクライアントとタイミングが被らないようにランダムな揺らぎ(ジッター)」を加えて再試行します。

指数バックオフの計算式

基本の待ち時間を $B$、試行回数を $c$ とすると、待機時間は一般的に $2^c \times B$ で増加します。
ここに、ミリ秒単位のランダムな揺らぎ(Jitter)を足し引きすることで、リクエストが特定の瞬間に集中するのを防ぎます。

Pythonによる実装例

実務でAPIクライアントを実装する際の、堅牢なリトライロジックのサンプルを提示します。標準ライブラリの requests と urllib3 の例外処理、そして 500, 503, 504 に対するハンドリングを網羅しています。

import time
import random
import requests
from requests.exceptions import RequestException

def robust_api_request(url, max_retries=5, base_delay=1.0):
    """
    指数バックオフとジッターを組み込んだ堅牢なAPIリクエスト関数
    """
    for attempt in range(max_retries):
        try:
            print(f"[{attempt + 1}/{max_retries}] リクエスト送信中: {url}")
            response = requests.get(url, timeout=10)

            # 5xx系エラー、または503などの一時的な障害を示すステータスのハンドリング
            if response.status_code in [500, 502, 503, 504]:
                print(f"警告: サーバーエラーを検出しました (Status: {response.status_code})")
                
                # サーバーから Retry-After ヘッダーが指定されている場合はそれを優先する考慮も実務では重要
                retry_after = response.headers.get("Retry-After")
                if retry_after and retry_after.isdigit():
                    sleep_time = int(retry_after)
                else:
                    # 指数バックオフの計算: base_delay * (2^attempt)
                    exponential_delay = base_delay * (2 ** attempt)
                    # フル・ジッター(0から計算された遅延の間のランダムな値)を付与
                    jitter = random.uniform(0, 1.0)
                    sleep_time = exponential_delay + jitter

                if attempt == max_retries - 1:
                    print("エラー: 最大リトライ回数に達しました。処理を中断します。")
                    response.raise_for_status()

                print(f"{sleep_time:.2f}秒後に再試行します...")
                time.sleep(sleep_time)
                continue

            # 4xx系など他のエラーハンドリング(今回は省略、200 OKなどを想定)
            response.raise_for_status()
            return response.json()

        except RequestException as e:
            # ネットワーク切断やDNS名前解決失敗など、トランスポート層の例外
            print(f"通信例外が発生しました: {e}")
            if attempt == max_retries - 1:
                raise
            
            sleep_time = base_delay * (2 ** attempt) + random.uniform(0, 1.0)
            print(f"ネットワークエラーのため {sleep_time:.2f}秒後に再試行します...")
            time.sleep(sleep_time)

# 使用例
if __name__ == "__main__":
    target_url = "https://api.example.com/v1/data"
    try:
        # data = robust_api_request(target_url)
        pass
    except Exception as e:
        print(f"最終的に処理が失敗しました: {e}")

—

4. フロントエンド(JavaScript / Fetch API)での実装アプローチ

Webブラウザから叩くSPA(Single Page Application)のバックエンドAPIや、BFF(Backend for Frontend)層でも、同様の配慮が必要です。ただし、ブラウザ環境ではメインスレッドをブロックしないよう、setTimeout と async/await を組み合わせた非同期のバックオフを実装します。

/**
 * 指数バックオフ付きのfetchラッパー
 */
async function fetchWithBackoff(url, options = {}, retries = 3, backoff = 1000) {
  try {
    const response = await fetch(url, options);

    // 5xx系エラーの場合
    if (response.status >= 500 && response.status <= 599) {
      if (retries <= 0) {
        throw new Error(`サーバーエラーが継続しています: ${response.status}`);
      }

      // ジッター(±20%程度の揺らぎ)の計算
      const jitter = Math.random() * 400 - 200;
      const delay = backoff + jitter;

      console.warn(`ステータス ${response.status} を検知。${Math.round(delay)}ms後に再試行します...(残り試行回数: ${retries})`);

      // 指定時間待機したのちに再帰呼び出し(バックオフ時間を倍増)
      await new Promise(resolve => setTimeout(resolve, delay));
      return fetchWithBackoff(url, options, retries - 1, backoff * 2);
    }

    if (!response.ok) {
      throw new Error(`HTTPエラー! ステータス: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    if (retries <= 0) throw error;
    // ネットワーク層のエラー(オフラインなど)に対するリトライ
    const delay = backoff * 2;
    await new Promise(resolve => setTimeout(resolve, delay));
    return fetchWithBackoff(url, options, retries - 1, delay);
  }
}

—

5. インフラ・プロキシ層での設定Tips(Nginxの例)

クライアント側だけでなく、インフラストラクチャを支えるリバースプロキシ側でも、5xxエラーを極力減らし、適切なハンドリングを行うための設定が不可欠です。
例えば、Nginxをプロキシとして使用する場合、バックエンドが一時的に 502 や 504 を返した際に、「別のアップストリームサーバーへ自動的にフェイルオーバー(リトライ)」させることができます。

以下の設定例は、Nginxがバックエンドからのエラーを検知した際に、安全に再試行を行うためのディレクティブです。

http {
    upstream backend_cluster {
        # 冗長化されたバックエンドサーバー群
        server 10.0.1.10:8080 max_fails=3 fail_timeout=10s;
        server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
    }

    server {
        listen 80;
        server_name api.example.com;

        location / {
            proxy_pass http://backend_cluster;
            
            # プロキシのタイムアウト設定(504を防ぐための適切なチューニング)
            proxy_connect_timeout 5s;
            proxy_send_timeout 60s;
            proxy_read_timeout 60s;

            # 【重要】どのような条件で次のアップストリームサーバーへリトライするか
            # タイムアウト、エラー、あるいは502, 503などのステータスを受け取った場合に再試行
            proxy_next_upstream error timeout http_502 http_503 http_504;
            
            # 最大何回までリトライを許容するか(無限ループ防止)
            proxy_next_upstream_tries 3;
            
            # 既にリクエストがバックエンドへ送信された(Idempotentではない可能性がある)場合でも
            # リトライを許可するかどうかの制御(POST等の場合は慎重に検討が必要)
            proxy_next_upstream_timeout 10s;
        }
    }
}

ここでインフラエンジニアとして特に注意喚起したいのは、proxy_next_upstream で POST や PUT などの「冪等性(Idempotency)がないリクエスト」を安易にリトライ対象に含めないことです。決済処理のAPIなどでこれをやると、二重決済の地獄を見るハメになります。非冪等なAPIのエラーハンドリングは、必ずクライアント側で一意なリクエストID(UUID等)を持たせた上で安全に設計する必要があります。

—

まとめ

5xx系エラーは、Webアプリケーションを運用する上で避けて通れない「避けられない嵐」のようなものです。しかし、その正体を正しく理解し、インフラ層でのフェイルオーバーと、クライアント層での「指数バックオフ+ジッター」による優しさを持った再試行戦略を組み合わせることで、障害の影響を最小限に抑え、システムを自律的な回復へと導くことができます。

夜中にアラートが鳴り響いたとき、あなたの書いたコードとインフラストラクチャが静かに、そして賢く嵐をやり過ごしている――そんな強靭なシステムを、一緒に作っていきましょう。

コメント

タイトルとURLをコピーしました