0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM APIのリトライ・タイムアウト・レート制限対策をPythonで実装する

0
Last updated at Posted at 2026-10-09

LLM APIをアプリケーションから呼び出すと、たまにタイムアウトしたり、429 Too Many Requests が返ったりします。ここで単純に「失敗したら何度も再送する」と、障害を長引かせたり、同じリクエストを重複実行したりします。

私はLLM APIの呼び出し処理を、次の3点に分けて実装しました。

  • 接続・読み取りのタイムアウトを設定する
  • 一時的な失敗だけを指数バックオフ付きで再試行する
  • クライアント側でもリクエスト頻度を制御する

この記事では、OpenAI互換形式のHTTP APIを例に、Pythonとrequestsだけで実装します。

まずエラーを分類する

すべてのエラーをリトライしてはいけません。

401や403は認証・権限の問題、400は入力値の問題である可能性が高いため、同じ内容を再送しても成功しません。一方、次のようなエラーは時間を置けば成功する可能性があります。

  • 408 Request Timeout
  • 429 Too Many Requests
  • 500 Internal Server Error
  • 502 Bad Gateway
  • 503 Service Unavailable
  • 504 Gateway Timeout

429は一定時間内のリクエスト数が多すぎる状態で、レスポンスにRetry-Afterヘッダーが含まれることがあります。指定されていれば、その秒数を優先して待つのが基本です。MDNのHTTP 429解説

タイムアウトは接続と読み取りを分ける

タイムアウト未指定のHTTPクライアントは、相手が応答しない場合に長時間停止することがあります。requestsでは、timeout=(接続, 読み取り)のように個別指定できます。なお、これはリクエスト全体の制限時間ではないため、別途デッドラインも管理します。Requestsのタイムアウト仕様

LLMでは生成時間が長くなることがあるため、接続は短め、読み取りは用途に合わせて長めに設定します。今回の例では接続3秒、読み取り30秒を初期値にしました。

指数バックオフとジッターを使う

リトライ間隔を固定値にすると、同じタイミングで大量のクライアントが再接続してしまいます。そこで、待ち時間を次のように増やします。

0.5秒 → 1秒 → 2秒 → 4秒

さらにランダムな揺らぎ(ジッター)を加えることで、再試行の集中を避けます。

ただし、リトライ回数には上限を設けます。私は回数だけでなく「この処理全体に使える時間」も制限するようにしました。

実装例

LLM API呼び出し全体の流れを図にすると次の通りです。

まず仮想環境を作り、requestsをインストールします。

python -m venv .venv
source .venv/bin/activate
python -m pip install requests

Windows PowerShellの場合は次のコマンドです。

.venv\Scripts\Activate.ps1
python -m pip install requests

次が実装例です。LLM_API_URLは利用するAPIのエンドポイントに置き換えてください。

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

import requests


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


class RateLimiter:
    """プロセス内の最大リクエスト頻度を制御する"""

    def __init__(self, requests_per_second: float):
        self.interval = 1.0 / requests_per_second
        self.lock = threading.Lock()
        self.next_at = 0.0

    def wait(self) -> None:
        with self.lock:
            now = time.monotonic()
            wait_seconds = max(0.0, self.next_at - now)
            self.next_at = max(now, self.next_at) + self.interval

        if wait_seconds > 0:
            time.sleep(wait_seconds)


def retry_after_seconds(response: requests.Response):
    value = response.headers.get("Retry-After")
    if not value:
        return None

    try:
        return max(0.0, float(value))
    except ValueError:
        pass

    try:
        retry_at = parsedate_to_datetime(value)
        if retry_at.tzinfo is None:
            retry_at = retry_at.replace(tzinfo=timezone.utc)

        return max(
            0.0,
            (retry_at - datetime.now(timezone.utc)).total_seconds(),
        )
    except (TypeError, ValueError):
        return None


