API Rate Limiting: Patterns and Best Practices

Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.5k 語数|

API レート制限: パターンとベスト プラクティス

すべてのパブリック API エンドポイントがターゲットになります。ボット、スクレイパー、悪意のある攻撃者は、サーバーが稼働した瞬間に攻撃を加えます。レート制限を行わないと、単一の不正なクライアントによってデータベース接続が枯渇し、クラウド料金が高騰し、すべての正規ユーザーのサービスがダウンする可能性があります。レート制限はオプションではありません。これは、実稼働 API にとって防御の最前線です。

このガイドでは、4 つの主要なレート制限アルゴリズム、そのトレードオフ、および Redis を使用して NestJS にそれらを正しく実装する方法について説明します。一般的なシナリオの設定をコピー&ペーストし、エンドポイントごとに適切な戦略を選択するためのメンタル モデルを残します。

重要なポイント

  • トークン バケットにより制御されたバーストが可能になる一方、固定ウィンドウ カウンターは実装が最も安価です
  • スライディング ウィンドウ ログは最も正確ですが、メモリを大量に消費します。引き違い窓のカウンターがベストバランス
  • 複数の API レプリカを実行する場合、Redis が唯一の正しいバッキング ストアです
  • NestJS @nestjs/throttler はカスタム ストレージ アダプターをサポートします - 1 つの構成変更で Redis に交換します
  • 常に Retry-AfterX-RateLimit-LimitX-RateLimit-Remaining、および X-RateLimit-Reset ヘッダーを返します
  • エンドポイントの機密性による制限の区別: 認証 (5/分) と読み取り API (1000/分)
  • 匿名トラフィックには IP ベースの制限を使用し、認証されたリクエストにはユーザーベースの制限を使用します
  • リクエストを黙ってドロップしないでください。常に役立つメッセージとともに 429 Too Many Requests を返します。

4 つのコア アルゴリズム

固定ウィンドウカウンター

最も単純なアプローチ: 固定時間枠内でリクエストをカウントし、境界でリセットします。

// Fixed window: 100 requests per minute
// Window resets at :00, :01, :02 ...
const windowKey = `ratelimit:${userId}:${Math.floor(Date.now() / 60000)}`;
const count = await redis.incr(windowKey);
await redis.expire(windowKey, 60);

if (count > 100) {
  throw new TooManyRequestsException();
}

弱点: 境界の悪用。クライアントは 12:00:59 に 100 件のリクエストを送信し、12:01:00 にさらに 100 件のリクエストを送信できます。つまり、2 秒間に 200 件のリクエストを送信できることになります。ほとんどの API では、これは許容されます。認証エンドポイントの場合はそうではありません。

スライディング ウィンドウ ログ

すべてのリクエストのタイムスタンプを保存します。リクエストごとに、最後のウィンドウのタイムスタンプをカウントします。

const now = Date.now();
const windowStart = now - 60_000; // 60 seconds ago

// Remove old entries, add current
await redis.zremrangebyscore(key, 0, windowStart);
await redis.zadd(key, now, now.toString());
const count = await redis.zcard(key);
await redis.expire(key, 60);

if (count > 100) {
  throw new TooManyRequestsException();
}

トレードオフ: 完全に正確ですが、ユーザーごとに O(n) エントリを保存します (n はリクエスト数)。 10,000 人のユーザーで 1,000 RPS になると、Redis メモリは急速に増加します。パスワードのリセットなど、少量で高セキュリティのエンドポイントに使用します。

引き違い窓カウンター

2 つの固定ウィンドウを使用してスライディング ウィンドウを概算します。メモリの爆発はありません。

const now = Date.now();
const currentWindow = Math.floor(now / 60000);
const previousWindow = currentWindow - 1;
const windowProgress = (now % 60000) / 60000; // 0.0 to 1.0

const [current, previous] = await redis.mget(
  `rl:${userId}:${currentWindow}`,
  `rl:${userId}:${previousWindow}`
);

const estimated =
  (parseInt(previous ?? '0') * (1 - windowProgress)) +
  parseInt(current ?? '0');

if (estimated >= 100) {
  throw new TooManyRequestsException();
}

await redis.incr(`rl:${userId}:${currentWindow}`);
await redis.expire(`rl:${userId}:${currentWindow}`, 120);

これはCloudflareが使用するアルゴリズムです。最小限のオーバーヘッドで境界スパイクを平滑化します (ウィンドウごとにユーザーごとに 2 つの Redis キー)。

トークンバケット

