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?

OpenAI SDK の timeout/retry を API ルーター前提で見直す

0
Posted at

はじめに

LLM API router を挟んだ構成にすると、アプリ側のコードはあまり変えずに model や upstream を切り替えられます。OpenAI SDK なら base_urlbaseURL を差し替えるだけで動く場面も多いです。

ただ、私はそこで一度雑に済ませてしまい、あとから timeout と retry のログが足りなくて困りました。SDK も retry する。router も upstream を切り替えるかもしれない。アプリの job queue も retry する。こうなると、障害時に「何回試したのか」「どこで待ったのか」が見えづらくなります。

この記事では、OpenAI SDK を API router 前提で使うときに、timeout、retry、idempotency っぽい扱い、ログ出力をどう決めるかを、Python と TypeScript の小さい wrapper で整理します。Flatkey AI のような OpenAI 互換 router を使う場合の話に寄せていますが、考え方自体は他の gateway でもだいたい同じだと思います。

3行まとめ

  • SDK の timeout と retry は default に任せず、client wrapper で明示する
  • router 配下では SDK retry、router retry、job retry が重なるので、まず低めの retry budget にする
  • operation_idrequest_id、timeout 値、retry 値、elapsed time を必ずログに残す

前提として確認した SDK の名前

2026-06-26 時点で、公式ドキュメントと SDK README/source を見て確認した名前です。

見たいもの Python SDK TypeScript SDK
base URL base_url baseURL
timeout timeout timeout
retry 回数 max_retries maxRetries
request 単位の上書き client.with_options(...) 第2引数の request options
成功時の request id response._request_id response._request_id
失敗時の request id APIStatusError.request_id APIError.request_id
SDK logging stdlib loggingOPENAI_LOG OPENAI_LOGlogLevellogger

OpenAI の docs と README では、公式 SDK の request timeout は default 10 分、retry は default 2 回と説明されています。また README では、connection error、408409429>=500 が retry 対象として説明されています。

ここで大事なのは、default が悪いという話ではないです。通常の script なら助かることもあります。ただ、API router と web application の間に置くなら、default のままでは待ち時間と retry 回数が運用上の意図とずれることがあります。

API ルーターを挟むと何が変わるか

router を挟まない場合、失敗経路はだいたい次のように見えます。

  • application が OpenAI SDK を呼ぶ
  • SDK が network、timeout、status code を見て retry する
  • 最後に application が成功か失敗を受け取る

router を挟むと、間にもう一段入ります。

  • application が OpenAI SDK を呼ぶ
  • SDK が retry する可能性がある
  • router が model routing や upstream switching をする可能性がある
  • upstream provider が timeout、rate limit、5xx を返す可能性がある
  • application の job queue がさらに retry する可能性がある

つまり、同じ 429500 でも、どの層が何回試したのかをログなしで追うのが難しくなります。

たとえば user-facing な API handler で SDK default の 10 分 timeout をそのまま使うと、アプリ側の HTTP timeout より SDK の方が長く待つかもしれません。逆に SDK timeout を短くしすぎると、router が upstream を切り替える余地をアプリ側が先に潰すかもしれません。

なので私は、router を挟むときは次の 3 つを分けて考えるようにしています。

予算 何を決めるか
request timeout 1 回の SDK call を何秒まで待つか
SDK retry budget SDK が同じ request を何回 retry してよいか
application retry budget job queue や business logic として再実行してよいか

この 3 つを混ぜると、「一度のユーザー操作で実は何回も生成していた」という状態になりやすいです。特に書き込み、課金、通知、外部 side effect が近い処理では気を付けた方がよさそうです。

私なら timeout / retry をこう分ける

私の基準はかなり地味です。

用途 SDK timeout SDK retry application retry
画面操作の同期 API 8 秒から 20 秒 0 回から 1 回 原則なし。ユーザーに再実行してもらう
社内 tool の手動実行 30 秒から 90 秒 1 回 操作者が判断
batch / enrichment 2 分から 10 分 1 回から 2 回 queue 側で backoff
streaming 初回接続と read timeout を別に考える 低め [DONE] まで読めたかを見る

