はじめに
API を公開すると、意図的な攻撃(DDoS)だけでなく、クライアントのバグによる無限ループや、想定以上のトラフィックによってサーバーが過負荷になるリスクがあります。
Rate Limiting(レート制限) は、一定期間内のリクエスト数を制限することで、サーバーを過負荷から守る仕組みです。
Rate Limiting が必要な理由
| 脅威 | Rate Limiting がない場合 |
|---|---|
| DDoS 攻撃 | サーバーが応答不能になる |
| クライアントのバグ | 無限ループで大量リクエストが飛ぶ |
| スクレイピング | コンテンツが大量に抜き取られる |
| API の不公平な使用 | 1ユーザーがリソースを独占する |
| コスト超過 | 従量課金の外部 API 呼び出しが爆発する |
HTTP レスポンスの仕組み
Rate Limiting に関連する HTTP ステータスコードとヘッダーを理解しておきましょう。
ステータスコード 429
HTTP/1.1 429 Too Many Requests
Retry-After: 30
クライアントがレート制限を超えた場合、サーバーは 429 Too Many Requests を返します。
レート制限関連ヘッダー
X-RateLimit-Limit: 100 # 期間内の最大リクエスト数
X-RateLimit-Remaining: 23 # 残りのリクエスト数
X-RateLimit-Reset: 1713400000 # 制限がリセットされるUNIXタイムスタンプ
Retry-After: 30 # 次にリトライ可能になるまでの秒数
クライアント側はこれらのヘッダーを確認し、制限を超えないようにリクエストを調整します。制限を超えてしまった場合は Exponential Backoff でリトライするのが推奨されます。
4つの主要アルゴリズム
1. Fixed Window Counter(固定ウィンドウ)
最もシンプルなアルゴリズムです。時間を固定長のウィンドウに区切り、各ウィンドウ内のリクエスト数をカウントします。
制限: 100リクエスト / 分
|--- 12:00〜12:01 ---|--- 12:01〜12:02 ---|
| カウント: 78/100 | カウント: 0/100 |
| → OK | → リセットされる |
class FixedWindowLimiter {
private count = 0;
private windowStart = Date.now();
constructor(
private maxRequests: number,
private windowMs: number
) {}
allow(): boolean {
const now = Date.now();
// ウィンドウを超えたらリセット
if (now - this.windowStart >= this.windowMs) {
this.count = 0;
this.windowStart = now;
}
if (this.count < this.maxRequests) {
this.count++;
return true;
}
return false;
}
}
const limiter = new FixedWindowLimiter(100, 60_000); // 100req/min
問題点: 境界問題(Boundary Problem)
制限: 100リクエスト / 分
|--- 12:00〜12:01 ---|--- 12:01〜12:02 ---|
↑ここで100件 + ↑ここで100件
12:00:55 12:01:05
→ 10秒間に200件のリクエストが通ってしまう!
ウィンドウの境界付近で集中してリクエストすると、実質的に制限の2倍のリクエストが通ってしまいます。
2. Sliding Window Counter(スライディングウィンドウ)
Fixed Window の境界問題を解決するアルゴリズムです。前のウィンドウのカウントを比率で考慮します。
制限: 100リクエスト / 分
現在時刻: 12:01:15(現在のウィンドウの25%が経過)
前のウィンドウ(12:00〜12:01): 84件
現在のウィンドウ(12:01〜12:02): 36件
推定カウント = 84 × 0.75(残り75%分) + 36 = 99件
→ あと1件まで許可
なぜ比率で計算するのか
「直近1分間に何件リクエストがあったか」を厳密に知るには、全リクエストのタイムスタンプを保持する必要があります(後述の Sliding Window Log がこれにあたります)。リクエストが多くなるほどメモリを食うため、API のように高頻度な用途では現実的ではありません。
そこで、「前のウィンドウ内のリクエストは時間軸上に均等に分布していた」と仮定 することで、カウンタ2つだけで直近1分を近似します。
12:00 12:01 12:02
|================|================|
← 前ウィンドウ →← 現ウィンドウ →
84件 36件
現在時刻: 12:01:15(現ウィンドウの25%経過)
「直近1分」= 12:00:15 〜 12:01:15 を見たい
↑ ↑
前ウィンドウの後半45秒 現ウィンドウの15秒すべて
= 前ウィンドウの75%
前ウィンドウの 84件が均等分布だと仮定すれば、後半45秒には 84 × 0.75 = 63件 あったはず。現ウィンドウは丸ごと含まれるので 36件。合計 63 + 36 = 99件 を「直近1分の推定値」として使います。
他のアルゴリズムとの比較
| 手法 | メモリ | 精度 | 境界バースト |
|---|---|---|---|
| Fixed Window | カウンタ1個 | 厳密 | 発生する |
| Sliding Window Log | リクエスト数ぶんのタイムスタンプ | 厳密 | なし |
| Sliding Window Counter | カウンタ2個 | 近似(数%ズレ) | なし |
Log と同じく境界バーストを防ぎつつ、メモリは Fixed Window と同等。「均等分布の仮定」と引き換えに計算コストとメモリを大幅に削減した のがこのアルゴリズムの旨味です。
class SlidingWindowLimiter {
private previousCount = 0;
private currentCount = 0;
private windowStart = Date.now();
constructor(
private maxRequests: number,
private windowMs: number
) {}
allow(): boolean {
const now = Date.now();
const elapsed = now - this.windowStart;
if (elapsed >= this.windowMs) {
this.previousCount = this.currentCount;
this.currentCount = 0;
this.windowStart = now;
}
// 前のウィンドウの残り比率を加味
const ratio = 1 - elapsed / this.windowMs;
const estimatedCount = this.previousCount * ratio + this.currentCount;
if (estimatedCount < this.maxRequests) {
this.currentCount++;
return true;
}
return false;
}
}
多くの API で推奨されるアルゴリズムです。 精度、シンプルさ、メモリ効率のバランスが良く、境界問題も緩和されます。
3. Token Bucket(トークンバケット)
バケツにトークンが一定レートで補充され、リクエストのたびにトークンを1つ消費します。トークンがなくなるとリクエストは拒否されます。
バケット容量: 10(バースト上限)
補充レート: 1トークン / 秒
[●●●●●●●●●●] ← 初期状態(10トークン)
↓ 3リクエスト
[●●●●●●●○○○] ← 7トークン残り
↓ 1秒後に1トークン補充
[●●●●●●●●○○] ← 8トークン
↓ バーストで8リクエスト
[○○○○○○○○○○] ← 0トークン → 次のリクエストは拒否
class TokenBucketLimiter {
private tokens: number;
private lastRefill: number;
constructor(
private capacity: number, // バケット容量(バースト上限)
private refillRate: number, // 補充レート(トークン/秒)
) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
allow(): boolean {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
private refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
const newTokens = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + newTokens);
this.lastRefill = now;
}
}
// 容量10、毎秒2トークン補充
const limiter = new TokenBucketLimiter(10, 2);
特徴: 一時的なバースト(突発的なリクエスト集中)を許容しつつ、平均レートを制御できます。AWS や多くのクラウドサービスで採用されています。
4. Leaky Bucket(リーキーバケット)
リクエストをキューに入れ、一定レートで処理します。バケツの底に穴が開いているイメージです。
処理レート: 1リクエスト / 秒
キュー容量: 5
リクエスト → [5][4][3][2][1] → 処理(1req/s)
キュー(FIFO) ↓
一定レートで出力
キューが満杯の状態で新しいリクエスト → 拒否(溢れる)
class LeakyBucketLimiter {
private queue: Array<() => void> = [];
private processing = false;
constructor(
private capacity: number, // キュー容量
private intervalMs: number // 処理間隔(ミリ秒)
) {}
allow(callback: () => void): boolean {
if (this.queue.length >= this.capacity) {
return false; // キューが満杯
}
this.queue.push(callback);
this.processQueue();
return true;
}
private processQueue() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const task = this.queue.shift()!;
task();
setTimeout(() => {
this.processing = false;
this.processQueue();
}, this.intervalMs);
}
}
特徴: 出力が常に一定レートになるため、バーストが発生しません。ネットワーク機器やトラフィックシェーピングでよく使われます。
アルゴリズムの比較
| アルゴリズム | バースト | 精度 | メモリ | 実装の複雑さ | 推奨場面 |
|---|---|---|---|---|---|
| Fixed Window | 境界で2倍 | 低 | 低 | 簡単 | 内部API、厳密さが不要な場合 |
| Sliding Window | 緩和 | 高 | 低 | 中 | 一般的な API(推奨) |
| Token Bucket | 許容 | 高 | 低 | 中 | バーストを許容したい場合 |
| Leaky Bucket | なし | 高 | 中 | やや複雑 | 一定レートを保証したい場合 |
迷ったら Sliding Window Counter を選ぶのがバランスが良いです。
Rate Limiting の設計ポイント
何を基準に制限するか
| 基準 | 用途 | 例 |
|---|---|---|
| IP アドレス | 未認証のリクエスト制限 | 100req/min per IP |
| API キー | ユーザーごとの制限 | 1000req/hour per API key |
| ユーザー ID | 認証済みユーザーの制限 | 500req/hour per user |
| エンドポイント | 高コストな操作の制限 | POST /orders: 10req/min |
制限値の決め方
- 通常のトラフィックを計測する: 平均的なリクエスト数を把握
- ピーク時の倍率をかける: 通常の2〜5倍程度を上限に
- 段階的に調整する: 最初は緩めに設定し、モニタリングしながら絞る
レスポンスの設計
// Rate Limit 超過時のレスポンス例
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "リクエスト数が上限を超えました。しばらく待ってから再試行してください。",
"retryAfter": 30
}
}
クライアントが適切にリトライできるよう、Retry-After ヘッダーやレスポンスボディで待機時間を伝えます。
実装の選択肢
アプリケーションレベル
// Express.js + express-rate-limit
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 60 * 1000, // 1分
max: 100, // 100リクエスト/分
standardHeaders: true, // RateLimit-* ヘッダーを返す
legacyHeaders: false,
message: { error: "リクエスト数が上限を超えました" },
});
app.use("/api/", limiter);
インフラレベル
| ツール | 方法 |
|---|---|
| Nginx |
limit_req_zone ディレクティブ |
| AWS API Gateway | スロットリング設定 |
| Cloudflare | Rate Limiting ルール |
| Redis | カウンターを Redis で共有(分散環境) |
分散環境での Rate Limiting
複数のサーバーで Rate Limiting を行う場合、カウンターを共有する必要があります。Redis がよく使われます。
// Redis を使った分散 Rate Limiting(概念)
async function checkRateLimit(userId: string): Promise<boolean> {
const key = `rate_limit:${userId}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, 60); // 1分でリセット
}
return current <= 100; // 100req/min
}
Rate Limiting と Exponential Backoff の関係
Rate Limiting はサーバー側の防御、Exponential Backoff はクライアント側の対応です。
クライアント サーバー
│ │
│──── リクエスト ────────────────→ │
│ │ Rate Limit チェック
│←── 429 + Retry-After: 30 ───── │
│ │
│ Exponential Backoff で待機 │
│ (Jitter を加えてリトライ) │
│ │
│──── リトライ ──────────────────→ │
│←── 200 OK ───────────────────── │
両方を適切に実装することで、システム全体の安定性が向上します。
まとめ
| 観点 | ポイント |
|---|---|
| なぜ必要か | サーバーを過負荷・攻撃・不公平な使用から守る |
| アルゴリズム | Sliding Window Counter が一般的に推奨 |
| 制限基準 | IP、API キー、ユーザー ID、エンドポイント |
| レスポンス | 429 + Retry-After ヘッダーで待機時間を伝える |
| 分散環境 | Redis でカウンターを共有 |
| クライアント側 | Exponential Backoff + Jitter でリトライ |
Rate Limiting は API の信頼性とセキュリティの基盤です。アルゴリズムの選択、制限値の設計、クライアントへの適切なフィードバックを組み合わせて、堅牢な API を構築しましょう。