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
|19 mars 202612 min de lecture2.6k Mots|

Limitation du débit des API : modèles et meilleures pratiques

Chaque point de terminaison d'API publique est une cible : les robots, les scrapers et les mauvais acteurs martèleront votre serveur dès sa mise en ligne. Sans limitation de débit, un seul client au comportement inapproprié peut épuiser vos connexions à la base de données, augmenter votre facture cloud et supprimer le service pour chaque utilisateur légitime. La limitation du débit n’est pas facultative ; c'est la première ligne de défense pour toute API de production.

Ce guide présente les quatre principaux algorithmes de limitation de débit, leurs compromis et comment les implémenter correctement dans NestJS avec Redis. Vous repartirez avec des configurations copier-coller pour des scénarios courants et un modèle mental pour choisir la bonne stratégie par point de terminaison.

Points clés à retenir

  • Le compartiment à jetons permet un éclatement contrôlé tandis que les compteurs à fenêtre fixe sont les moins chers à mettre en œuvre
  • Le journal à fenêtre coulissante est le plus précis mais gourmand en mémoire ; le comptoir à fenêtre coulissante est le meilleur équilibre
  • Redis est le seul magasin de sauvegarde correct lorsque vous exécutez plusieurs réplicas d'API
  • NestJS @nestjs/throttler prend en charge les adaptateurs de stockage personnalisés - échangez dans Redis avec un changement de configuration
  • Renvoie toujours les en-têtes Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining et X-RateLimit-Reset
  • Différencier les limites selon la sensibilité du point de terminaison : authentification (5/min) vs API de lecture (1 000/min) - Utilisez des limites basées sur IP pour le trafic anonyme et des limites basées sur l'utilisateur pour les demandes authentifiées.  - N'abandonnez jamais les demandes en silence : renvoyez toujours 429 Too Many Requests avec un message utile

Les quatre algorithmes de base

Compteur de fenêtre fixe

L'approche la plus simple : compter les demandes dans une fenêtre de temps fixe, réinitialisée à la limite.

// 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();
}

Faiblesse : L'exploit des limites. Un client peut envoyer 100 requêtes à 12 h 00 min 59 s et 100 autres à 12 h 01 min 00 s, soit en réalité 200 requêtes en deux secondes. Pour la plupart des API, cela est acceptable. Pour les points de terminaison d’authentification, ce n’est pas le cas.

Journal à fenêtre coulissante

Stockez l’horodatage de chaque demande. A chaque requête, comptez les horodatages dans la dernière fenêtre.

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();
}

Compromis : parfaitement précis mais stocke O(n) entrées par utilisateur, n étant le nombre de demandes. À 1 000 RPS pour 10 000 utilisateurs, votre mémoire Redis grimpe rapidement. À utiliser pour les points de terminaison à faible volume et à haute sécurité, comme la réinitialisation de mot de passe.

Compteur à fenêtre coulissante

Fenêtre coulissante approximative utilisant deux fenêtres fixes — pas d'explosion de mémoire.

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);

C'est l'algorithme utilisé par Cloudflare. Il lisse le pic de limite avec une surcharge minimale : deux clés Redis par utilisateur et par fenêtre.

Seau de jetons

L’étalon-or pour permettre des sursauts tout en maintenant un taux à long terme. Chaque utilisateur dispose d'un seau qui se remplit à un rythme constant. Les requêtes consomment des jetons.

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;
}

Le compartiment de jetons est idéal pour les API qui doivent autoriser de courtes rafales (téléchargement de 10 fichiers à la fois) tout en évitant les abus prolongés.


Configuration du régulateur NestJS

@nestjs/throttler v5 est livré avec un adaptateur de stockage Redis. Voici une configuration prête pour la production :

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 {}

Remplacer les limites par contrôleur ou par itinéraire :

@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) { /* ... */ }
}

Générateurs de clés personnalisés

Par défaut, le régulateur NestJS utilise l'adresse IP du client. En production derrière Nginx/Cloudflare, vous avez besoin de X-Real-IP ou 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);
  }
}

En-têtes de réponse

La RFC 6585 et le projet d’en-têtes RateLimit indiquent aux clients exactement quand réessayer :

