LESSON 30分

ストーリー

高橋アーキテクト
あるAPIが突然応答しなくなった。原因は何だと思う?

高橋アーキテクトがモニタリング画面を見せた。リクエスト数が急激に跳ね上がっている。

高橋アーキテクト
1つのクライアントが1秒間に10,000回のリクエストを送っていた。バグなのか悪意なのかは分からないが、他のユーザーに影響が出た
あなた
対策はあるんですか?
高橋アーキテクト
ある。レート制限だ。APIを安定的に運用するための防御機構だ。良いAPIは、この仕組みをクライアントに明確に伝える

レート制限とは

基本概念

// レート制限: 一定時間内のリクエスト数に上限を設ける
// スロットリング: 制限を超えたリクエストを遅延させる or 拒否する

// 例: 1時間あたり1000リクエストまで
// 1001回目のリクエスト → 429 Too Many Requests

なぜ必要か

目的説明
サービスの安定性一部のクライアントがリソースを独占するのを防ぐ
コスト管理インフラコストの爆発を防ぐ
公平性すべてのクライアントに平等なアクセスを保証
セキュリティDDoS攻撃やブルートフォース攻撃を軽減

レート制限のアルゴリズム

1. 固定ウィンドウ(Fixed Window)

// 固定の時間枠でカウントをリセット
// 例: 毎時0分にカウンターをリセット

// 10:00 - 10:59 → 上限1000リクエスト
// 11:00 - 11:59 → カウンターリセット、再び1000リクエスト

// 問題点: ウィンドウの境界で2倍のリクエストが通る可能性
// 10:59に1000リクエスト + 11:00に1000リクエスト = 2分間で2000リクエスト

interface FixedWindowCounter {
  count: number;
  windowStart: number;
}

function isAllowed(counter: FixedWindowCounter, limit: number, windowMs: number): boolean {
  const now = Date.now();
  if (now - counter.windowStart >= windowMs) {
    counter.count = 0;
    counter.windowStart = now;
  }
  counter.count++;
  return counter.count <= limit;
}

2. スライディングウィンドウ(Sliding Window)

// 現在時刻から遡って一定期間のリクエスト数をカウント
// 固定ウィンドウの境界問題を解決

// 現在10:30の場合、9:30 - 10:30 のリクエスト数をチェック
// より正確だが、実装が複雑

interface SlidingWindowLog {
  timestamps: number[];
}

function isAllowed(log: SlidingWindowLog, limit: number, windowMs: number): boolean {
  const now = Date.now();
  // ウィンドウ外の古いタイムスタンプを除去
  log.timestamps = log.timestamps.filter(t => now - t < windowMs);
  if (log.timestamps.length >= limit) {
    return false;
  }
  log.timestamps.push(now);
  return true;
}

3. トークンバケット(Token Bucket)

// バケットにトークンが一定速度で補充される
// リクエストごとにトークンを1つ消費
// トークンがなくなったら拒否

// バースト対応: バケットに溜まったトークン分だけ一時的に高速処理可能

interface TokenBucket {
  tokens: number;
  maxTokens: number;
  refillRate: number;  // トークン/秒
  lastRefill: number;
}

function isAllowed(bucket: TokenBucket): boolean {
  const now = Date.now();
  const elapsed = (now - bucket.lastRefill) / 1000;

  // トークンを補充
  bucket.tokens = Math.min(
    bucket.maxTokens,
    bucket.tokens + elapsed * bucket.refillRate
  );
  bucket.lastRefill = now;

  // トークンを消費
  if (bucket.tokens >= 1) {
    bucket.tokens -= 1;
    return true;
  }
  return false;
}

アルゴリズムの比較

アルゴリズムメモリ使用量精度バースト対応実装の複雑さ
固定ウィンドウなし
スライディングウィンドウなし
トークンバケットあり

レスポンスヘッダーの設計

標準的なレート制限ヘッダー

// レスポンスヘッダーでクライアントに情報を伝える
// RateLimit-Limit: 制限値
// RateLimit-Remaining: 残りリクエスト数
// RateLimit-Reset: リセットまでの秒数

// 正常なレスポンス(200 OK)
HTTP/1.1 200 OK
RateLimit-Limit: 1000
RateLimit-Remaining: 742
RateLimit-Reset: 1854

// 制限超過時のレスポンス(429 Too Many Requests)
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 1854
Retry-After: 1854
Content-Type: application/json

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "リクエスト数が制限を超えました。1854秒後に再試行してください。",
    "retryAfter": 1854
  }
}

TypeScript でのミドルウェア実装

interface RateLimitConfig {
  windowMs: number;     // ウィンドウサイズ(ミリ秒)
  maxRequests: number;  // ウィンドウ内の最大リクエスト数
}

function rateLimitMiddleware(config: RateLimitConfig) {
  const counters = new Map<string, { count: number; resetAt: number }>();

  return (req: Request, res: Response, next: NextFunction) => {
    const clientId = req.ip || req.headers['x-api-key'] as string;
    const now = Date.now();

    let counter = counters.get(clientId);
    if (!counter || now >= counter.resetAt) {
      counter = { count: 0, resetAt: now + config.windowMs };
      counters.set(clientId, counter);
    }

    counter.count++;
    const remaining = Math.max(0, config.maxRequests - counter.count);
    const resetSeconds = Math.ceil((counter.resetAt - now) / 1000);

    // ヘッダーを設定
    res.set('RateLimit-Limit', String(config.maxRequests));
    res.set('RateLimit-Remaining', String(remaining));
    res.set('RateLimit-Reset', String(resetSeconds));

    if (counter.count > config.maxRequests) {
      res.set('Retry-After', String(resetSeconds));
      return res.status(429).json({
        error: {
          code: 'RATE_LIMITED',
          message: `リクエスト数が制限を超えました。${resetSeconds}秒後に再試行してください。`,
          retryAfter: resetSeconds,
        }
      });
    }

    next();
  };
}

プラン別のレート制限

// APIの利用プランに応じて制限を変える
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  free: { windowMs: 3600_000, maxRequests: 100 },      // 100 req/hour
  basic: { windowMs: 3600_000, maxRequests: 1000 },     // 1,000 req/hour
  pro: { windowMs: 3600_000, maxRequests: 10000 },      // 10,000 req/hour
  enterprise: { windowMs: 3600_000, maxRequests: 100000 }, // 100,000 req/hour
};

まとめ

ポイント内容
レート制限の目的安定性、コスト管理、公平性、セキュリティ
アルゴリズム固定ウィンドウ、スライディングウィンドウ、トークンバケット
レスポンスヘッダーRateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
429 Too Many RequestsRetry-After ヘッダーで再試行タイミングを伝える
プラン別制限利用プランに応じて制限値を変える

チェックリスト

  • レート制限が必要な理由を説明できる
  • 3つのアルゴリズムの違いを理解した
  • レート制限のレスポンスヘッダー設計を把握した
  • 429ステータスコードとRetry-Afterヘッダーの使い方を理解した

次のステップへ

レート制限とスロットリングを学びました。

次は演習です。ここまで学んだRESTful API設計の知識を総動員して、 実際にAPIを設計してみましょう。


推定読了時間: 30分