数字は絶対値ではありません。大事なのは、wrapper の中に値を閉じ込めて、ログに出すことです。

SDK に任せる retry は、network error や短い 5xx には便利です。一方で、router 側も upstream を見ているなら、アプリ側の SDK が大量に retry しなくてもよい場面があります。私は最初は max_retries=0 または 1 に寄せて、必要になったら job queue 側で retry policy を足す方が追いやすいと思っています。

idempotency については、この記事では SDK 内部の挙動に寄せすぎず、アプリ側の operation_id を必ず発行する方針にしています。同じユーザー操作、同じ job、同じ business event を追える ID です。これがあると、timeout 後に再実行されたのか、ユーザーが別操作として押し直したのかを少し分けやすくなります。

Python の最小 wrapper

まず Python です。base_urltimeoutmax_retries を client 生成時に明示し、request 単位では with_options でさらに絞っています。

import logging
import os
import time
import uuid

from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI

logging.basicConfig(level=os.getenv("APP_LOG_LEVEL", "INFO"))
logger = logging.getLogger("llm_client")

client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],
    base_url=os.getenv("OPENAI_BASE_URL", "https://router.flatkey.ai/v1"),
    timeout=20.0,
    max_retries=1,
)


def ask_llm(prompt: str, operation_id: str | None = None) -> str:
    operation_id = operation_id or str(uuid.uuid4())
    started = time.monotonic()
    model = os.environ["OPENAI_MODEL"]

    try:
        response = client.with_options(
            timeout=12.0,
            max_retries=0,
        ).chat.completions.create(
            model=model,
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            max_tokens=128,
        )

        logger.info(
            "llm_request_ok",
            extra={
                "operation_id": operation_id,
                "model": model,
                "request_id": response._request_id,
                "timeout_sec": 12.0,
                "max_retries": 0,
                "elapsed_ms": round((time.monotonic() - started) * 1000),
            },
        )
        return response.choices[0].message.content or ""

    except APIStatusError as exc:
        logger.warning(
            "llm_request_status_error",
            extra={
                "operation_id": operation_id,
                "model": model,
                "request_id": exc.request_id,
                "status_code": exc.status_code,
                "timeout_sec": 12.0,
                "max_retries": 0,
                "elapsed_ms": round((time.monotonic() - started) * 1000),
            },
        )
        raise

    except APITimeoutError:
        logger.warning(
            "llm_request_timeout",
            extra={
                "operation_id": operation_id,
                "model": model,
                "timeout_sec": 12.0,
                "max_retries": 0,
                "elapsed_ms": round((time.monotonic() - started) * 1000),
            },
        )
        raise

    except APIConnectionError:
        logger.warning(
            "llm_request_connection_error",
            extra={
                "operation_id": operation_id,
                "model": model,
                "timeout_sec": 12.0,
                "max_retries": 0,
                "elapsed_ms": round((time.monotonic() - started) * 1000),
            },
        )
        raise

ポイントは、OpenAI(...) の default をそのまま使わないことです。さらに、画面から呼ばれる短い処理では request 単位で max_retries=0 にしています。client 全体では 1 にしておき、必要な場所だけ 0 に落とす形です。

この wrapper はまだ簡単ですが、運用で欲しい情報はだいたい入っています。成功しても失敗しても、operation_idrequest_idtimeout_secmax_retrieselapsed_ms が残ります。prompt 本文は出していません。

TypeScript の最小 wrapper

TypeScript でも同じことをします。Node/TypeScript SDK は baseURLmaxRetries という camelCase なので、Python と混ぜないように wrapper に閉じ込めます。

import crypto from "node:crypto";
import { performance } from "node:perf_hooks";
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL ?? "https://router.flatkey.ai/v1",
  timeout: 20_000,
  maxRetries: 1,
  logLevel: process.env.OPENAI_SDK_LOG_LEVEL === "debug" ? "debug" : "warn",
});

