レート制限が永遠に回復しなくなった話 — 自動再接続とカウンタの共鳴

音声通話アプリの常設デモをサーバレスで月0円化した続きである。誰でも登録なしで触れる代わりに、OpenAI課金の暴発を防ぐレート制限を入れた。IPごとに一時キー発行を毎時10回まで、超えたら429を返す——ありふれた設計だ。それが、一度上限に当たると二度と回復しない罠になっていた。

症状

デモの画面に「切断されました。3秒後に再接続します…」が出っぱなしになった。最初はSSM(APIキーの取得元)を疑ったが、サーバーのログを見ると一時キー発行は正常に動いている。詰まっているのはレート制限だった。DynamoDBのカウンタを覗くと、毎時10回のはずが 197 まで伸びていた。

なぜカウンタが197まで伸びたか

原因は2つのコードの、それぞれ単体では正しい挙動の共鳴だった。

サーバー側: レート制限は「カウンタを+1してから、上限を超えていたら429」という素直な実装だった。

# 加算してから判定(これが罠)
resp = ddb.update_item(..., UpdateExpression="ADD cnt :one", ...)
return int(resp["Attributes"]["cnt"]) > limit

クライアント側: このアプリは切断に強くできている。OpenAIがアイドルセッションを勝手に閉じるので、切れたら3秒後に自動再接続する設計だ。これは通話の信頼性のために正しい。

ここで悲劇が起きる。ユーザーが上限に達して429が返る。クライアントは429を「切断」と解釈し、3秒後に再接続を試みる。その再接続がまた一時キーを要求し、サーバーはまた+1する。429が返る。また3秒後に…

失敗するリクエストが、失敗するたびにカウンタを押し上げる。毎時ウィンドウは60分経てば0に戻るはずが、3秒ごとの自動リトライがそれより速くカウンタを積み増すので、窓は永遠に回復しない。カウンタ197は、この無限ループが積み上げた数字だった。

レート制限そのものは仕事をしていた。むしろ仕事をしすぎて、自分の回復を妨げていた。

両側から直す

サーバー側: 「加算してから判定」を「上限未満のときだけ加算」に変えた。DynamoDBの条件付き更新を使う。

ddb.update_item(
    ...,
    UpdateExpression="ADD cnt :one SET ...",
    ConditionExpression="attribute_not_exists(cnt) OR cnt < :limit",
    ...
)
# 条件を満たさなければ ConditionalCheckFailedException → 429(カウンタは触らない)

こうすると、上限に達した後の呼び出しはカウンタに一切触れない。何回リトライされてもカウンタは10で止まる。60分後には確実に窓が開く。偽のIPで13連打してみると 200×10 → 429×3、カウンタは10でピタリと止まった。

クライアント側: そもそも429をリトライするのが間違いだった。ネットワーク断や5xxはリトライで回復し得るが、レート制限(429)とキルスイッチ(403)は、待つかリロードするしか回復しない。リトライは無駄なだけでなく、上のように事態を悪化させる。

if (res.status === 429 || res.status === 403) {
  // リトライしても回復しない。自動再接続で連打するとカウンタを押し上げて悪化する
  setStatus(detail || '利用が集中しています。しばらくしてページを再読み込みしてください');
  return; // 静かに止まる
}

サーバーが返すメッセージをそのまま画面に出して、静かに止まる。「切断→再接続」の無限ループは消えた。

教訓: リトライしてよいエラーと、してはいけないエラー

HTTPのエラーは、リトライの観点で2種類に分かれる。

  • 一時的な失敗(ネットワーク断、5xx、タイムアウト): リトライで回復し得る。自動再接続は正しい
  • 状態による拒否(429 レート制限、403 認可): リトライしても状態は変わらない。むしろ、レート制限のように「リクエスト数」で状態が決まるものは、リトライが状態を悪化させる

「切られても平気なように自動再接続する」は良い設計だが、何でもかんでも再接続してよいわけではない。再接続すべきは「切れた」ときであって、「断られた」ときではない。この2つを一緒くたにしていたのが今回のバグだった。

そしてレート制限を実装するときは、カウンタを増やす条件そのものに上限を織り込む(超過後は増やさない)。「増やしてから判定」は、リトライするクライアントと組み合わさると自家中毒を起こす。

おまけ: このバグの検証で気づいたこと

修正の検証で、偽の X-Forwarded-For ヘッダを送って別IPを名乗りレート制限を試した。これは裏を返せば、ヘッダ偽装でIP制限を回避できるということだ。CloudFront経由では実クライアントIPが X-Forwarded-For の先頭に入るが、クライアントが最初から偽の値を送ると先頭を汚染できる(CloudFrontは実IPを末尾に追記するだけ)。厳密にやるなら CloudFront-Viewer-Address という改竄できないヘッダを使う。今回のデモは後段に同時実行数の上限とキルスイッチがあるので許容範囲とし、既知の限界として記録した。守りは一層では作らない。


デモは https://demo.pocraft.net で動いている。レート制限に当たっても、もう永遠には詰まらない。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール