はじめに
LLM API router を挟んだ構成にすると、アプリ側のコードはあまり変えずに model や upstream を切り替えられます。OpenAI SDK なら base_url や baseURL を差し替えるだけで動く場面も多いです。
ただ、私はそこで一度雑に済ませてしまい、あとから 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_id、request_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 logging と OPENAI_LOG
|
OPENAI_LOG、logLevel、logger
|
OpenAI の docs と README では、公式 SDK の request timeout は default 10 分、retry は default 2 回と説明されています。また README では、connection error、408、409、429、>=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 する可能性がある
つまり、同じ 429 や 500 でも、どの層が何回試したのかをログなしで追うのが難しくなります。
たとえば 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_url、timeout、max_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_id、request_id、timeout_sec、max_retries、elapsed_ms が残ります。prompt 本文は出していません。
TypeScript の最小 wrapper
TypeScript でも同じことをします。Node/TypeScript SDK は baseURL、maxRetries という 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 には logLevel と logger があります。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 |
429、408、5xx の分類に必要 |
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 に渡す前後の状態を薄くてもよいので残しておきたいです。
私なら、まずこの順番で確認します。
- SDK の
base_url/baseURLが router を向いているか - model 名が今の key と route で使えるか
- user-facing request の timeout が長すぎないか
- SDK retry と job retry が二重になっていないか
- 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_idとrequest_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 へ閉じ込めておく方がよさそうでした。
間違いあったらコメントください。よろしくお願いします。