function log(event: string, fields: Record<string, unknown>) {
  console.info(JSON.stringify({ event, ...fields }));
}

export async function askLlm(prompt: string, operationId = crypto.randomUUID()) {
  const started = performance.now();
  const model = process.env.OPENAI_MODEL;

  if (!model) {
    throw new Error("OPENAI_MODEL is required");
  }

  try {
    const response = await client.chat.completions.create(
      {
        model,
        messages: [{ role: "user", content: prompt }],
        temperature: 0,
        max_tokens: 128,
      },
      {
        timeout: 12_000,
        maxRetries: 0,
      },
    );

    log("llm_request_ok", {
      operation_id: operationId,
      model,
      request_id: response._request_id,
      timeout_ms: 12_000,
      max_retries: 0,
      elapsed_ms: Math.round(performance.now() - started),
    });

    return response.choices[0]?.message?.content ?? "";
  } catch (error) {
    if (error instanceof OpenAI.APIError) {
      log("llm_request_api_error", {
        operation_id: operationId,
        model,
        request_id: error.request_id,
        status_code: error.status,
        error_name: error.name,
        timeout_ms: 12_000,
        max_retries: 0,
        elapsed_ms: Math.round(performance.now() - started),
      });
    } else {
      log("llm_request_unknown_error", {
        operation_id: operationId,
        model,
        timeout_ms: 12_000,
        max_retries: 0,
        elapsed_ms: Math.round(performance.now() - started),
      });
    }
    throw error;
  }
}

TypeScript SDK には logLevellogger があります。debug にすると HTTP request / response の詳細が出るため、検証時には便利です。ただし body に prompt や response が含まれる可能性があるので、本番では安易に debug を常時有効にしない方がよいと思います。

私は、SDK の debug log は一時的な調査用、上の log(...) は恒久的な運用 log、という分け方にしています。

ハマりやすい失敗パターン

私が一番避けたいのは、「たぶん retry されたと思う」という状態です。LLM API の障害は、単純な 1 回の失敗だけではなく、複数の層が少しずつ待った結果として表に出ることがあります。

たとえば、アプリ側の HTTP handler は 30 秒で timeout するのに、SDK は 10 分待つ設定になっているケースです。ユーザーから見ると画面は失敗していますが、backend process はまだ LLM API の返答を待っているかもしれません。そこに job queue の retry が重なると、同じユーザー操作に見える request が複数走ることがあります。

もう一つは、429 を全部同じように retry することです。短い rate limit なら待てば回復するかもしれませんが、quota 上限、key の権限、router の group 制限が原因なら、SDK が数回 retry しても成功しません。ここは status code だけではなく、error body、router 側の usage、対象 model を一緒に見た方がよいです。

streaming も少し別物です。途中まで token が届いたあとで切れた場合、同じ request を機械的に投げ直すと、ユーザーには二つの回答候補が見えるかもしれません。streaming は retry 回数より、最後の [DONE] 相当まで読めたか、途中結果を UI にどう扱うかを先に決める方が現実的だと思います。

つまり、retry は「失敗したらもう一回」ではなく、「どの層で、何を根拠に、何回までやるか」を決めるものです。小さい wrapper でも、その判断材料を毎回同じ形で残せるなら十分価値があります。

ログに残す項目

LLM client wrapper のログには、最低限このあたりを残すようにしています。

項目 理由
operation_id ユーザー操作や job と request を結びつける
request_id provider や router 側に問い合わせるときの手がかり
model route や upstream の切り分けに必要
base_url_host OpenAI 直か router 経由かを後から見る
timeout_ms / timeout_sec 待ち時間の意図を残す
max_retries SDK retry budget を残す
status_code 4294085xx の分類に必要
elapsed_ms timeout なのか遅延なのかを見る
stream streaming と non-streaming を混ぜない

例としては、こういう 1 行が残っていれば十分です。