def call_llm(
    prompt: str,
    *,
    limiter: RateLimiter,
    max_attempts: int = 4,
    deadline_seconds: float = 90.0,
) -> dict:
    url = os.environ["LLM_API_URL"]
    api_key = os.environ["LLM_API_KEY"]

    payload = {
        "model": os.getenv("LLM_MODEL", "example-model"),
        "messages": [
            {"role": "user", "content": prompt}
        ],
    }

    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }

    deadline_at = time.monotonic() + deadline_seconds
    last_error = None

    with requests.Session() as session:
        for attempt in range(1, max_attempts + 1):
            remaining = deadline_at - time.monotonic()
            if remaining <= 0:
                break

            limiter.wait()

            connect_timeout = min(3.0, max(0.1, remaining))
            read_timeout = min(30.0, max(0.1, remaining))

            try:
                response = session.post(
                    url,
                    headers=headers,
                    json=payload,
                    timeout=(connect_timeout, read_timeout),
                )
            except (requests.Timeout, requests.ConnectionError) as exc:
                last_error = exc
                retry_after = None
            else:
                if 200 <= response.status_code < 300:
                    return response.json()

                if response.status_code not in RETRYABLE_STATUS:
                    raise RuntimeError(
                        f"LLM API failed: {response.status_code} "
                        f"{response.text[:300]}"
                    )

                last_error = RuntimeError(
                    f"temporary error: {response.status_code}"
                )
                retry_after = retry_after_seconds(response)

            if attempt == max_attempts:
                break

            backoff = min(8.0, 0.5 * (2 ** (attempt - 1)))
            jitter = random.uniform(0.0, 0.2)
            delay = max(retry_after or 0.0, backoff + jitter)

            remaining = deadline_at - time.monotonic()
            if remaining <= 0:
                break

            time.sleep(min(delay, remaining))

    raise TimeoutError(
        f"LLM API did not succeed within the deadline: {last_error}"
    )


if __name__ == "__main__":
    limiter = RateLimiter(requests_per_second=2.0)

    result = call_llm(
        "リトライ設計の要点を3つ、日本語で説明してください。",
        limiter=limiter,
    )

    print(result)

実行前に環境変数を設定します。

export LLM_API_URL="https://api.example.com/v1/chat/completions"
export LLM_API_KEY="your-api-key"
export LLM_MODEL="your-model"
python app.py

このコードでは、Retry-Afterが秒数の場合とHTTP日時の場合の両方を処理しています。また、指数バックオフよりサーバー指定の待ち時間が長い場合は、Retry-Afterを優先します。

レート制限は「同時実行数」と分けて考える

RateLimiterは、1秒あたりのリクエスト数を制御します。ただし、これは同時実行数の制限とは別です。

例えば、1秒に2件まで送れても、1リクエストが30秒かかれば、多数の処理が同時に実行される可能性があります。実運用では、次の2つを別々に設定します。

  • リクエスト頻度: 1秒あたり何件送るか
  • 同時実行数: 最大何件を処理中にするか

同時実行数は、ワーカー数やthreading.Semaphoreで制限できます。複数プロセスや複数サーバーから呼び出す場合は、プロセス内のRateLimiterだけでは不十分です。共有キューやRedisなど、全体で共有できる仕組みが必要になります。

リトライ時の重複実行に注意する

タイムアウトした場合、サーバー側では処理が完了している可能性があります。クライアントが応答を受け取れなかっただけで、同じリクエストを再送すると二重処理になることがあります。

LLMのテキスト生成だけなら影響が限定的な場合もありますが、生成結果を保存する、外部システムを更新する、といった処理を同時に行う場合は注意が必要です。

対策として、次のような設計にします。

  • リクエストごとに一意な処理IDを付ける
  • APIが対応していればIdempotency-Keyを利用する
  • API呼び出しとデータ保存を分離する
  • リトライ回数をログに残す
  • プロンプト本文やAPIキーはログに出さない

私はログに、処理ID、試行回数、HTTPステータス、待ち時間、総経過時間だけを記録するようにしています。これだけでも、429が多いのか、タイムアウトが多いのかを切り分けやすくなります。

まとめ

LLM APIを安定して利用するには、単純な再送ではなく、失敗の種類に応じた制御が必要です。

  • 400や401などは原則リトライしない
  • 接続タイムアウトと読み取りタイムアウトを設定する
  • 429ではRetry-Afterを尊重する
  • 指数バックオフとジッターで再試行を分散する
  • リトライ回数だけでなく全体のデッドラインも設ける
  • リクエスト頻度と同時実行数を別々に制御する
  • タイムアウト後の重複実行を考慮する

まずは手元のAPI呼び出しをこの形に置き換え、ログでエラーの傾向を確認するところから始めるのがおすすめです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?