Aetherio

Rate Limiting et Quotas API LLM : comment éviter la facture surprise en production

12 minutes min de lecture

Partager l'article

Introduction

Le potentiel des Large Language Models (LLM) est indéniable pour transformer nos applications web et métiers, de la génération de contenu à l'automatisation de tâches complexes. Cependant, l'intégration de ces puissantes briques d'Intelligence Artificielle en production est souvent synonyme de coûts imprévus et de factures salées pour les entreprises non préparées. Le scénario catastrophe du bug qui boucle en appelant une API des milliers de fois par seconde n'est pas une légende urbaine, il s'agit d'une réalité amère pour de nombreuses startups et PME.

Chez Aetherio, nous intégrons des LLM dans des applications web et SaaS, du MVP à la refonte d'applications métier. Cet article est un guide pratique et technique pour maîtriser le rate limiting et les quotas des API LLM en production : protéger votre budget, garder une application stable et proposer une expérience utilisateur correcte quand une limite est atteinte. Nous aborderons les stratégies d'implémentation, les exemples de code concrets et les mécanismes d'alerting indispensables.

Illustration de gestion des requêtes API LLM avec des garde-fous

Le Scénario Catastrophe : Quand l'IA fait déraper la facture

Le scénario est simple : votre application SaaS, boostée à l'IA, est en production. Un client lance une requête, mais à cause d'une erreur inattendue (un bug dans la logique applicative, une boucle infinie due à une mauvaise gestion d'état, ou même une tentative de manipulation), votre API backend appelle l'API LLM externe des centaines, voire des milliers de fois par seconde. En quelques heures, le compteur tourne, et une facture à quatre ou cinq chiffres apparaît, ruinant votre budget et la confiance de la direction. C'est un risque très réel pour toute application utilisant des API LLM en production sans mécanismes de protection.

Les dangers d'une intégration LLM sans garde-fous

L'API d'un fournisseur LLM comme OpenAI, Anthropic ou Google est un point d'intégration externe. Sans contrôle, elle devient une vulnérabilité majeure pour votre budget et la performance de votre application :

  • Explosion des coûts : Chaque requête à un LLM coûte de l'argent, en fonction du nombre de tokens (des fragments de mots) envoyés et générés. Un bug peut multiplier ce coût de façon exponentielle.
  • DDoS involontaire contre soi-même : Trop de requêtes peuvent saturer non seulement l'API externe, mais aussi vos propres serveurs, entraînant des latences, des erreurs et une mauvaise expérience utilisateur.
  • Plafonnement des fournisseurs : Même si vous êtes prêt à payer, les fournisseurs d'API ont leurs propres limites de débit (requêtes et tokens par minute). Atteindre ces limites signifie que vos requêtes seront rejetées, rendant votre application inutilisable pour tous les utilisateurs.
  • Impact sur la réputation : Une application instable ou avec des fonctionnalités IA capricieuses dégrade l'image de votre entreprise et la satisfaction client.

Pour une analyse plus approfondie des implications financières, consultez notre article sur le coût réel de l'IA en production.

Implémenter des Quotas Granulaires : Maîtriser la Consommation

Le premier rempart contre ces débordements est la mise en place de quotas précis. Contrairement au rate limiting qui concerne le nombre de requêtes par unité de temps, un quota définit une limite d'utilisation cumulée sur une période plus longue (journalière, hebdomadaire, mensuelle) ou un plafond global. Cette approche permet d'éviter la facture API LLM surprise grâce à une gouvernance fine des ressources.

Types de quotas pertinents en production

Pour des applications SaaS, une approche granulaire est essentielle :

  • Quota par utilisateur : Chaque utilisateur dispose d'un budget individuel (ex: 100 requêtes LLM par jour, 10 000 tokens par mois). Idéal pour des offres freemium ou des paliers d'abonnement.
  • Quota par tenant (entreprise) : Pour les solutions B2B, l'entreprise cliente a un budget global que ses utilisateurs partagent. Ceci permet une facturation claire par entité.
  • Quota par feature IA : Certaines fonctionnalités IA sont plus coûteuses que d'autres. Limiter spécifiquement l'utilisation d'une fonctionnalité de génération d'images ultra-performante par exemple.
  • Quota par coût : Vous pouvez définir un budget monétaire maximal à ne pas dépasser pour une période donnée (budget_max_€). Cela nécessite de connaître le coût unitaire de chaque type de requête LLM et de le suivre activement.

Exemple d'implémentation de quotas en base de données (Node.js/Next.js avec Prisma)

La gestion des quotas nécessite une persistance. Une base de données est le lieu idéal pour stocker les compteurs et les limites.

Deux tables suffisent : l'une porte la limite et le compteur de la période en cours, l'autre garde le détail de chaque appel pour la facturation, l'analyse des coûts et les éventuels litiges avec un client.

// schema.prisma
model AiQuota {
  id         String   @id @default(cuid())
  tenantId   String
  period     String   // "2026-09" pour un quota mensuel
  tokenLimit Int
  tokensUsed Int      @default(0)
  updatedAt  DateTime @updatedAt

  @@unique([tenantId, period])
}

model AiUsageEvent {
  id           String   @id @default(cuid())
  tenantId     String
  userId       String
  feature      String   // "resume-document", "chat-support"...
  model        String
  inputTokens  Int
  outputTokens Int
  costMicroUsd Int      // millionièmes de dollar : jamais de flottant pour de l'argent
  createdAt    DateTime @default(now())

  @@index([tenantId, createdAt])
}

Le piège classique consiste à lire le compteur, le comparer à la limite, puis appeler le LLM. Deux requêtes simultanées passent alors le contrôle en même temps et dépassent le quota ensemble. La parade : réserver le budget avant l'appel, dans une seule requête SQL conditionnelle, puis ajuster avec la consommation réelle une fois la réponse reçue.

// lib/ai-quota.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export class QuotaExceededError extends Error {
  constructor(public readonly tenantId: string) {
    super(`Quota IA atteint pour le tenant ${tenantId}`);
  }
}

