HB DevTools

Knowledge

Gemini API の 429 エラー判定で分レート制限を日次上限と誤判定していたバグ

isDailyTokenLimit 関数の正規表現が分レート制限の 429 エラーにも誤マッチし、ボタンが永続的に非活性になっていた問題。isPerMinuteRateLimit をガード関数として分離することで解決しました。

Gemini APIエラーハンドリングデバッグ

1. 発生した現象

Markdown Formatter の「MD変換」ボタンをクリックした後、 Gemini API からレート制限エラー(HTTP 429)が返ってきた際に、ボタンが永続的に非活性(「上限到達」表示)になる問題が発生しました。

本来の仕様では「上限到達」として永続非活性になるのは日次トークン上限に達した場合のみです。 分あたりのレート制限(per-minute rate limit)は一時的なものであり、 数秒〜数十秒待てば再試行できるため、ボタンは非活性にしないという設計でした。

にも関わらず、分レート制限の 429 エラーでも isRateLimited: true が返され、 セッション中ずっとボタンが使えない状態になっていました。

2. 原因:2種類の 429 エラーを1関数で判定していた

問題の根源は isDailyTokenLimit() 関数の正規表現でした。 当初の実装では以下のパターンで日次上限を判定していました。

function isDailyTokenLimit(message: string): boolean {
  return /daily|per.?day|token.*quota|quota.*token|exceeded.*quota|free tier.*limit/i.test(message)
}

Gemini の分レート制限エラーのメッセージには、実際には"Quota exceeded for quota metric" といったフレーズが含まれていることがあります。 このため exceeded.*quota のパターンが分制限エラーにも誤マッチし、 日次上限と誤判定していました。

Gemini API の 429 エラーには以下の2種類があります。

  • 分レート制限(一時的): エラーメッセージに "retry in Xs"(秒単位の待機指示)が含まれる。 無料プランでは15回/分の制限。
  • 日次トークン上限(永続的):"retry in Xs" は含まれず、dailyper daytoken quota 等のキーワードが含まれる。 翌日まで使用不可。

2つを区別する最大の手がかりは "retry in Xs" の有無ですが、isDailyTokenLimit() はこの区別を一切行っていませんでした。

3. 修正:判定関数を2つに分離する

isPerMinuteRateLimit() を新たに追加し、isDailyTokenLimit() の冒頭でこれをガードチェックとして呼び出す構造に変更しました。

/** 分レート制限かどうかを判定する。"retry in Xs" パターンを検出 */
function isPerMinuteRateLimit(message: string): boolean {
  return /retry in \d+\.?\d*s\b/i.test(message)
}

/** 日次トークン上限かどうかを判定する */
function isDailyTokenLimit(message: string): boolean {
  // "retry in Xs" があれば分制限(一時的)→ 日次上限ではない
  if (isPerMinuteRateLimit(message)) return false
  return /daily|per.?day|token.*quota|quota.*token|exceeded.*quota|free tier.*limit/i.test(message)
}

分レート制限は "retry in Xs" という固有のフレーズを持つため、 このパターンを先行チェックすることで誤判定を防ぎます。"retry in Xs" が存在する場合は必ず一時的な分制限であり、 日次上限には含まれません。

4. 実装の詳細

429 ハンドラ全体の流れは以下のとおりです。

  • HTTP ステータス 429 を受け取ったら、レスポンスボディのerror.message を取得する。
  • isDailyTokenLimit(errorMsg) で日次上限かどうかを判定する (内部で isPerMinuteRateLimit がガードされる)。
  • 日次上限の場合: isRateLimited: true を返す。 クライアントはこのフラグを受け取ってセッション中ボタンを非活性化する。
  • 分制限の場合: isRateLimited: false を返す。"retry in Xs" から待機秒数を抽出し、"Xs ほど待ってから再試行してください" というメッセージをユーザーに提示する。 ボタンは非活性化しない。

また、RESOURCE_EXHAUSTED ステータスの非 429 レスポンスに対しても 同様の判定ロジックを適用しています。

5. 教訓

  • 外部 API のエラーレスポンスは同一ステータスコードでも複数の意味を持つ: HTTP 429 は「レート制限」を意味しますが、一時的な分制限と永続的な日次上限では ユーザーへの影響が大きく異なります。エラーメッセージの内容を精査して区別することが重要です。
  • 「あるパターンに当てはまらない」ことで別パターンを確定する:"retry in Xs" が存在すれば必ず分制限という関係を利用し、 先行ガードで排除することでその後の正規表現の誤マッチを防げます。 複数の状態を1つの関数で判定しようとするより、 排他的な判定関数を組み合わせる方が安全です。
  • セッション内の永続的状態変更は慎重に設計する:isRateLimited: true はページリロードまでボタンを非活性化し続けます。 この設計は日次上限のように「翌日まで使えない」場合に適切ですが、 一時的なエラーには過剰です。ユーザーへの影響が大きい状態変更ほど、 トリガー条件の正確な判定が求められます。