長期金利を維持しながらバーストを許可するためのゴールドスタンダード。各ユーザーは一定の割合で満たされるバケットを持っています。リクエストはトークンを消費します。

async function consumeToken(
  redis: Redis,
  userId: string,
  ratePerSec: number,
  capacity: number
): Promise<boolean> {
  const now = Date.now() / 1000;
  const key = `bucket:${userId}`;

  const values = await redis.hmget(key, 'tokens', 'lastRefill');
  const currentTokens = parseFloat(values[0] ?? String(capacity));
  const lastRefillTime = parseFloat(values[1] ?? String(now));

  // Refill tokens based on elapsed time
  const elapsed = now - lastRefillTime;
  const refilled = Math.min(capacity, currentTokens + elapsed * ratePerSec);

  if (refilled < 1) {
    return false; // No tokens available
  }

  await redis.hset(key, 'tokens', String(refilled - 1), 'lastRefill', String(now));
  await redis.expire(key, Math.ceil(capacity / ratePerSec) + 60);

  return true;
}

トークン バケットは、持続的な悪用を防止しながら、短期間のバースト (一度に 10 個のファイルのアップロード) を許可する必要がある API に最適です。


NestJS スロットラーの構成

@nestjs/throttler v5 には Redis ストレージ アダプターが付属しています。以下は本番環境に対応したセットアップです。

pnpm add @nestjs/throttler @nestjs/throttler-storage-redis ioredis
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nestjs/throttler-storage-redis';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    ThrottlerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        throttlers: [
          { name: 'short',  ttl: 1000,    limit: 5    }, // 5 req/sec burst
          { name: 'medium', ttl: 60000,   limit: 300  }, // 300 req/min
          { name: 'long',   ttl: 3600000, limit: 5000 }, // 5000 req/hr
        ],
        storage: new ThrottlerStorageRedisService({
          host: config.get('REDIS_HOST'),
          port: config.get('REDIS_PORT'),
        }),
      }),
    }),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

コントローラーまたはルートごとの制限をオーバーライドします。

@Controller('auth')
export class AuthController {
  // Authentication: very strict — 5 attempts per minute
  @Post('login')
  @Throttle({ medium: { ttl: 60000, limit: 5 } })
  async login(@Body() dto: LoginDto) { /* ... */ }

  // Refresh: moderate — 30 per minute
  @Post('refresh')
  @Throttle({ medium: { ttl: 60000, limit: 30 } })
  async refresh(@Body() dto: RefreshDto) { /* ... */ }

  // Skip throttling on the exchange endpoint (protected by one-time code TTL)
  @Post('exchange')
  @SkipThrottle()
  async exchange(@Body() dto: ExchangeDto) { /* ... */ }
}

カスタムキージェネレーター

デフォルトでは、NestJS スロットラーはクライアント IP を使用します。 Nginx/Cloudflare の運用環境では、X-Real-IP または CF-Connecting-IP が必要です。

// throttler-behind-proxy.guard.ts
import { ThrottlerGuard } from '@nestjs/throttler';
import { Injectable, ExecutionContext } from '@nestjs/common';

@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
  protected async getTracker(req: Record<string, any>): Promise<string> {
    // Authenticated user — use userId for accurate per-user limits
    if (req.user?.sub) {
      return `user:${req.user.sub}`;
    }
    // Anonymous — use real IP from Cloudflare header
    return (
      req.headers['cf-connecting-ip'] ||
      req.headers['x-real-ip'] ||
      (req.headers['x-forwarded-for'] as string)?.split(',')[0] ||
      req.ip
    );
  }

  protected async throwThrottlingException(
    context: ExecutionContext,
    throttlerLimitDetail: ThrottlerLimitDetail
  ): Promise<void> {
    const response = context.switchToHttp().getResponse();
    response.header(
      'Retry-After',
      Math.ceil(throttlerLimitDetail.ttl / 1000)
    );
    await super.throwThrottlingException(context, throttlerLimitDetail);
  }
}

応答ヘッダー

RFC 6585 およびドラフト RateLimit ヘッダーは、クライアントにいつ再試行するかを正確に指示します。

// rate-limit.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class RateLimitHeadersInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      tap(() => {
        const res = context.switchToHttp().getResponse();
        const req = context.switchToHttp().getRequest();

        // Values injected by ThrottlerGuard after evaluation
        if (req.rateLimit) {
          res.set({
            'X-RateLimit-Limit': req.rateLimit.limit,
            'X-RateLimit-Remaining': Math.max(
              0,
              req.rateLimit.limit - req.rateLimit.current
            ),
            'X-RateLimit-Reset': new Date(
              Date.now() + req.rateLimit.ttl
            ).toISOString(),
            'RateLimit-Policy': `${req.rateLimit.limit};w=${Math.ceil(
              req.rateLimit.ttl / 1000
            )}`,
          });
        }
      })
    );
  }
}