function currentPeriod(date = new Date()): string {
  return date.toISOString().slice(0, 7); // "2026-09"
}

// La condition du WHERE rend la réservation atomique : deux requêtes
// concurrentes ne peuvent pas dépasser la limite ensemble.
export async function reserveTokens(tenantId: string, estimated: number): Promise<string> {
  const period = currentPeriod();
  const updated = await prisma.$executeRaw`
    UPDATE "AiQuota"
    SET "tokensUsed" = "tokensUsed" + ${estimated}
    WHERE "tenantId" = ${tenantId}
      AND "period" = ${period}
      AND "tokensUsed" + ${estimated} <= "tokenLimit"
  `;
  // Aucune ligne modifiée : quota atteint, ou pas de quota ouvert pour la période
  // (la ligne est créée à l'activation de l'abonnement ou par un job mensuel).
  if (updated === 0) throw new QuotaExceededError(tenantId);
  return period;
}

interface SettleParams {
  tenantId: string;
  userId: string;
  feature: string;
  model: string;
  period: string;
  estimated: number;
  inputTokens: number;
  outputTokens: number;
  costMicroUsd: number;
}

// Remplace l'estimation par la consommation réelle et trace l'appel.
export async function settleTokens(p: SettleParams): Promise<void> {
  await prisma.$transaction([
    prisma.aiQuota.update({
      where: { tenantId_period: { tenantId: p.tenantId, period: p.period } },
      data: { tokensUsed: { increment: p.inputTokens + p.outputTokens - p.estimated } },
    }),
    prisma.aiUsageEvent.create({
      data: {
        tenantId: p.tenantId,
        userId: p.userId,
        feature: p.feature,
        model: p.model,
        inputTokens: p.inputTokens,
        outputTokens: p.outputTokens,
        costMicroUsd: p.costMicroUsd,
      },
    }),
  ]);
}

Côté service, l'appel au LLM (ici avec le Vercel AI SDK) s'insère entre la réservation et le règlement. Le bloc finally garantit que la réservation est libérée même si l'appel échoue.