{
  "event": "llm_request_api_error",
  "operation_id": "2e7c2f8c-9cbb-4eb7-8c4f-2ac0e72f4f27",
  "request_id": "req_abc123",
  "model": "your-model",
  "status_code": 429,
  "timeout_ms": 12000,
  "max_retries": 0,
  "elapsed_ms": 1234,
  "stream": false
}

逆に、prompt 全文、API key、顧客データ、長い response body は普段の application log には出しません。調査用に必要なら、redaction した別経路に寄せます。

値の決め方の目安

私が最近意識しているのは、「retry は増やす前に置き場所を決める」です。

SDK retry は、短い network error や一時的な 500 には効きます。ただし、router が upstream を切り替える設計なら、SDK の中で 2 回、router の中で数回、queue で数回、という重なり方をするかもしれません。

なので、まずは次のように分けます。

状況 私の初期値
synchronous API SDK retry 0 or 1
retry しても同じ結果になりやすい読み取り SDK retry 1
batch の独立 task SDK retry 1、queue retry あり
side effect を伴う処理 SDK retry を低くし、operation_id で二重実行を検知
streaming retry よりも終端検知と再開設計を優先

生成結果そのものは、同じ prompt でも完全に同じとは限りません。timeout 後に再実行したとき、最初の request が裏で成功していたのか、完全に失敗していたのか、application からは判断しにくいことがあります。

そのため、idempotency については「同じ SDK option を入れれば全部安全」とは考えず、業務上の単位で operation_id を持つ方が現実的だと思います。たとえば、同じ問い合わせに対する回答生成、同じ enrichment job、同じ通知作成を application 側で一意にしておく、という話です。

Flatkey AI のような router で見るところ

Flatkey AI のような router を使う場合、価値は model routing、load balancing、usage / billing visibility にあります。だからこそ、アプリ側の SDK wrapper では、router に渡す前後の状態を薄くてもよいので残しておきたいです。

私なら、まずこの順番で確認します。

  1. SDK の base_url / baseURL が router を向いているか
  2. model 名が今の key と route で使えるか
  3. user-facing request の timeout が長すぎないか
  4. SDK retry と job retry が二重になっていないか
  5. router dashboard の usage / error と application log の operation_id を突き合わせられるか

ここまで揃うと、「SDK が retry した」「router が upstream を見に行った」「queue が再実行した」を会話しやすくなります。逆に、wrapper がないまま各 feature が直接 SDK を呼ぶと、timeout 値も retry 値もばらばらになります。あとから直すのは少しつらいです。

さらに、障害調査のときは dashboard 側の時刻と application log の時刻を同じ timezone で見られるようにしておくと助かります。秒単位で完全に合わなくても、同じ operation_id と近い時刻が残っていれば、router 側の request と backend 側の handler を人間が追えます。

まとめ

OpenAI SDK は default のままでもかなり便利です。ただ、API router を挟む実運用では、timeout と retry を明示した小さい wrapper を置く方が安心でした。

今回の私の結論は次です。

  • base_url / baseURL の差し替えと同時に timeout / retry も見直す
  • SDK default の 10 分 timeout と 2 retry は、使う場所に合っているか確認する
  • router 配下では SDK retry を低めにし、application retry と分ける
  • operation_idrequest_id をログに残す
  • prompt や response body は普段の log に出さない

地味ですが、ここを先に決めると障害時の会話がかなり楽になります。LLM API の wrapper は薄くてよいので、各機能から直接 SDK を呼ばせない方が、あとで自分を助けると思います。

参考

  • OpenAI docs: Flex processing, API request timeouts
  • openai-python README: Retries, Timeouts, Logging, Request IDs
  • openai-node README: Retries, Timeouts, Logging, Request IDs
  • Flatkey AI: OpenAI-compatible router and usage visibility

おわりに

timeout と retry は、普段は目立たないわりに、障害時だけ急に重要になります。私もつい SDK default に寄せたくなりますが、router を挟むなら最初に wrapper へ閉じ込めておく方がよさそうでした。

間違いあったらコメントください。よろしくお願いします。

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?