レート制限

API やサービスへのリクエスト数に上限を設け、過負荷を防ぐ仕組み

APIセキュリティ

レート制限とは

レート制限 (Rate Limiting) は、API やサービスへのリクエスト数に上限を設け、過負荷、DDoS 攻撃、リソースの不公平な消費を防ぐ仕組みである。トークンバケットやスライディングウィンドウといったアルゴリズムの選び分け、サーバー側でカウンターを共有する実装は「レート制限パターン」で扱う。本項は上限に当たったクライアントとサーバーの間で何が起きるか、上限をどの層に置くかを扱う。

なぜ必要か

レート制限は、過負荷によるサービスダウンの防止、1 ユーザーによるリソース独占の防止 (公平性)、下流サービス (Bedrock 等) の呼び出しコスト制御、DDoS 攻撃の緩和を目的とする。

上限を置く層で守れるものが変わる

上限を 1 か所に置けば済むわけではない。層ごとに、判断に使える情報とリクエストを捨てるコストが違う。

エッジで IP 単位に落とす層は、リクエストが自分のアプリケーションに到達する前に切れるため 1 件あたりのコストが最も安いが、誰の呼び出しかを知らない。共有回線や大規模 NAT の背後では多数の利用者が 1 つの IP に見えるので、IP 単位の上限は無関係な利用者を巻き込む。逆にアプリケーション層は認証済みの利用者やテナントを識別できるが、そこまで通した時点で認証と参照のコストは支払い済みである。

つまり層は代替ではなく分担であり、粗い層で総量を削り、細かい層で公平性を担保する。

CloudFront (WAF) → API Gateway → Lambda → DynamoDB
  ↑ IP ベース       ↑ API キーベース    ↑ ユーザーベース
  2000 req/5min     100 req/sec        10 req/min
レイヤーサービス粒度
エッジWAFIP アドレス (集計単位は変更可)
APIAPI Gateway スロットリングAPI キー、ステージ
アプリケーションLambda + DynamoDBユーザー、テナント

上の数値は設定例である。どの層も厳密な天井としては期待しない方がよい。API Gateway の開発者ガイドはスロットリングを保証された上限ではなく目標値と説明しており、WAF のレートベースルールも設定値の近くで効く仕組みで、検知までに時間差がある (2026 年 8 月時点)。詳細は「API スロットリング」を参照。

429 と Retry-After

429 Too Many Requests は RFC 6585 の第 4 節で定義された、一定時間内のリクエストが多すぎることを示すステータスコードである。待つべき時間の伝達には Retry-After を使う。

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711929600

クライアントは Retry-After に従って待機し、リトライする。ただし、この値は秒数だけとは限らない。RFC 9110 の第 10.2.3 節は Retry-After の値を HTTP 日付または遅延秒数のいずれかと定めており、Retry-After: Fri, 31 Dec 1999 23:59:59 GMT のような形も規格上正しい。

X-RateLimit- で始まるヘッダーは規格ではなく実装ごとの慣行なので、名前が付いていることを前提にした処理を書くと、別のサービスに向けた途端に効かなくなる。そもそも 429 に Retry-After が付かない実装もある。

クライアント側の対応

素朴なリトライ実装が壊れるのは、Retry-After を秒数だと決めつけたときである。parseInt に HTTP 日付を渡すと NaN になり、setTimeout(NaN) は待たずに即実行されるため、待つはずのコードが上限に当たった相手を全速で叩き続ける形に化ける。日付形式の場合、値が無い場合、値が過去の日付の場合、極端に長い秒数が返る場合の 4 通りを、待ち時間の算出側で吸収しておく。

async function fetchWithRetry(url: string, retries = 3): Promise<Response> {
  const res = await fetch(url);
  if (res.status !== 429 || retries === 0) return res;

  // Retry-After が無い / 解釈できない場合は指数バックオフへ退避する
  const advised = parseRetryAfter(res.headers.get('Retry-After'));
  const backoff = advised ?? 2 ** (3 - retries) * 1000;
  const jitter = Math.random() * 1000;
  await new Promise((r) => setTimeout(r, Math.min(backoff + jitter, 60_000)));
  return fetchWithRetry(url, retries - 1);
}

function parseRetryAfter(value: string | null): number | null {
  if (!value) return null;
  if (/^\d+$/.test(value.trim())) return Number(value) * 1000;
  const at = Date.parse(value); // HTTP 日付形式
  return Number.isNaN(at) ? null : Math.max(at - Date.now(), 0);
}

jitter を足しているのは、同時に上限に当たった呼び出し元が全員同じ秒数だけ待つと、待ち終わった瞬間に同じ山を作り直すためである。上限の秒数を Math.min で抑えているのは、サーバーが 1 時間後の日付を返してきたときに、呼び出し側のリクエストがその間ずっと保留されるのを避けるためである。待つより諦めて呼び出し元にエラーを返した方がよい場面は多い。

落とし穴

自分がレート制限をかける側になるときは、まず上限を数える鍵を決める。IP・API キー・利用者 ID のどれで数えるかで守れるものが変わり、認証前に効かせたいなら利用者 ID は使えない。

次に、拒否したリクエストのコストを見積もる。カウントの読み書きも記録も、拒否した分だけ発生する。上限に達したクライアントが同じ速度で叩き続ける状況が想定できるなら、拒否そのものが安く済む層 (エッジ) に上限を置く判断が要る。

最後に、上限を超えたときに 429 ではなく 503 を返す実装が混ざっていないか確認する。呼び出し側から見ると、429 は「自分が出しすぎた」、503 は「相手が落ちている」であり、リトライの判断が変わる。

この記事は役に立ちましたか?

関連用語

関連する記事