// services/summarize.ts
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { reserveTokens, settleTokens } from '../lib/ai-quota';
import { PRICING } from '../config/llm-pricing'; // { [modelId]: { inputPerMTok, outputPerMTok } } en $

const MODEL_ID = 'gpt-4o-mini';
const MAX_OUTPUT_TOKENS = 800;

export async function summarize(tenantId: string, userId: string, text: string): Promise<string> {
  // Estimation volontairement pessimiste : mieux vaut réserver trop que pas assez.
  const estimated = Math.ceil(text.length / 3) + MAX_OUTPUT_TOKENS;
  const period = await reserveTokens(tenantId, estimated);

  let inputTokens = 0;
  let outputTokens = 0;
  try {
    const result = await generateText({
      model: openai(MODEL_ID),
      system: 'Résume le document en 5 points factuels, en français.',
      prompt: text,
      maxOutputTokens: MAX_OUTPUT_TOKENS, // plafond dur sur la partie la plus chère
      abortSignal: AbortSignal.timeout(30_000),
    });
    inputTokens = result.totalUsage.inputTokens ?? 0;
    outputTokens = result.totalUsage.outputTokens ?? 0;
    return result.text;
  } finally {
    // Un prix "par million de tokens" en dollars est aussi un prix en micro-dollars par token.
    const price = PRICING[MODEL_ID];
    await settleTokens({
      tenantId,
      userId,
      feature: 'resume-document',
      model: MODEL_ID,
      period,
      estimated,
      inputTokens,
      outputTokens,
      costMicroUsd: Math.round(inputTokens * price.inputPerMTok + outputTokens * price.outputPerMTok),
    });
  }
}

Les tarifs vivent dans un fichier de configuration que vous mettez à jour depuis la grille officielle du fournisseur : ils changent régulièrement, et une valeur figée dans le code finit toujours par fausser vos tableaux de bord.

Rate limiting avec Redis : lisser les pics de requêtes

Le quota protège le budget du mois. Le rate limiting protège contre les rafales : une boucle applicative, un script qui abuse d'un endpoint, un utilisateur qui clique vingt fois sur « Générer ». Il vous garde aussi sous les limites de débit de votre compte chez le fournisseur, qui s'appliquent à toute l'application.

Redis est l'outil naturel pour cela : les compteurs sont partagés entre toutes les instances de votre backend, les opérations sont atomiques et la latence est négligeable. Nous détaillons ses autres usages dans notre guide Redis pour le cache, les sessions et les files d'attente.

Une fenêtre glissante en quelques lignes

L'algorithme de fenêtre glissante enregistre l'horodatage de chaque requête dans un sorted set et compte celles de la dernière minute. Contrairement à une fenêtre fixe, il n'autorise pas le double de la limite à cheval sur deux minutes.

// lib/rate-limit.ts
import Redis from 'ioredis';
import { randomUUID } from 'node:crypto';

export const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');

// Le script Lua s'exécute d'un bloc côté Redis : pas de course entre le comptage et l'ajout.
const SLIDING_WINDOW = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
if redis.call('ZCARD', key) >= limit then
  return 0
end
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)
return 1
`;

export async function allowRequest(key: string, limit: number, windowMs: number): Promise<boolean> {
  const now = Date.now();
  const allowed = await redis.eval(SLIDING_WINDOW, 1, key, now, windowMs, limit, `${now}:${randomUUID()}`);
  return allowed === 1;
}

// Limites en cascade : l'utilisateur, son organisation, puis toute l'application.
// La limite globale reste sous la limite de requêtes par minute de votre compte fournisseur.
const LIMITS = [
  { scope: 'user', limit: 10, windowMs: 60_000 },
  { scope: 'tenant', limit: 100, windowMs: 60_000 },
  { scope: 'global', limit: 400, windowMs: 60_000 },
] as const;

export async function checkLlmRateLimit(userId: string, tenantId: string): Promise<boolean> {
  const ids = { user: userId, tenant: tenantId, global: 'all' };
  for (const { scope, limit, windowMs } of LIMITS) {
    if (!(await allowRequest(`rl:llm:${scope}:${ids[scope]}`, limit, windowMs))) return false;
  }
  return true;
}

Les valeurs de LIMITS sont des exemples : elles se calibrent à partir de l'usage réel de vos utilisateurs et des limites de votre palier chez le fournisseur.

Brancher le tout sur une route Express

La route vérifie d'abord la taille de l'entrée (c'est elle qui fait le coût), puis le débit, puis le quota. Chaque refus renvoie un code 429 et un message que l'utilisateur peut comprendre.

// routes/ai.ts
import { Router } from 'express';
import { requireAuth } from '../middlewares/auth';
import { checkLlmRateLimit } from '../lib/rate-limit';
import { QuotaExceededError } from '../lib/ai-quota';
import { summarize } from '../services/summarize';

const MAX_INPUT_CHARS = 50_000;
export const aiRouter = Router();

aiRouter.post('/summarize', requireAuth, async (req, res) => {
  const { id: userId, tenantId } = req.user;
  const { text } = req.body;

  if (typeof text !== 'string' || text.length === 0 || text.length > MAX_INPUT_CHARS) {
    return res.status(400).json({ error: `Le texte doit faire entre 1 et ${MAX_INPUT_CHARS} caractères.` });
  }
  if (!(await checkLlmRateLimit(userId, tenantId))) {
    res.set('Retry-After', '60');
    return res.status(429).json({ error: 'Trop de demandes en peu de temps. Réessayez dans une minute.' });
  }

  try {
    return res.json({ summary: await summarize(tenantId, userId, text) });
  } catch (error) {
    if (error instanceof QuotaExceededError) {
      return res.status(429).json({ error: 'Le quota IA mensuel de votre organisation est atteint.' });
    }
    throw error;
  }
});

Limiter aussi les tokens par minute

Les fournisseurs plafonnent le nombre de requêtes par minute, mais aussi le nombre de tokens par minute. Une poignée de requêtes sur de longs documents suffit à atteindre ce second plafond. Un compteur par minute sur les tokens estimés fait l'affaire :

// lib/rate-limit.ts (suite)
export async function reserveGlobalTokensPerMinute(estimated: number, limitPerMinute: number): Promise<boolean> {
  const key = `rl:llm:tpm:${Math.floor(Date.now() / 60_000)}`;
  const total = await redis.incrby(key, estimated);
  await redis.expire(key, 120);
  if (total > limitPerMinute) {
    await redis.decrby(key, estimated); // on rend les tokens d'une requête refusée
    return false;
  }
  return true;
}

Si vous préférez ne pas maintenir ce code, les bibliothèques rate-limiter-flexible (Node.js avec Redis) et @upstash/ratelimit (pensée pour le serverless) implémentent ces algorithmes.

Respecter les limites du fournisseur : retries, backoff et timeouts

Un 429 renvoyé par le fournisseur n'est pas une erreur à marteler. Le Vercel AI SDK, comme les SDK officiels d'OpenAI et d'Anthropic, réessaie déjà automatiquement certaines erreurs (dont les 429) avec un backoff exponentiel ; le nombre de tentatives se règle avec l'option maxRetries.

Le vrai danger est d'empiler les couches de retry. Si le SDK réessaie 3 fois, que votre code enveloppe l'appel dans sa propre boucle de 3 tentatives et que la file de jobs relance elle aussi 3 fois, une seule demande utilisateur peut produire 27 appels facturés. Choisissez une couche responsable des retries et désactivez les autres.

Trois réglages sont à poser sur chaque appel, sans exception :

  • maxOutputTokens : le plafond de tokens générés, qui borne le coût maximal d'une réponse.
  • Un timeout via abortSignal: AbortSignal.timeout(...), pour qu'un appel bloqué ne retienne pas une connexion et une réservation de quota indéfiniment.
  • maxRetries explicite, pour savoir exactement combien d'appels une requête peut coûter.

Pour les traitements en masse (import de documents, enrichissement d'une base), ne sollicitez pas l'API en direct depuis une boucle. Passez par une file de jobs dont le débit est plafonné, par exemple avec l'option limiter des workers BullMQ :

// workers/enrichment.worker.ts
import { Worker } from 'bullmq';
import { redis } from '../lib/rate-limit';
import { enrichDocument } from '../services/enrichment';

new Worker('document-enrichment', async (job) => enrichDocument(job.data.documentId), {
  connection: redis,
  concurrency: 5,
  limiter: { max: 60, duration: 60_000 }, // au plus 60 jobs par minute, toutes instances confondues
});

BullMQ exige une connexion ioredis configurée avec maxRetriesPerRequest: null pour ses workers : si vous réutilisez le client du rate limiter, ajoutez cette option à sa création. Et pour les traitements qui peuvent attendre quelques heures, OpenAI comme Anthropic proposent des API batch, moins chères que les appels unitaires.

Stratégies de fallback : dégrader proprement

Même avec des garde-fous, un fournisseur finit par saturer, tomber en panne ou vous renvoyer des 429 en série. L'objectif est alors que l'utilisateur obtienne une réponse, même moins bonne, ou au minimum un message clair.

Basculer vers un modèle de secours

Le fallback le plus simple consiste à réessayer sur un autre modèle, idéalement chez un autre fournisseur, uniquement quand l'erreur vient d'une saturation ou d'une panne côté fournisseur :

// lib/generate-with-fallback.ts
import { APICallError, RetryError, generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';

function httpStatusOf(error: unknown): number | undefined {
  // Après épuisement des retries, le SDK enveloppe la dernière erreur dans une RetryError.
  const cause = RetryError.isInstance(error) ? error.lastError : error;
  return APICallError.isInstance(cause) ? cause.statusCode : undefined;
}

export async function generateWithFallback(system: string, prompt: string) {
  const base = { system, prompt, maxOutputTokens: 800, maxRetries: 1 };
  try {
    return await generateText({ ...base, model: openai('gpt-4o-mini'), abortSignal: AbortSignal.timeout(20_000) });
  } catch (error) {
    const status = httpStatusOf(error);
    // Une erreur 4xx (hors 429) vient de notre requête : changer de modèle ne la corrigera pas.
    const providerSide = status === 429 || (status !== undefined && status >= 500);
    if (!providerSide) throw error;
    return generateText({ ...base, model: anthropic('claude-haiku-4-5'), abortSignal: AbortSignal.timeout(20_000) });
  }
}

Deux points d'attention. Le modèle de secours n'a pas le même tarif : tracez le modèle réellement utilisé dans vos événements de consommation. Et un second fournisseur est un second sous-traitant qui reçoit vos données : il doit figurer dans votre registre et vos contrats, comme nous l'expliquons dans notre article sur la conformité RGPD des projets IA.

Mettre en cache ce qui peut l'être

Pour les tâches déterministes (classification, extraction de champs, résumé d'un même document), stockez la réponse dans Redis sous une clé dérivée du modèle, de la version du prompt et d'un hash de l'entrée. Un document résumé une fois n'a pas à être payé une seconde fois. Un cache reste un stockage de données : s'il contient des données personnelles, il obéit aux mêmes règles de durée de conservation que votre base.

Les fournisseurs proposent par ailleurs du prompt caching, qui réduit le coût des préfixes répétés d'un appel à l'autre (un long system prompt, un document de contexte). Structurez vos prompts avec la partie stable en premier pour en profiter.

Prévoir le mode dégradé

Quand ni le modèle principal ni le secours ne répondent, l'interface doit le dire : un message qui explique que la fonctionnalité est momentanément indisponible, le reste de l'application qui continue de fonctionner, et pour les traitements longs, une mise en file avec notification quand le résultat est prêt. Une fonctionnalité IA ne doit jamais bloquer un parcours métier qui fonctionnait sans elle.

Alerting et coupe-circuit budgétaire

Les quotas et le rate limiting bornent la consommation par client. Il manque un dernier filet : une vue globale de la dépense, avec des alertes avant qu'il ne soit trop tard et un coupe-circuit qui suspend les appels si le budget du jour est dépassé.

// lib/ai-budget.ts
import { redis } from './rate-limit';

const budgetUsd = Number(process.env.AI_DAILY_BUDGET_USD);
if (!Number.isFinite(budgetUsd) || budgetUsd <= 0) {
  throw new Error('AI_DAILY_BUDGET_USD manquant ou invalide');
}
const DAILY_BUDGET_MICRO_USD = budgetUsd * 1_000_000;
const THRESHOLDS = [50, 80, 100];
const KILL_SWITCH_KEY = 'llm:kill-switch';
const TWO_DAYS = 60 * 60 * 48;

export async function recordSpend(costMicroUsd: number): Promise<void> {
  const day = new Date().toISOString().slice(0, 10);
  const total = await redis.incrby(`llm:spend:${day}`, costMicroUsd);
  await redis.expire(`llm:spend:${day}`, TWO_DAYS);

  for (const pct of THRESHOLDS) {
    if (total < (DAILY_BUDGET_MICRO_USD * pct) / 100) continue;
    // SET NX : une seule alerte par seuil et par jour, même avec plusieurs instances.
    const first = await redis.set(`llm:alert:${day}:${pct}`, '1', 'EX', TWO_DAYS, 'NX');
    if (first !== 'OK') continue;
    await notify(`Budget IA : ${pct} % du budget journalier consommé (${(total / 1_000_000).toFixed(2)} $)`);
    // Pas d'expiration : le coupe-circuit est levé à la main, après analyse.
    if (pct >= 100) await redis.set(KILL_SWITCH_KEY, day);
  }
}

export async function assertAiEnabled(): Promise<void> {
  if (await redis.exists(KILL_SWITCH_KEY)) {
    throw new Error('Fonctionnalités IA suspendues : budget journalier dépassé');
  }
}

async function notify(text: string): Promise<void> {
  const url = process.env.SLACK_WEBHOOK_URL;
  if (!url) return;
  await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });
}

recordSpend s'appelle dans settleTokens, assertAiEnabled au tout début de summarize, avant la réservation de quota. Le coupe-circuit s'arrête volontairement sur une intervention humaine : si la dépense a explosé, quelqu'un doit comprendre pourquoi avant de rouvrir le robinet.

Deux compléments rendent ce dispositif vraiment utile :

  • Alerter sur la vitesse, pas seulement sur le total. Une heure qui coûte beaucoup plus que les heures équivalentes des jours précédents signale un bug ou un abus, même si le budget du jour n'est pas encore atteint. Les événements AiUsageEvent suffisent à calculer ce ratio dans votre outil de monitoring.
  • Configurer les plafonds du fournisseur en dernier recours. Les consoles d'OpenAI et d'Anthropic permettent de fixer des limites ou des alertes de dépense. Elles agissent au niveau du compte entier : utiles contre la catastrophe, trop grossières pour gérer vos clients un par un.

Conclusion

Une fonctionnalité IA en production a besoin des mêmes garde-fous qu'un compte bancaire : un plafond par client (les quotas), une limite de débit contre les rafales (le rate limiting), une stratégie quand la banque ferme ses guichets (retries maîtrisés et fallback) et une alerte quand les dépenses s'emballent (le budget global et son coupe-circuit). Aucune de ces briques n'est complexe prise isolément. Ce qui coûte cher, c'est de les ajouter après la première facture.

Commencez par les deux protections qui évitent le pire : un maxOutputTokens et un timeout sur chaque appel, puis un rate limiting par utilisateur. Les quotas par tenant et l'alerting suivent dès que l'application a de vrais clients. Si vous préparez l'intégration d'un LLM dans votre produit, notre équipe peut vous aider à concevoir une architecture IA maîtrisée, du prototype à la production.

FAQ - Questions fréquentes