エンドポイント固有の戦略

エンドポイントが異なれば、異なる制限も保証されます。一般的なパターンの参照表は次のとおりです。

エンドポイントの種類アルゴリズム制限ウィンドウ
ログイン/パスワードリセットスライディングウィンドウログ515分
OTP / 2FA 検証固定ウィンドウ310分
パブリック読み取り APIトークンバケット1000 バースト、100/秒フィル
Mutation API (認証済み)引き違い窓カウンター3001分
Webhook の取り込み固定ウィンドウ10,0001分
ファイルのアップロードトークンバケット10 バースト、1/s フィル
AI / LLM エンドポイント固定ウィンドウ201分
検索(匿名)固定ウィンドウ301分

分散型安全性のための Atomic Lua スクリプト

複数の API レプリカがある場合、増分チェック シーケンスの競合状態により、制限を超えるバーストが発生する可能性があります。 redis.defineCommand 経由でロードされた Lua スクリプトを使用して、チェックアンドインクリメントをアトミックにします。

// rate-limit.service.ts
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class RateLimitService {
  constructor(private readonly redis: Redis) {
    // Define atomic increment+check as a custom Redis command
    this.redis.defineCommand('rateLimitCheck', {
      numberOfKeys: 1,
      lua: `
        local key   = KEYS[1]
        local limit = tonumber(ARGV[1])
        local ttlMs = tonumber(ARGV[2])
        local count = redis.call('INCR', key)
        if count == 1 then
          redis.call('PEXPIRE', key, ttlMs)
        end
        if count > limit then
          return {0, redis.call('PTTL', key)}
        end
        return {1, -1}
      `,
    });
  }

  async isAllowed(
    key: string,
    limit: number,
    windowMs: number
  ): Promise<{ allowed: boolean; retryAfterMs: number }> {
    const result = await (this.redis as any).rateLimitCheck(
      key, limit, windowMs
    ) as [number, number];

    return {
      allowed: result[0] === 1,
      retryAfterMs: result[1] > 0 ? result[1] : 0,
    };
  }
}

グレースフル デグラデーションとバイパス戦略

レート制限により、内部ヘルスチェック、監視エージェント、または信頼できるパートナーがブロックされるべきではありません。

// trusted-bypass.guard.ts
@Injectable()
export class RateLimitWithBypassGuard extends ThrottlerBehindProxyGuard {
  private readonly trustedTokens = new Set([
    process.env.MONITORING_TOKEN,
    process.env.PARTNER_API_TOKEN,
  ].filter(Boolean));

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest();

    // Internal health checks bypass all rate limits
    if (req.path === '/health' || req.path === '/ready') {
      return true;
    }

    // Trusted API tokens bypass
    const token = req.headers['x-bypass-token'];
    if (token && this.trustedTokens.has(token)) {
      return true;
    }

    return super.canActivate(context);
  }
}

プログレッシブ レート制限 (ハード ブロックの前に警告) の場合は、制限の 80% を超えた後にのみ Retry-After ヘッダーを持つ 429 を返します。

// In your custom guard, after counting requests:
if (count > limit * 0.9 && count <= limit) {
  response.set('X-RateLimit-Warning', 'approaching limit');
}
if (count > limit) {
  response.set('Retry-After', retryAfterSeconds.toString());
  throw new HttpException('Rate limit exceeded', 429);
}

レート制限のテスト

// rate-limit.spec.ts
describe('Rate Limiting', () => {
  it('should block after limit exceeded', async () => {
    const app = moduleRef.createNestApplication();
    await app.init();

    // Hit the endpoint 5 times (limit for login)
    for (let i = 0; i < 5; i++) {
      await request(app.getHttpServer())
        .post('/auth/login')
        .send({ email: '[email protected]', password: 'wrongpassword' })
        .expect((res) => expect(res.status).toBeLessThan(429));
    }

    // 6th request should be blocked
    const response = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email: '[email protected]', password: 'wrongpassword' });

    expect(response.status).toBe(429);
    expect(response.headers['retry-after']).toBeDefined();
    expect(response.body.message).toContain('rate limit');
  });
});

監視とアラート

レート制限イベントは貴重なシグナルです。それらを可観測性プラットフォームに記録します。