// 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
            )}`,
          });
        }
      })
    );
  }
}

## Stratégies spécifiques aux points de terminaison

Différents paramètres justifient des limites différentes. Voici un tableau de référence pour les modèles courants :

Type de point de terminaisonAlgorithmeLimiteFenêtre
Connexion / réinitialisation du mot de passeJournal de fenêtre coulissante515 minutes
Vérification OTP / 2FAFenêtre fixe310 minutes
API de lecture publiqueSeau de jetons1 000 rafales, remplissage 100/s
API de mutation (authentifiée)Comptoir à fenêtre coulissante3001 minute
Ingestion de webhooksFenêtre fixe10 0001 minute
Téléchargement de fichiersSeau de jetons10 rafales, remplissage 1/s
Points de terminaison IA/LLMFenêtre fixe201 minute
Recherche (anonyme)Fenêtre fixe301 minute

Scripts Lua atomiques pour la sécurité distribuée

Lorsque vous disposez de plusieurs réplicas d'API, les conditions de concurrence dans les séquences de vérification d'incrémentation peuvent autoriser des rafales supérieures à la limite. Utilisez un script Lua chargé via redis.defineCommand pour rendre la vérification et l'incrémentation atomique :

// 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,
    };
  }
}

## Dégradation gracieuse et stratégies de contournement

La limitation du débit ne doit pas bloquer les contrôles de santé internes, les agents de surveillance ou les partenaires de confiance.

// 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);
  }
}

Pour une limitation progressive du débit (avertir avant un blocage dur), renvoyez 429 avec un en-tête Retry-After uniquement après avoir franchi 80 % de la limite :

// 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);
}

Limitation du taux de test

// 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');
  });
});

Surveillance et alerte

Les événements de limite de débit sont des signaux précieux. Enregistrez-les sur votre plateforme d'observabilité :

@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,
      });
    }
  }
}

Créer des tableaux de bord de suivi :

  • Limite de taux d'accès par point de terminaison (p95, p99)
  • Les principales IP/utilisateurs atteignant les limites
  • Pourcentage de demandes bloquées par rapport aux demandes servies
  • Durées de réessai après pour détecter les clients mal configurés

Questions fréquemment posées

Dois-je évaluer la limite par adresse IP ou par ID utilisateur ?

Utilisez les deux. Pour les points de terminaison non authentifiés, l’adresse IP est le seul identifiant disponible. Pour les points de terminaison authentifiés, préférez toujours l'ID utilisateur : il est plus précis et empêche une adresse IP partagée (comme un NAT d'entreprise) de bloquer tous les employés. Implémentez une vérification à deux niveaux : limite IP au niveau Nginx et limite d'ID utilisateur au niveau de l'application.

Quel est le code d'état HTTP correct pour la limitation du débit ?

Toujours 429 Too Many Requests selon RFC 6585. N'utilisez jamais 503 Service Unavailable (implique une défaillance de l'infrastructure) ou 403 Forbidden (implique un échec d'autorisation). Incluez Retry-After comme en-tête en quelques secondes afin que les clients sachent quand réessayer.

Comment gérer la limitation de débit derrière Cloudflare ou un équilibreur de charge ?

Configurez votre proxy pour définir X-Real-IP ou CF-Connecting-IP et faites confiance uniquement à la plage IP de votre proxy. Dans Nginx : set_real_ip_from 103.21.244.0/22; real_ip_header CF-Connecting-IP;. Dans NestJS, définissez app.set('trust proxy', 1) et lisez req.ip que NestJS résout à partir de l'en-tête du proxy de confiance.

Quelle structure de données Redis est la meilleure pour limiter le débit ?

Pour les compteurs à fenêtre fixe/coulissante, utilisez INCR + EXPIRE sur une clé de chaîne — O(1) par requête. Pour le journal à fenêtre coulissante, utilisez un ensemble trié (ZADD, ZREMRANGEBYSCORE, ZCARD) — O(log n). Pour le compartiment de jetons, utilisez un hachage (HSET, HGET) — O(1). Les scripts Lua rendent toutes les opérations atomiques dans les trois modèles.

Comment dois-je gérer les limites de débit pour les webhooks provenant de fournisseurs de confiance ?

Stripe, GitHub et des fournisseurs similaires envoient des webhooks à partir de plages IP connues. Conservez une liste verte de leurs plages CIDR et contournez la limitation du taux pour ces adresses IP sur votre point de terminaison d’ingestion de webhook. Vérifiez d'abord la signature du webhook : la vérification de la signature constitue votre véritable couche de sécurité, et non une limitation de débit.

Puis-je plutôt implémenter une limitation de débit au niveau Nginx ?

Oui, et vous devriez le faire pour une protection DDoS de base. Utilisez limit_req_zone dans Nginx pour les limites grossières basées sur IP (1 000 req/min). Superposez la limitation du débit au niveau de l'application pour un contrôle granulaire par utilisateur et par point de terminaison. Les deux couches se complètent : Nginx gère les attaques en volume à moindre coût sans toucher votre application, et NestJS gère les limites nuancées de la logique métier.


Prochaines étapes

Construire une API de production sans limitation de débit robuste, c'est comme laisser votre porte d'entrée ouverte. Les modèles présentés dans ce guide (un compartiment de jetons pour un trafic intense, un compteur à fenêtre coulissante pour une application fluide, un stockage distribué soutenu par Redis et des réponses 429 appropriées) constituent l'épine dorsale d'une API sécurisée et évolutive.

ECOSIRE construit des backends NestJS de niveau entreprise avec une limitation de débit, une mise en cache Redis et une observabilité complète intégrée dès le premier jour. Si vous lancez une nouvelle API ou renforcez une API existante, explorez nos services d'ingénierie backend pour voir comment nous pouvons accélérer votre livraison.

E

Rédigé par

ECOSIRE Research and Development Team

Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.

Discutez sur WhatsApp