モジュール 03 · 第 4 回

エラー、リトライ、レート制限、そして費用

よくある API エラーをいくつか再現し、openai SDK が既定でどんなリトライをしてくれるのかを見たうえで、タイムアウト、指数バックオフ、同時実行数の制限、費用の記録を備えた、プロジェクトにそのまま入れて使える呼び出し関数を書きます。

  • 約 40 分
  • 難易度:中級
  • 検証:2026-09-14 deepseek-flash、openai 3.14

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

自分のパソコンで例を動かしているあいだは、API 呼び出しがエラーになることはほとんどありません。公開すると事情は変わります。ピーク時にはサーバーが 429 を返してリクエストが多すぎると言い、あるリクエストは 1 分間応答がなく、ときには 500 エラーも出ます。コードで備えていなければ、ユーザーは長いエラーを目にするか、ページがいつまでもくるくる回ることになります。

お金の問題もあります。書き間違えたループ一つ、上限のないリトライ一つで、一晩で 1 か月分の予算を燃やしてしまうかもしれません。

この課では、まずよくあるエラーを一通り再現し、それからプロジェクトにそのまま入れて使える呼び出し関数を書きます。

よくあるエラー

code/03-llm-apps/errors.py はわざと三種類のエラーを起こします。

print("== 1. 密钥错误")
try:
    OpenAI(api_key="sk-wrong", base_url=BASE_URL).chat.completions.create(model=MODEL, messages=HELLO)
except openai.AuthenticationError as e:
    print(f"{type(e).__name__},状态码 {e.status_code}")

print("== 2. 模型名写错")
try:
    OpenAI(api_key=os.environ["LLM_API_KEY"], base_url=BASE_URL).chat.completions.create(model="deepseek-flsh", messages=HELLO)
except openai.APIStatusError as e:
    print(f"{type(e).__name__},状态码 {e.status_code},{e.message[:100]}")

print("== 3. 超时(故意把超时设成 0.5 秒,并关掉 SDK 自带的重试)")
start = time.time()
try:
    OpenAI(api_key=os.environ["LLM_API_KEY"], base_url=BASE_URL, timeout=0.5, max_retries=0).chat.completions.create(
        model=MODEL, messages=[{"role": "user", "content": "写一篇 800 字的文章"}], extra_body=NO_THINKING)
except openai.APITimeoutError as e:
    print(f"{type(e).__name__},用了 {time.time() - start:.1f} 秒")

実行結果です。

== 1. 密钥错误
AuthenticationError,状态码 401
== 2. 模型名写错
BadRequestError,状态码 400,Error code: 400 - {'error': {'message': 'The supported API model names are deepseek-flash, deepseek-
== 3. 超时(故意把超时设成 0.5 秒,并关掉 SDK 自带的重试)
APITimeoutError,用了 0.7 秒

openai SDK はエラーの種類ごとに別の例外クラスに変えてくれるので、種類に応じて処理を分けられます。よく出てくるのは次のものです。

例外 ステータスコード 原因 リトライすべきか
AuthenticationError 401 キーが間違っているか無効 すべきでない。何回やっても同じ
PermissionDeniedError 403 権限がない すべきでない
BadRequestError 400 リクエスト自体に問題がある:モデル名の誤り、不正なパラメータ、コンテキスト長の超過 すべきでない。コードを直す
RateLimitError 429 リクエストが多すぎてレート制限された すべき。少し待ってから
InternalServerError 500 以上 サーバー側で問題が起きた すべき。たいてい一時的
APITimeoutError なし 長く待っても応答がない すべき
APIConnectionError なし ネットワークにつながらない すべき

法則は簡単です。あなたの側の誤りなら、リトライは無駄。相手側やネットワークの誤りなら、リトライしてよい。モデル名を間違えたときのエラーメッセージは親切で、対応しているモデル名を一覧で示してくれるので、そのとおりに直せば済みます。

また、残高が足りないとき、DeepSeek は 402 のステータスコードを返し、SDK では汎用の APIStatusError になります。このエラーもリトライすべきではなく、チャージするよう通知すべきです。

SDK がすでにやってくれていること

意外と知られていませんが、openai SDK は既定で自動リトライをします。現在のバージョン(3.14)のソースコードを調べたところ、次のとおりでした。

  • 既定は max_retries=2、つまり失敗後に最大 2 回まで再試行します。
  • リトライする場合:タイムアウト、接続失敗、そしてステータスコード 408、409、429 と 500 以上のすべてのエラー。サーバーがレスポンスヘッダーでリトライする、あるいはしないよう明示した場合も、それに従います。
  • リトライの間には待機があり、0.5 秒から始まって毎回倍になり、最長 8 秒です。
  • 既定のタイムアウトは 600 秒で、そのうち接続の確立は最大 5 秒待ちます。

つまり何も書かなくても、たまの 429 や 500 は SDK が黙ってリトライで片づけてくれます。これはよいことですが、注意すべき問題が二つあります。

600 秒のタイムアウトは長すぎる。ユーザーは Web ページで 10 分も待ちません。一般的な会話の場面なら、タイムアウトは 30~60 秒にすることをおすすめします。思考をオンにして複雑なタスクをこなすなら、もう少し長くてもかまいません。クライアントを作るときに timeout=60 を渡すだけです。

リトライが起きたことが見えない。SDK のリトライは静かに行われるので、1 回の呼び出しが実は 3 回試行し、十数秒待っていたことがわかりません。「なぜこんなに遅いのか」を調べるとき、この情報はとても重要です。

プロジェクトに入れて使える呼び出し関数

そこで私はたいてい SDK 組み込みのリトライをオフにして、リトライ、レート制限、記録をひとまとめにした呼び出し関数を自分で書きます。

client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url=BASE_URL,
    timeout=60,  # 单次请求最多等 60 秒
    max_retries=0,  # 关掉 SDK 自带的重试,由下面的函数统一处理,方便记录
)
RETRYABLE = (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError, openai.InternalServerError)
limiter = threading.Semaphore(5)  # 同一时刻最多 5 个请求在路上
log_lock = threading.Lock()
LOG = Path("calls.jsonl")


def call_llm(messages, max_attempts=4, **kwargs):
    for attempt in range(1, max_attempts + 1):
        start = time.time()
        try:
            with limiter:
                response = client.chat.completions.create(model=MODEL, messages=messages, **kwargs)
        except RETRYABLE as e:
            if attempt == max_attempts:
                raise
            # 指数退避:1 秒、2 秒、4 秒……再加一点随机,避免大家同时重试
            wait = 2 ** (attempt - 1) + random.random()
            print(f"  第 {attempt} 次失败({type(e).__name__}),{wait:.1f} 秒后重试")
            time.sleep(wait)
            continue
        record = {
            "time": time.strftime("%Y-%m-%d %H:%M:%S"),
            "model": response.model,
            "seconds": round(time.time() - start, 2),
            "prompt_tokens": response.usage.prompt_tokens,
            "completion_tokens": response.usage.completion_tokens,
            "cost_usd": round(cost_usd(response.usage, MODEL), 6),
            "attempts": attempt,
        }
        with log_lock:
            with LOG.open("a") as f:
                f.write(json.dumps(record, ensure_ascii=False) + "\n")
        return response

ブロックごとに説明します。

リトライすべきエラーだけをリトライするRETRYABLE にはリトライしてよい四種類の例外を並べています。401 や 400 のようなエラーは入っていないので、そのまま投げられ、すぐに気づけます。

指数バックオフとランダムな揺らぎ。1 回目の失敗では 1 秒ちょっと、2 回目は 2 秒ちょっと、3 回目は 4 秒ちょっと待ちます。待ち時間を毎回倍にするのは、サーバーに回復する時間を与えるためです。相手が過負荷のとき、毎秒リトライしても状況を悪くするだけです。random.random() のランダムな部分を加えるのは、多くのリクエストが同時に失敗し、同時にリトライして、波のように押し寄せるのを避けるためです。

リトライ回数に上限を設ける。4 回とも失敗したら諦めて、例外を呼び出し元に投げます。無限リトライは決して書かないでください。

同時実行数を制限するthreading.Semaphore(5) で、同時に進行するリクエストを最大 5 つに抑えます。サービス側には同時実行数と 1 分あたりのリクエスト数の制限があり(2026 年 9 月時点で、DeepSeek のドキュメントには deepseek-flash の同時実行上限が 2500、deepseek-v4-pro が 500 とあります)、429 を待つより先に自分で抑えておくほうがよいのです。さらに大事なのは、コードのバグ一つで一瞬に数千件のリクエストが飛ぶのを防げることです。

呼び出しごとに記録する。時刻、モデル、所要時間、入出力トークン、費用、試行回数を 1 行の JSON にして calls.jsonl に追記します。費用はモジュール 01 第 4 課の cost_usd を使っています。このログがあれば、「この機能は 1 日いくらかかるのか」「どのリクエストが特に遅いのか」「リトライはどれくらい頻繁か」といった問いに答えられます。モジュール 06 第 3 課では、これをもとに本格的なログと監視を作ります。

試す:20 件を並行して呼び出す

questions = [f"用一句话解释 HTTP 状态码 {code} 的含义。" for code in [200, 201, 204, 301, 302, 304, 400, 401, 403, 404,
                                                                  405, 408, 409, 418, 429, 500, 502, 503, 504, 505]]
with ThreadPoolExecutor(20) as pool:
    answers = list(pool.map(lambda q: call_llm([{"role": "user", "content": q}], extra_body=NO_THINKING), questions))

20 本のスレッドが同時に呼び出しますが、limiter は同時に 5 つしか進めません。

== 4. 并发 20 个请求,最多同时 5 个,每次调用记账
20 个请求用了 3.5 秒
第一条回答: HTTP 状态码 200 表示服务器成功处理了请求,并正常返回了所请求的资源。
日志共 20 条,总花费 0.000655 美元,第一条:{'time': '2026-09-14 21:35:48', 'model': 'deepseek-flash', 'seconds': 0.62, 'prompt_tokens': 16, 'completion_tokens': 19, 'cost_usd': 2.8e-05, 'attempts': 1}

20 件のリクエストに 3.5 秒かかりました。一つずつ順番に呼ぶと約 12 秒かかります。今回の実行ではリトライが必要なエラーには出会わなかったので、どの記録も attempts は 1 です。

費用のためのいくつかの安全装置

リトライと同時実行数の管理は想定外の事態を防ぐものですが、次のものは請求書を守るためのものです。

  • 呼び出しごとに max_tokens を設定する。モデルがいつまでも出力し続けるのを防ぎます。思考をオンにしているときは余裕を持たせてください。モジュール 00 第 3 課で説明したように、思考もこの上限を消費します。
  • ループに上限を設ける。ツール呼び出しのループにも、リトライのループにも、最大回数が必要です。第 3 課のツール呼び出しのループを最大 5 ラウンドにしていたのはこのためです。
  • プラットフォームで残高のアラートを設定する。DeepSeek は前払い制で、残高がなくなれば止まるので、もともと上限があるようなものです。それでも、一定期間に使う分だけをチャージし、残高に気を配っておくほうがよいでしょう。
  • ログを見る。毎日 calls.jsonl の合計費用に目を通せば、異常な増加はすぐにわかります。

練習問題

  1. limiter の同時実行数を 1 と 20 に変えてそれぞれ実行し、20 件のリクエストの合計所要時間を比べてください。
  2. 模擬的な障害を作ってください。最初の 2 回の呼び出しでは openai.APITimeoutError を投げ、3 回目で本当にモデルを呼ぶ関数を定義し、それを call_llm に差し込んで、リトライと待機の出力、ログの attempts を見ます。(ヒント:openai.APITimeoutError(request=...) には request 引数が必要で、httpx.Request("POST", "https://example.com") を使えます。)
  3. calls.jsonl を読み込み、呼び出し総数、合計費用、平均所要時間、最も遅かった 3 回の呼び出しを表示する小さなスクリプトを書いてください。

確認テスト

1. 401 エラーが出たら、リトライすべきですか?429 ならどうですか?

401 はキーの誤りで、何回リトライしても結果は同じなので、すぐにエラーにして人にキーを確認してもらうべきです。429 はリクエストが多すぎてレート制限されたもので、一時的なものなので、少し待ってからリトライすべきです。待ち時間は回を追うごとに長くします。

2. リトライの間の待ち時間を毎回倍にし、さらに少しランダムな値を加えるのはなぜですか?

倍にするのはサーバーに回復する時間を与えるためで、相手が過負荷のときに頻繁にリトライすると状況を悪くするだけです。ランダムな値は、同時に失敗した多くのリクエストのリトライのタイミングをずらし、それらが同じ瞬間に再びサーバーへ押し寄せるのを避けるためです。

3. 何も設定しないとき、openai SDK は自動でリトライしますか?

します。現在のバージョンは既定で max_retries=2 で、タイムアウト、接続失敗、そしてステータスコード 408、409、429 と 500 以上のときに、少し待ってから自動でリトライし、最大 2 回まで行います。既定のタイムアウトは 600 秒で、ほとんどの対話的な場面には長すぎるので、自分で timeout を設定することをおすすめします。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…