@Injectable()
export class RateLimitMetricsService {
  async recordRateLimitHit(userId: string, endpoint: string, ip: string) {
    await this.metricsService.increment('rate_limit.hits', {
      endpoint,
      user_type: userId ? 'authenticated' : 'anonymous',
    });

    // Alert on sustained attacks (>100 hits in 1 min from same IP)
    const alertKey = `rl_alert:${ip}`;
    const recentHits = await this.redis.incr(alertKey);
    if (recentHits === 1) {
      await this.redis.expire(alertKey, 60);
    }

    if (recentHits === 100) {
      await this.alertService.send({
        severity: 'high',
        message: `Rate limit attack detected from IP ${ip}`,
        endpoint,
      });
    }
  }
}

ダッシュボード追跡を作成します。

  • エンドポイントごとのレート制限ヒット (p95、p99)
  • 上位 IP/ユーザーが制限に達している
  • ブロックされたリクエストと処理されたリクエストの割合
  • 設定ミスのあるクライアントを検出するための再試行期間

よくある質問

レート制限は IP またはユーザー ID で行うべきですか?

両方を使用してください。認証されていないエンドポイントの場合、使用できる識別子は IP のみです。認証されたエンドポイントの場合は、常にユーザー ID を優先します。これはより正確であり、1 つの共有 IP (企業 NAT など) がすべての従業員をブロックすることを防ぎます。 Nginx レベルでの IP 制限とアプリケーション レベルでのユーザー ID 制限の 2 層チェックを実装します。

レート制限の正しい HTTP ステータス コードは何ですか?

RFC 6585 に従って常に 429 Too Many Requests を使用します。503 Service Unavailable (インフラストラクチャの障害を意味する) または 403 Forbidden (認可の失敗を意味する) は決して使用しないでください。 Retry-After をヘッダーとして秒単位で含めると、クライアントがいつ再試行するかを知ることができます。

Cloudflare またはロードバランサーの背後でレート制限を処理するにはどうすればよいですか?

X-Real-IP または CF-Connecting-IP を設定し、プロキシの IP 範囲のみを信頼するようにプロキシを構成します。 Nginx の場合: set_real_ip_from 103.21.244.0/22; real_ip_header CF-Connecting-IP;。 NestJS で app.set('trust proxy', 1) を設定し、NestJS が信頼できるプロキシ ヘッダーから解決する req.ip を読み取ります。

レート制限に最適な Redis データ構造はどれですか?

固定/スライディング ウィンドウ カウンターの場合は、文字列キーで INCR + EXPIRE を使用します (リクエストごとに O(1))。スライディング ウィンドウ ログの場合は、ソート セット (ZADDZREMRANGEBYSCOREZCARD) — O(log n) を使用します。トークン バケットの場合は、ハッシュ (HSETHGET) — O(1) を使用します。 Lua スクリプトは、3 つのパターンすべてにわたってすべての操作をアトミックにします。

信頼できるプロバイダーからの Webhook のレート制限はどのように処理すればよいですか?

Stripe、GitHub、および同様のプロバイダーは、既知の IP 範囲から Webhook を送信します。 CIDR 範囲のホワイトリストを維持し、Webhook 取り込みエンドポイント上のこれらの IP のレート制限をバイパスします。最初に Webhook 署名を検証します。署名検証はレート制限ではなく、実際のセキュリティ層です。

代わりに Nginx レベルでレート制限を実装できますか?

はい、基本的な DDoS 保護のためにはそうする必要があります。大まかな IP ベースの制限 (1000 要求/分) には、Nginx で limit_req_zone を使用します。アプリケーションレベルのレート制限を最上位に置き、ユーザーごと、エンドポイントごとのきめ細かい制御を実現します。 2 つのレイヤーは相互に補完します。Nginx はアプリケーションに影響を与えることなく大量攻撃を安価に処理し、NestJS は微妙なビジネス ロジック制限を処理します。


次のステップ

堅牢なレート制限なしで運用 API を構築することは、玄関ドアの鍵を開けたままにするようなものです。このガイドのパターン (バースト トラフィック用のトークン バケット、スムーズな適用のためのスライディング ウィンドウ カウンター、Redis を利用した分散ストレージ、適切な 429 応答) は、安全でスケーラブルな API のバックボーンを形成します。

ECOSIRE は、レート制限、Redis キャッシュ、完全な可観測性が初日から組み込まれたエンタープライズ グレードの NestJS バックエンドを構築します。新しい API を立ち上げる場合、または既存の API を強化する場合は、バックエンド エンジニアリング サービスを探索 して、提供を加速する方法を確認してください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット