deepseek.ac日本語で学ぶ、実践する

実装

DeepSeek APIのリトライ設計:指数バックオフとジッターを正しく実装する

· 約12分

リード

DeepSeek API の呼び出しを「とりあえず while ループで再試行」すると、障害時に全クライアントが同時に叩きに行き、30秒の不調が30分の停止に育ちます。この記事では、リトライしてよいエラーの見分け方、フルジッター付き指数バックオフの計算式、全体デッドラインの持ち方、そして LLM API 特有の「再試行=二重課金」問題への対処を、動くコードで示します。

前提

本記事のエラーコード分類は一般的な HTTP の挙動に基づきます。DeepSeek API が返すステータスコードの正式な一覧・Retry-After ヘッダの有無・冪等キーヘッダのサポート状況は、必ず公式ドキュメントで確認してください(本記事では断定しません)。

なぜ単純なリトライループが危険か

リトライは「失敗したらもう一度呼ぶ」だけに見えて、実際には次の3つを同時に設計する必要があります。

  1. いつ再試行するか(エラー種別の判定)
  2. どれだけ待つか(バックオフとジッター)
  3. いつ諦めるか(試行回数と全体デッドライン)

このうち 2 を等間隔(例: 1秒固定)にすると、レート制限や過負荷で失敗した瞬間、待機時間が揃ったクライアントが一斉に再突撃します。これが「リトライストーム」で、サーバー側の復旧を妨げます。対策が指数バックオフ+ジッターです。

リトライしてよいエラー/ダメなエラー

まず判定表を固定します。ここを間違えると、認証エラーを5回繰り返して無駄に遅くなるだけの実装になります。

ステータス 意味の例 リトライ
408 / 425 タイムアウト、Too Early する
429 レート制限 する(Retry-After があれば優先)
500 / 502 / 503 / 504 サーバー側の一時障害・過負荷 する
400 / 422 リクエスト形式・パラメータ不正 しない
401 / 403 認証・権限 しない
402 残高・支払い関連 しない(チャージしない限り永遠に失敗)
404 エンドポイント・モデル名の誤り しない

ネットワーク例外(接続失敗、読み取りタイムアウト、コネクション切断)はリトライ対象です。ただし リクエスト送信後に切断された場合、サーバー側では処理が完了している可能性がある 点は後述します。

指数バックオフの計算式

基本形は次のとおりです。

delay = min(cap, base * 2^attempt) + jitter

そしてジッターには方式があります。AWS の記事で知られる フルジッターは、指数部分をそのまま上限とした一様乱数にします。

delay = random(0, min(cap, base * 2^attempt))

フルジッターは「待機時間のばらつき」を最大化でき、実装も1行で済むため、まずこれを選べば問題ありません。平均待機は指数バックオフより短くなるので、レイテンシ面でも有利です。

Python実装(標準ライブラリ+httpx)

外部依存を増やさない素朴な実装です。ポイントは「全体デッドライン」「Retry-After の尊重」「非リトライエラーは即座に例外」の3点です。

import random
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime

import httpx

RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}


def parse_retry_after(value: str | None) -> float | None:
    """Retry-After は秒数 or HTTP-date。解釈できなければ None。"""
    if not value:
        return None
    try:
        return max(0.0, float(value))
    except ValueError:
        pass
    try:
        dt = parsedate_to_datetime(value)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds())
    except Exception:
        return None


def full_jitter(attempt: int, base: float = 0.5, cap: float = 20.0) -> float:
    return random.uniform(0.0, min(cap, base * (2 ** attempt)))


def post_with_retry(
    client: httpx.Client,
    url: str,
    *,
    headers: dict,
    payload: dict,
    max_attempts: int = 5,
    base: float = 0.5,
    cap: float = 20.0,
    deadline: float = 90.0,
) -> httpx.Response:
    started = time.monotonic()
    last_error: Exception | None = None

    for attempt in range(max_attempts):
        if time.monotonic() - started > deadline:
            break
        try:
            resp = client.post(url, headers=headers, json=payload)
        except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as e:
            last_error = e
        else:
            if resp.status_code < 400:
                return resp
            if resp.status_code not in RETRYABLE_STATUS:
                resp.raise_for_status()  # 400/401/402 などは即失敗
            last_error = RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
            wait = parse_retry_after(resp.headers.get("retry-after"))
            if wait is not None:
                time.sleep(min(wait, cap))
                continue

        time.sleep(full_jitter(attempt, base, cap))

    raise RuntimeError(f"retry exhausted after {max_attempts} attempts") from last_error

呼び出し側では、タイムアウトを用途別に明示します。LLM の応答は長いので、read だけ長めにします。

timeout = httpx.Timeout(connect=5.0, read=120.0, write=30.0, pool=5.0)

with httpx.Client(timeout=timeout) as client:
    resp = post_with_retry(
        client,
        "https://api.deepseek.com/chat/completions",
        headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
        payload={"model": "deepseek-chat", "messages": [{"role": "user", "content": "ping"}]},
    )

tenacity を使う場合

既に tenacity を入れているプロジェクトなら、待機戦略だけ借りるのが安全です。

from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_random_exponential,
)

@retry(
    retry=retry_if_exception_type((httpx.TransportError, RetryableError)),
    wait=wait_random_exponential(multiplier=0.5, max=20),
    stop=stop_after_attempt(5),
    reraise=True,
)
def call_deepseek(payload: dict) -> dict:
    ...

wait_random_exponential は「0〜指数バックオフ値の乱数」を返す フルジッター です。wait_exponential_jitter は指数値に乱数を 加算 する方式で挙動が異なるため、混同しないでください。

TypeScript実装(fetch)

const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);

type Options = {
  maxAttempts?: number;
  baseMs?: number;
  capMs?: number;
  deadlineMs?: number;
};

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function fullJitter(attempt: number, baseMs: number, capMs: number) {
  return Math.random() * Math.min(capMs, baseMs * 2 ** attempt);
}

function parseRetryAfter(value: string | null): number | null {
  if (!value) return null;
  const asNumber = Number(value);
  if (Number.isFinite(asNumber)) return Math.max(0, asNumber * 1000);
  const date = Date.parse(value);
  return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}

export async function postWithRetry(
  url: string,
  body: unknown,
  headers: Record<string, string>,
  { maxAttempts = 5, baseMs = 500, capMs = 20_000, deadlineMs = 90_000 }: Options = {},
): Promise<Response> {
  const started = Date.now();
  let lastError: unknown;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    if (Date.now() - started > deadlineMs) break;

    let res: Response;
    try {
      res = await fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json", ...headers },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(120_000),
      });
    } catch (e) {
      lastError = e; // ネットワーク系はリトライ
      await sleep(fullJitter(attempt, baseMs, capMs));
      continue;
    }

    if (res.ok) return res;
    if (!RETRYABLE.has(res.status)) {
      throw new Error(`non-retryable: HTTP ${res.status}`);
    }

    lastError = new Error(`HTTP ${res.status}`);
    const retryAfter = parseRetryAfter(res.headers.get("retry-after"));
    await sleep(retryAfter ?? fullJitter(attempt, baseMs, capMs));
  }

  throw new Error(`retry exhausted or deadline exceeded`, { cause: lastError });
}

AbortSignal.timeout() は Node 18 以降で利用できます。全体デッドライン(deadlineMs)と1回あたりのタイムアウトは別物なので、両方持たせます。

LLM API 特有の注意:再試行は「二重課金」になりうる

通常の Web API と違い、LLM API のリトライには次のコストが伴います。

したがって、方針を分けます。

状況 方針
接続失敗・429・5xx(応答本文を受け取る前) リトライしてよい
ストリーミング開始前の失敗 リトライしてよい
ストリーミング途中の切断 自動リトライしない。部分出力を捨てるか、ユーザー操作で再生成
エージェントのツール実行(決済・DB書き込み・メール送信) ツール側に冪等キーを付けてからリトライ

エージェント用途では「LLM 呼び出しのリトライ」と「ツール実行のリトライ」を混ぜないことが重要です。後者は副作用を持つため、リトライ前に必ず一意のリクエストIDを発行し、実行側で重複排除します。DeepSeek API 側に冪等キーヘッダが用意されているかは公式ドキュメントで確認し、無ければアプリケーション側で管理してください。

リトライ予算とサーキットブレーカー

個々の呼び出しのリトライを固めても、上位で無限に再試行すると同じことになります。

バッチ処理では「リトライで復旧できなかったリクエストを DLQ(デッドレターキュー)に積み、後で再実行」とするのが安全です。

つまずきポイント

症状 対処
429 が減らず、全体が遅くなる ジッターなしの可能性。フルジッターに変更し、同時実行数も制限する
1リクエストが数分かかる 試行回数 × cap が上限になっている。全体デッドラインを設定する
認証エラーで無駄に待つ 400/401/402/404 を非リトライ対象に分類する
同じ応答が二重に課金される ストリーミング途中の切断をリトライしている。途中失敗はリトライしない
Retry-After を無視してさらに 429 ヘッダがあればバックオフより優先する(上限 cap でクリップ)
タイムアウトが効かない httpx.Timeout の connect/read/write/pool を個別に設定する

まとめ

チェックリスト