Rate Limiting & Retry — Tenir des charges massives sans claquer les quotas LLM
TL;DR Sans rate limiting tu te fais bannir par Anthropic/OpenAI/Mistral après 3 minutes de spike Black Friday. Sans retry intelligent tu perds 8% des requêtes pour rien. Deux insights que le débutant rate : (a) la limite qui te tue est souvent l'axe token (ITPM/OTPM), pas le RPM — un gros prompt RAG sature l'ITPM à RPS bas ; (b) le SDK Anthropic retry déjà 429/5xx avec backoff — tu choisis : t'appuyer dessus ou le couper (
maxRetries: 0) et mettre ta couche, jamais les deux (double retry = facture × 2). Les leviers 2026 : (1) provider rate limits (RPM + ITPM + OTPM par modèle ; demande l'augmentation tôt), (2) token bucket client-side qui réserve des TOKENS pas des requêtes (Bottleneck.js, p-queue, Lua/Redis), (3) exponential backoff + jitter (toujours randomize sinon thundering herd), (4) retry-on policy (429, 529, 503, timeouts, mais PAS sur 4xx contenu), (5) idempotency keys (avoid double charge si retry), (6) queue avec priorités (BullMQ pour faire passer le user-facing avant le batch), (7) per-tenant quotas (un client gourmand ne tue pas les autres), (8) circuit breaker (couper rapidement quand le provider est down). Côté freelance, vendre un "resilience pack" est 18-30 k€ avec ROI immédiat le jour du premier incident évité.
🧠 Mental model
┌─────────────────────────────────────────────────┐
│ CLIENT TIER (multi-tenant) │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │tenantA │ │tenantB │ │tenantC │ │
│ │QPS 10 │ │QPS 5 │ │QPS 20 │ │
│ └───┬────┘ └───┬────┘ └───┬────┘ │
└──────┼──────────┼──────────┼─────────────────────┘
│ │ │
┌──────▼──────────▼──────────▼──────────┐
│ PER-TENANT TOKEN BUCKET │
│ (Redis cluster, atomic INCR) │
└─────────────────┬─────────────────────┘
│
┌─────────────────▼─────────────────────┐
│ QUEUE BullMQ (priorities) │
│ user-facing P10, batch P50 │
└─────────────────┬─────────────────────┘
│
┌─────────────────▼─────────────────────┐
│ GLOBAL CLIENT LIMITER │
│ match provider RPM/TPM │
└─────────────────┬─────────────────────┘
│
▼
┌─────────────────────────┐
│ PROVIDER (Anthropic) │
│ RPM 4000 / TPM 200k │
└────────────┬────────────┘
│
┌──────────┴──────────┐
▼ ▼
200 OK 429/529 → backoff+jitter
→ retry up to N
→ circuit breaker if streakAnalogie : penser à un péage d'autoroute. Le provider LLM, c'est une seule cabine. Si 50 000 voitures arrivent en même temps, tout le monde galère. Tu mets en place :
- Des voies réservées (per-tenant quotas) : VIP ne bloquent pas les particuliers.
- Un système de priorité (BullMQ priorities) : ambulance d'abord.
- Un régulateur en amont (token bucket) : tu lisses le trafic.
- Un plan B (retry) si la cabine se gèle 2 sec.
- Un disjoncteur (circuit breaker) : si la cabine est cassée pendant 10 min, tu fermes l'accès et tu rediriges.
Sans ça, un seul script tenant peut saturer ton quota global et faire planter tous les autres clients.
Le piège mental n°1 : RPM ≠ TPM
La majorité des devs raisonnent en requêtes par minute (RPM) parce que c'est l'unité des API REST classiques. Pour un LLM c'est le mauvais axe. Anthropic limite sur trois axes simultanés : RPM, ITPM (input tokens/min) et OTPM (output tokens/min). Tu peux être à 5 % de ton RPM et déjà à 100 % de ton ITPM si tes prompts sont gros (RAG avec 50 k tokens de contexte, par exemple). Le 429 tombe sur l'axe le plus tendu.
Conséquences pour un staff engineer :
- Ton token bucket doit réserver des tokens, pas des requêtes. Un bucket RPM seul te laisse exploser l'ITPM. (C'est exactement pourquoi
TenantLimiterplus bas a un bucket RPM et un bucket TPM.) - Le coût d'un token bucket TPM précis dépend de l'estimation des tokens. Tu ne connais l'output qu'après la réponse → tu réserves sur une estimation (
max_tokens) puis tu réconcilies avecresp.usageréel après coup. Sur-réserver = sous-utiliser le quota ; sous-réserver = 429. Le prompt caching change l'équation : lescache_read_input_tokenscomptent ~0,1× — un bucket qui ignore le cache sous-estime ta capacité réelle d'un facteur 5–10. - Le streaming ne change pas la facture de tokens mais change la latence perçue et évite les timeouts HTTP sur les gros outputs : c'est un levier de résilience, pas seulement d'UX.
🛠️ Code minimal
Token bucket par tenant + retry avec jitter + idempotency key.
// libs/limiter/redis-token-bucket.ts
import { Redis } from "ioredis";
export class TokenBucket {
constructor(private redis: Redis) {}
// LUA script: atomic refill + take
private static SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local bucket = redis.call("HMGET", key, "tokens", "ts")
local tokens = tonumber(bucket[1]) or capacity
local ts = tonumber(bucket[2]) or now
local delta = (now - ts) * refill_rate
tokens = math.min(capacity, tokens + delta)
if tokens < cost then
redis.call("HMSET", key, "tokens", tokens, "ts", now)
redis.call("EXPIRE", key, 3600)
return {0, tokens}
else
tokens = tokens - cost
redis.call("HMSET", key, "tokens", tokens, "ts", now)
redis.call("EXPIRE", key, 3600)
return {1, tokens}
end
`;
async take(opts: { tenantId: string; capacity: number; refillPerSec: number; cost: number }): Promise<{ allowed: boolean; remaining: number }> {
const now = Date.now() / 1000;
const [ok, remaining] = (await this.redis.eval(
TokenBucket.SCRIPT,
1,
`tb:${opts.tenantId}`,
opts.capacity,
opts.refillPerSec,
now,
opts.cost,
)) as [number, number];
return { allowed: ok === 1, remaining };
}
}// libs/llm/retrying-client.ts
import Anthropic from "@anthropic-ai/sdk";
// IMPORTANT : on désactive le retry interne du SDK (maxRetries: 0) parce que
// NOTRE couche (token bucket + backoff + métriques) est la source de vérité.
// Laisser les deux actifs = double retry = spike 2× au retour du provider.
// On garde un timeout par requête pour ne pas bloquer un worker indéfiniment.
const anthropic = new Anthropic({ maxRetries: 0, timeout: 30_000 });
const RETRIABLE = new Set([408, 425, 429, 500, 502, 503, 504, 529]);
export async function callWithRetry<T>(
fn: () => Promise<T>,
opts: { maxAttempts?: number; baseMs?: number; capMs?: number; idempotencyKey?: string } = {},
): Promise<T> {
const { maxAttempts = 5, baseMs = 400, capMs = 8000 } = opts;
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
// err instanceof Anthropic.APIError → status typé ; sinon erreur réseau (ECONNRESET…)
// que l'on traite comme retriable (pas de status = timeout/socket).
const status: number | undefined = err?.status;
const isNetwork = status === undefined && !(err instanceof Anthropic.APIUserAbortError);
const retriable = isNetwork || (status !== undefined && RETRIABLE.has(status));
if (!retriable || attempt === maxAttempts) {
throw err;
}
// exponential backoff + FULL jitter (sleep ∈ [0, exp]) — décorrèle les clients.
const exp = Math.min(capMs, baseMs * 2 ** (attempt - 1));
const sleep = Math.floor(Math.random() * exp);
// Respecter Retry-After si le provider l'envoie (429/529). Le SDK expose
// les headers sur err.headers (Headers ou objet selon version).
const ra = err?.headers?.get?.("retry-after") ?? err?.headers?.["retry-after"];
const retryAfter = Number(ra);
const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : sleep;
await new Promise(r => setTimeout(r, wait));
lastErr = err;
}
}
throw lastErr;
}
export async function answer(question: string, tenantId: string) {
return callWithRetry(
() =>
anthropic.messages.create(
{
model: "claude-haiku-4-5",
max_tokens: 600,
messages: [{ role: "user", content: question }],
},
{ headers: { "x-tenant-id": tenantId } },
),
{ maxAttempts: 5 },
);
}🎬 Cas d'usage concrets
Cas 1 — Ingestion massive 100k factures la nuit (compta)
Le client : ESN compta (suite du cas 03), traitement nocturne de 100k factures par tenant entre 22h et 6h. Anthropic Batch API utilisé. Mais batch API a aussi des limites (concurrent batches, taille).
Problèmes initiaux :
- Quand tous les tenants commencent à 22h pile → spike → batch queue saturée → 30% lancent en retard.
- Quand le batch finit, le post-process en synchrone explose RPM → 429.
Solution :
- Per-tenant scheduler : on étale les départs 22h-23h30 par hash tenant. Pas tous en même temps.
- BullMQ avec priorité :
priority: 50pour batch nuit,priority: 10pour traitements interactifs. - Token bucket Anthropic global :
RPM=4000réparti, le worker attend si bucket vide. - Idempotency : chaque facture a un
idempotencyKey = sha256(tenantId + invoiceId + version). Si retry, on ne re-écrit pas un double dans la compta. - Resume capability : si la nuit fail à mi-chemin, on reprend là où on s'est arrêté (state dans Postgres).
Résultat : 100k × 30 tenants = 3M factures/nuit, fini en moyenne à 4h. Mission 28 k€.
Cas 2 — Spike Black Friday e-commerce
Le client : DTC fashion FR (cf cas du chapitre 04). Black Friday 2025 : +800% trafic chatbot. Sans préparation : 35% de requêtes échouées (429 Anthropic), conversion en chute.
Plan resilience 2026 :
- Tier upgrade : demander à Anthropic d'augmenter le tier (RPM 5000 → 25000) 2 semaines avant le Black Friday. Anthropic l'accorde sur preuve de traffic projection.
- Multi-provider failover : Anthropic primary, OpenAI fallback. Si 3 erreurs 429/529 d'affilée sur 30 sec → bascule OpenAI (cf chapitre 07).
- Queue dégradée : si la queue grossit > 1000, on retire le chatbot du site (degradation gracieuse via feature flag) → conversion préservée sur le reste.
- Pré-warm caches : la veille, on warmup les caches sémantiques avec les 1000 questions top historiques.
- Monitor temps réel : Datadog dashboard avec RPS, error rate, p95 latence, queue depth ; PagerDuty si seuil.
Résultat : Black Friday 2026 : 0% requêtes perdues, latence p95 stable, conversion +12%. Mission resilience 24 k€ facturée en prep amont.
Cas 3 — Traitement batch RGPD (purge / export sur demande)
Le client : éditeur SaaS RH FR (cf chapitre 04). Quand un client final demande l'export RGPD de ses données → traitement lourd (export + résumés LLM + chiffrement + envoi). Doit aboutir sous 30 jours (RGPD). Volume : 50-200 demandes/mois.
Solution :
- Queue dédiée RGPD : BullMQ queue
rgpd,priority: 20(moins prioritaire que user-facing). - Rate limit dédié : 10% du quota total réservé à RGPD pour pas crasher l'usage normal.
- Idempotency par demande : si la demande est re-soumise (user impatient), on ne refait pas. On retourne l'état.
- Retry persistent : BullMQ avec
attempts: 10, exponential,backoff: { type: "exponential", delay: 5000 }. Jusqu'à 7 jours de retry persistent en cas d'outage provider. - Notification user : webhook quand prêt, fallback email J+25 si proche du délai.
Mission : 16 k€.
Cas 4 — Processing files avocat (10k contrats à un client)
Le client : cabinet d'avocats (cf chapitre 01). Un dossier énorme arrive : 10k contrats PDF à analyser et classifier. Le cabinet veut une livraison sous 5 jours.
Stratégie :
- Hybride batch + interactive : 95% en Batch API (-50% coût, livraison 24h), 5% en synchrone pour les contrats critiques signalés.
- Worker fleet : 4 workers BullMQ parallèles, chacun avec quota de 1/4 du RPM total.
- Retry per-doc : si un PDF échoue (corrupt, encodage, OCR pourri), retry 3x puis marque "à revoir manuellement" sans bloquer la queue.
- Progress dashboard : Slack notif toutes les 1k traitées, ETA dynamique.
- Coût par tenant : on facture exactement au token (cf chapitre 08).
Mission : 22 k€ + facturation à l'usage (mode "PaaS LLM").
🛠️ Exemple end-to-end — Worker BullMQ qui process 1M docs/jour avec quotas + batch API + retry
Contexte : éditeur SaaS LegalTech FR. Pipeline d'ingestion qui doit traiter ~1M documents juridiques par jour (jurisprudence + JO + actes admin). Multi-tenant (15 clients, du petit cabinet à la grande étude). Quotas variables par contrat.
1) Architecture
SOURCES (web scraping, FTP, S3 push)
│
▼
Postgres `inbox`
│
┌───────────┴───────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ Sched │ │ Backfill│
│ (cron) │ │ jobs │
└────┬────┘ └────┬────┘
│ │
▼ ▼
┌──────────────────────────────────────┐
│ BullMQ queues │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ ingest │ │ analyze│ │ index │ │
│ └───┬────┘ └───┬────┘ └───┬────┘ │
└──────┼──────────┼──────────┼──────────┘
│ │ │
▼ ▼ ▼
Workers (k8s pods, autoscale 5-30)
│
▼
┌───────────────────────────────────┐
│ CLAUDE Batch API + Sync fallback │
│ Per-tenant token bucket │
│ Per-tenant cost meter │
└───────────────┬───────────────────┘
│
▼
Qdrant + Postgres2) BullMQ setup
// libs/queue/queues.ts
import { Queue } from "bullmq";
const connection = { host: process.env.REDIS_HOST!, port: 6379 };
export const ingestQueue = new Queue("ingest", { connection });
export const analyzeQueue = new Queue("analyze", { connection });
export const indexQueue = new Queue("index", { connection });
// ⚠️ Piège de version : en BullMQ v4 il fallait instancier un `QueueScheduler`
// par queue pour gérer les jobs delayed/retry. En **v5 le QueueScheduler a été
// supprimé** : les workers gèrent nativement les delays. Si tu copies un vieux
// snippet v4 avec `new QueueScheduler(...)`, l'import explose à l'exécution.
// Rien à faire ici en v5 — le worker suffit.3) Per-tenant rate limiter
// libs/limiter/tenant-limiter.service.ts
import { Injectable } from "@nestjs/common";
import { Redis } from "ioredis";
interface TenantPolicy {
rpmCap: number; // max LLM RPM allowed for tenant
tpmCap: number; // max LLM TPM
costEurDailyCap: number; // hard EUR cap daily
}
@Injectable()
export class TenantLimiter {
constructor(private redis: Redis) {}
async tryConsume(opts: { tenantId: string; tokensRequested: number; eurCost: number; policy: TenantPolicy }) {
// 1. Token bucket RPM
const rpmOk = await this.checkBucket(`rpm:${opts.tenantId}`, opts.policy.rpmCap, opts.policy.rpmCap / 60, 1);
if (!rpmOk) return { allowed: false, reason: "rpm_exceeded" };
// 2. Token bucket TPM
const tpmOk = await this.checkBucket(`tpm:${opts.tenantId}`, opts.policy.tpmCap, opts.policy.tpmCap / 60, opts.tokensRequested);
if (!tpmOk) return { allowed: false, reason: "tpm_exceeded" };
// 3. EUR daily cap
const dayKey = `eur:${opts.tenantId}:${new Date().toISOString().slice(0, 10)}`;
const eurUsed = parseFloat(((await this.redis.get(dayKey)) ?? "0")) || 0;
if (eurUsed + opts.eurCost > opts.policy.costEurDailyCap) {
return { allowed: false, reason: "eur_cap_exceeded" };
}
await this.redis.incrbyfloat(dayKey, opts.eurCost);
await this.redis.expire(dayKey, 86400 * 35);
return { allowed: true };
}
private async checkBucket(key: string, cap: number, refillPerSec: number, cost: number) {
// ... call TokenBucket from previous example ...
return true;
}
}4) Worker analyze (Batch API)
// workers/analyze.worker.ts
import { Worker } from "bullmq";
import Anthropic from "@anthropic-ai/sdk";
import { TenantLimiter } from "@app/limiter";
import { Audit } from "@app/observability";
import { v4 as uuid } from "uuid";
const anthropic = new Anthropic();
const limiter = new TenantLimiter(redis);
new Worker(
"analyze",
async (job) => {
const { docs, tenantId, policy } = job.data; // docs: 50-500 docs grouped per tenant
const totalTokens = docs.reduce((a: number, d: any) => a + d.estTokens, 0);
const eurCost = estimateBatchEur(totalTokens);
const consume = await limiter.tryConsume({
tenantId,
tokensRequested: totalTokens,
eurCost,
policy,
});
if (!consume.allowed) {
// Push back to queue with delay
throw new Error(`Rate limited: ${consume.reason}`);
}
// Build batch payload
const requests = docs.map((d: any) => ({
custom_id: `${tenantId}:${d.id}:${d.version}`,
params: {
model: "claude-sonnet-4-6",
max_tokens: 800,
messages: [{ role: "user", content: buildPrompt(d) }],
},
}));
const batch = await anthropic.beta.messages.batches.create({ requests });
// store batch_id, wait via poll job
return { batchId: batch.id, count: requests.length };
},
{
connection,
concurrency: 4,
limiter: { max: 10, duration: 1000 }, // 10 jobs/sec max per worker
},
);5) Polling batches
// workers/poll-batches.worker.ts
import { Worker } from "bullmq";
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
new Worker(
"poll-batches",
async (job) => {
const { batchId, tenantId } = job.data;
const batch = await anthropic.beta.messages.batches.retrieve(batchId);
if (batch.processing_status !== "ended") {
// requeue with delay
await job.queue.add("poll-batches", job.data, { delay: 60_000 });
return;
}
// download results
const results = anthropic.beta.messages.batches.results(batchId);
for await (const r of results) {
// Réconciliation coût : on logge l'usage RÉEL (input/output + cache) pour
// (1) facturer au token près, (2) recaler le token bucket TPM, (3) alerter
// si retry_count × coût explose. C'est la seule source de vérité du coût.
if (r.result.type === "succeeded") {
const u = r.result.message.usage;
metric("llm.tokens.input", u.input_tokens, { tenantId });
metric("llm.tokens.output", u.output_tokens, { tenantId });
metric("llm.tokens.cache_read", u.cache_read_input_tokens ?? 0, { tenantId });
}
await persistResult({ tenantId, custom_id: r.custom_id, result: r.result });
}
},
{ connection, concurrency: 10 },
);6) Retry policy & idempotency
// libs/queue/job-options.ts
export const HEAVY_JOB_OPTS = {
attempts: 8,
backoff: { type: "exponential" as const, delay: 5_000 },
removeOnComplete: { age: 86_400, count: 1000 },
removeOnFail: false, // keep failed for inspection
};
export function idempotencyKey(t: { tenantId: string; docId: string; version: number }) {
return `${t.tenantId}:${t.docId}:${t.version}`;
}When adding job :
await analyzeQueue.add(
"analyze",
{ docs, tenantId, policy },
{
...HEAVY_JOB_OPTS,
jobId: `analyze:${tenantId}:${batchSig(docs)}`, // BullMQ dedup
},
);7) Circuit breaker for provider outages
// libs/llm/circuit-breaker.ts
import CircuitBreaker from "opossum";
export const claudeBreaker = new CircuitBreaker(
async (params: Anthropic.MessageCreateParams) => {
return anthropic.messages.create(params);
},
{
timeout: 30_000,
errorThresholdPercentage: 50,
resetTimeout: 30_000,
rollingCountTimeout: 60_000,
},
);
claudeBreaker.on("open", () => {
metric("llm.circuit_open", 1, { provider: "anthropic" });
// optionally toggle feature flag to switch to fallback provider
});8) Dashboard (extrait Grafana / Datadog)
- RPS per tenant.
- 429 / 529 rate per provider.
- Retry count distribution (median, p95, p99).
- Queue depth per queue.
- EUR consumed per tenant per day.
- Circuit breaker state.
Mission : 42 k€ (6 semaines) pour stack complète. Maintenance 1800€/j 2j/mois.
🎯 Patterns courants
- Tier the queues : une queue par priorité (P10 user, P50 batch, P90 background). BullMQ supporte les priorités numériques.
- Token bucket Redis Lua : atomic via script Lua. Pas de race condition.
- Per-tenant + global : limiter à 2 niveaux. Tenant ne saturate pas son voisin, global ne saturate pas le provider.
- Idempotency key :
jobIdBullMQ +idempotency-keyheader LLM si supporté (OpenAI le supporte ; Anthropic viacustom_iddans batch). - Backoff + jitter : toujours randomize. Full jitter ou decorrelated jitter. Sans jitter → thundering herd au retour du provider.
- Respect
Retry-After: si présent dans la réponse 429/529, use-le, ne calcule pas le tien. - Distinguer retriable et non-retriable : retry sur 5xx, 429, 529, timeouts. NE PAS retry sur 4xx contenu (le prompt est mauvais, ça va re-fail).
- Circuit breaker : opossum (Node), pybreaker (Python). State open = bypass complet vers fallback.
- Dead letter queue : après N retries échoués, push dans DLQ pour inspection manuelle (pas perdre).
- Backpressure : si la queue grossit > seuil, refuser de nouveaux jobs et alerter (mieux que crasher).
- Provider tier upgrade proactif : Anthropic accorde tier supérieur sur preuve d'usage. Demande 2 semaines avant le pic.
- Multi-provider failover : préparer fallback (cf chapitre 07).
🔄 Versions & écosystème 2026
- BullMQ v5 (Node) : standard de fait. Postgres backend dispo depuis 2025 (en alternative Redis).
- Inngest : moderne, durable workflows, retry built-in, idéal pour LLM-heavy.
- Trigger.dev : alternative similaire.
- Temporal : pour workflows complexes, durables, multi-jour.
- Celery / RQ : si tu vis en Python.
- Bottleneck.js / p-queue : pour limiter sur un seul process Node.
- opossum : circuit breaker Node de référence.
- LiteLLM intègre retry + fallback + breaker natif.
- Portkey : gateway commerciale avec rate limiting + retry.
- Anthropic : tiers 1→4, RPM/ITPM/OTPM séparés par modèle (Opus, Sonnet, Haiku ont chacun leur pool — Haiku 4.5 ne pioche pas dans le quota de Opus 4.8). Le SDK officiel (
@anthropic-ai/sdk,anthropicpy) retry déjà 429/5xx avec backoff (maxRetries: 2par défaut) et litretry-aftertout seul. Décision senior : soit tu t'appuies dessus, soit tu le coupes (maxRetries: 0) et tu mets ta propre couche — jamais les deux (double retry). Upgrade de tier via la console, sur preuve de trafic. - OpenAI : tier auto basé sur historique de spend.
⚠️ Pitfalls
- Retry sur tout : retry sur 401 (auth) → tu boucles infiniment. Whitelist explicite des codes.
- Pas de jitter : 1000 clients qui retry à
t+2sexactement → mégaspike → 429 récurrent. - Idempotency oubliée : retry crée un doublon en base. Toujours dedup côté code (jobId, idempotency-key, primary key).
- Token bucket non-atomique :
GET + SETséparé → race. Use Lua ou Redis MULTI. - Rate limit côté SDK ignoré : le SDK Anthropic a sa propre logique retry. Tu en ajoutes une → double retry → spike. Configure-le explicitement.
- Queue Redis non-persisté : redis.conf
appendonly no→ crash → tu perds les jobs. Toujours AOF + RDB. - Pas de DLQ : un job qui fail 8x est perdu. DLQ obligatoire en prod.
- Per-tenant cap absent : un client met un cron buggé qui spam → tue tout le monde.
- Circuit breaker mal réglé : seuil trop bas → ouvre à chaque blip réseau. Trop haut → ne s'ouvre jamais.
- Coût retry non monitoré : tu retry 5x sur un Opus call coûteux → multiplies la facture. Track retry_count par provider.
💰 Pricing / ROI client
Mission types :
- Resilience audit (1 sem) : 6-10 k€.
- Resilience pack (queue + retry + breaker + multi-tenant) : 18-35 k€ (3-6 sem).
- Maintenance + alerting : 1500-1800€/j 1j/mois.
ROI client :
- 1 incident provider down = 4-8h indispo = des dizaines de k€ CA + image.
- 1 ban tier provider = freeze produit pendant des jours.
- Mission payée par 1 seul incident évité.
🧪 Testing / Eval
- Chaos test : injecte 429 / 529 / timeouts aléatoires via proxy (Toxiproxy) → vérifie que retry/jitter/breaker font le job.
- Load test : 10x trafic réel pendant 30 min, mesure error rate et p95.
- Tenant isolation test : un tenant spam 100x quota → vérifie que les autres restent ok.
- DLQ test : injecte un job qui fail toujours → vérifie qu'il atterrit en DLQ après N attempts.
- Idempotency test : duplicate jobs avec même key → vérifie no doublon en base.
🔁 Quand utiliser / éviter
Utiliser :
- Tout SaaS multi-tenant.
- Pipelines batch (compta, legal, ingest).
- Burst events (Black Friday, lancement produit).
- Provider mission-critical.
Use minimal :
- POC < 100 req/jour.
- Script CLI one-shot.
- Internal tool < 5 utilisateurs.
🧩 Bonus — Patterns resilience avancés FR
A. Adaptive concurrency
Au lieu de cap fixe, tu adaptes le concurrency en temps réel selon l'error rate. Si 429 monte → réduit concurrency ; si 200 stable → augmente.
// libs/limiter/adaptive.ts
class AdaptiveLimiter {
private inflight = 0;
private cap = 50;
private errors = 0;
private successes = 0;
async run<T>(fn: () => Promise<T>): Promise<T> {
while (this.inflight >= this.cap) await sleep(50);
this.inflight++;
try {
const res = await fn();
this.successes++;
this.maybeAdjust();
return res;
} catch (e: any) {
if (e.status === 429) {
this.errors++;
this.cap = Math.max(5, Math.floor(this.cap * 0.7));
}
throw e;
} finally {
this.inflight--;
}
}
private maybeAdjust() {
if (this.successes > 100 && this.errors === 0) {
this.cap = Math.min(200, this.cap + 5);
this.successes = 0;
}
}
}B. Bulkhead pattern
Isole les ressources : un pool de workers pour user-facing, un autre pour batch. Si le batch sature, l'user-facing n'est PAS impacté.
# BullMQ : 2 workers pools séparés
- name: user-facing-pool
concurrency: 20
redis: redis-fast
- name: batch-pool
concurrency: 5
redis: redis-sharedC. Outbox pattern pour Stripe / persist
Pour persister fiable malgré les retries :
- Tu écris d'abord dans une table
outbox(Postgres) en transaction. - Un worker dédié pousse ces lignes vers Stripe / API externe avec retry.
- Si succès, marque
sent_at. Sinon, retry indéfini.
Pas de perte, même si le worker crash en plein vol.
D. Idempotency multi-niveaux
- Job idempotency :
jobIdBullMQ. - HTTP idempotency : header
Idempotency-Key(OpenAI le supporte ; Anthropic Batch viacustom_id). - DB primary key :
UNIQUE(tenant_id, doc_id, version)empêche les doublons.
E. Saturate-soon alert
Au lieu d'attendre l'incident, alerte si la trajectoire indique saturation dans X minutes.
-- alert si trend → 80% capacity dans 10 min
WITH usage AS (
SELECT toMinute(ts) AS m, sum(tokens) AS used
FROM events WHERE ts > now() - INTERVAL 30 MINUTE
GROUP BY m
)
SELECT linearForecast(m, used, 10) AS forecast_10min FROM usage;F. Brownout (graceful degradation)
Quand la charge explose, tu peux dégrader plutôt que casser :
- Désactive le chatbot non-critique (FAQ static à la place).
- Réduit la qualité (Haiku au lieu de Sonnet).
- Coupe les features avancées (parsing image, search étendu).
Mieux que 503 brutal.
G. Provider dual write (rare)
Pour des features critiques sans tolérance à l'erreur (urgence santé), tu envoies en parallèle à Anthropic ET OpenAI, et tu prends la première réponse. Coût x2 mais 99.99% dispo.
H. SLA tracking par tenant
Tu mesures par tenant son SLA observé (uptime, p95 latence, error rate) et tu génères un rapport mensuel. Si SLA inférieur au contrat (99.5% par exemple), tu déclenches un credit auto. Transparence = trust.
🧨 Failure modes — comment un staff engineer raisonne
Le débutant code le happy path. Le senior raisonne par mode de défaillance : pour chaque levier, "qu'est-ce qui casse si je l'oublie, et comment je le détecte ?".
| Mode de défaillance | Cause racine | Symptôme observé | Détection | Mitigation |
|---|---|---|---|---|
| Thundering herd | Retry sans jitter, ou cache TTL qui expire pour tous en même temps | Spike de 429 toutes les ~2 s, en dents de scie | Auto-corrélation du RPS, pics périodiques | Full/decorrelated jitter ; randomiser les TTL de cache |
| Double retry | Couche maison + retry SDK actifs simultanément | Facture × 2, RPS double au retour du provider | retry_count > attendu, usage × attempts | maxRetries: 0 côté SDK ou pas de couche maison |
| Retry storm sur non-retriable | Whitelist absente, retry sur 400/401/contenu | Boucle infinie, worker bloqué | Même requête, même 4xx, N fois | Whitelist explicite des codes retriables |
| Quota TPM saturé, RPM OK | Bucket basé sur les requêtes, pas les tokens | 429 alors que le RPS est bas | 429 corrélé à la taille des prompts, pas au RPS | Bucket ITPM/OTPM séparé, réservation sur max_tokens |
| Noisy neighbor | Pas de per-tenant cap | Un cron buggé d'un tenant tue tous les autres | p95 global dégradé, un seul tenant en cause | Token bucket par tenant + bulkhead |
| Circuit breaker mal réglé | Seuil trop bas / trop haut | Ouvre à chaque blip réseau, ou jamais | Flapping de circuit_open, ou 0 ouverture en outage | Tuner errorThresholdPercentage + volumeThreshold |
| Perte de jobs | Redis sans AOF, ou pas de DLQ | Jobs disparus après crash / fail | Compte de jobs traités < soumis | AOF + RDB ; DLQ après N attempts |
| Cost blow-up silencieux | Retry × Opus sur gros prompt, non monitoré | Facture mensuelle qui dérape sans alerte | Pas de métrique usage par appel | Logger resp.usage ; alerte sur retry_count × coût |
🏋️ Exercices
Demandés : durs et progressifs. Chacun se code en NestJS + Redis +
@anthropic-ai/sdk. Tu n'as pas réussi tant que tu n'as pas un test qui prouve le comportement (chaos, charge, ou isolation).
Exercice 1 — Token bucket atomique, prouve qu'il l'est
Objectif : implémenter le TokenBucket Lua du chapitre et prouver par un test de concurrence qu'il n'a pas de race condition.
Indice/Solution : lance 1 000 take() en parallèle (Promise.all) sur un bucket de capacité 100 avec refill 0. Assert que exactement 100 passent (allowed: true) et 900 sont refusés. Si tu obtiens 101+, ton script n'est pas atomique (tu as un GET puis SET séparé au lieu d'un eval Lua). Refais en MULTI/Lua et re-teste. Bonus : mesure le throughput du eval sous 10 k req/s — au-delà, le bucket Redis devient le goulot, il faut sharder par tenant ou passer en token bucket local approximatif + réconciliation.
Exercice 2 — Backoff : démontre le thundering herd, puis tue-le
Objectif : reproduire un thundering herd en environnement contrôlé, puis prouver que le jitter le supprime.
Indice/Solution : 500 clients simulés tapent un faux provider (Toxiproxy ou un mock Express) qui renvoie 429 pendant 3 s puis 200. Variante A : backoff exponentiel sans jitter → trace le RPS, tu verras des pics nets à t+1s, t+2s, t+4s. Variante B : full jitter (sleep ∈ [0, exp]) → le RPS s'aplatit. Mesure le p99 de la latence de complétion des 500 requêtes dans les deux cas : le jitter améliore le p99 même s'il dégrade légèrement le best-case. Défends ce trade-off à l'oral.
Exercice 3 — Bucket TPM, pas RPM
Objectif : faire échouer un bucket RPM seul sur des prompts hétérogènes, puis le corriger en réservant des tokens.
Indice/Solution : envoie un mix de prompts (1 k, 10 k, 50 k tokens de contexte) à RPM constant. Un bucket RPM laisse passer le volume → tu manges des 429 ITPM en prod. Ajoute un bucket TPM qui réserve max_tokens + tokens d'input estimés avant l'appel, puis réconcilie avec resp.usage réel après (rends les tokens sur-réservés, déduis les sous-réservés). Mesure l'écart estimation/réel sur 100 appels. Question piège : où intègres-tu cache_read_input_tokens (~0,1×) dans le calcul de réservation ? (Réponse : tu réserves au tarif input plein puis crédites le delta cache à la réconciliation — sinon tu gaspilles 5–10× ta capacité.)
Exercice 4 — Circuit breaker + fallback multi-provider, casse-le
Objectif : un breaker opossum qui bascule sur un provider de secours, puis exhiber les deux mauvais réglages.
Indice/Solution : enveloppe l'appel Anthropic dans un breaker (errorThresholdPercentage, volumeThreshold, resetTimeout). Injecte via Toxiproxy : (a) 100 % d'erreurs → le breaker doit ouvrir et router vers le fallback ; (b) 2 % d'erreurs (blips réseau) → le breaker ne doit pas ouvrir. Si avec un seuil bas il flappe sur les blips, monte volumeThreshold (n'évalue qu'au-delà de N appels). Puis half-open : après resetTimeout, un seul appel test passe ; s'il échoue, ré-ouvre. Piège de cohérence : que fais-tu des jobs en cours quand le breaker s'ouvre — tu les rejoues sur le fallback (risque de double effet si pas idempotent) ou tu les remets en queue ? Défends.
Exercice 5 — Isolation multi-tenant sous attaque (production-grade)
Objectif : prouver qu'un tenant malveillant ne dégrade pas le p95 des autres.
Indice/Solution : 3 tenants. Le tenant C lance un cron buggé à 100× son quota. Sans per-tenant cap, mesure le p95 de A et B → il explose (noisy neighbor). Ajoute : (1) token bucket par tenant, (2) bulkhead — pool de workers séparé pour le batch vs l'interactif, (3) priorités BullMQ. Re-mesure : le p95 de A/B doit rester plat pendant que C se fait throttler. Le test d'acceptation est p95(A) sous attaque ≈ p95(A) au repos ± 10 %. Bonus : ajoute un EUR daily cap par tenant et prouve que C se fait couper à son plafond sans toucher au quota global.
Exercice 6 — Chaos test de bout en bout, "défends le chiffre"
Objectif : industrialiser le pipeline complet et garantir un chiffre de fiabilité sous chaos continu.
Indice/Solution : monte le pipeline BullMQ (token bucket + retry + DLQ + breaker + idempotency jobId). Lance Toxiproxy qui injecte en continu et aléatoirement 429/529/timeouts/latence sur 30 min, à 10× le trafic nominal. Exigences : 0 job perdu (DLQ inclus), 0 doublon en base (assert sur UNIQUE(tenant_id, doc_id, version)), error rate utilisateur final < 0,5 %, p95 stable. Quand un recruteur demande "ton système tient combien de 9 ?", tu réponds avec ce harness et le chiffre mesuré, pas une intuition. Si tu n'atteins pas le chiffre, identifie le maillon faible (souvent : lookback de 20 blocs, ou DLQ sans rejeu) et corrige.
🎤 En entretien
Q : Pourquoi le jitter est-il obligatoire dans un backoff, et lequel choisir ? R : Sans jitter, tous les clients qui ont pris un 429 au même instant retentent au même t+exp → thundering herd, 429 récurrent. Full jitter (sleep ∈ [0, exp]) décorrèle ; decorrelated jitter converge plus vite tout en restant borné. On randomise toujours, quitte à dégrader le best-case pour aplatir le p99.
Q : Tu vois des 429 alors que ton RPS est très bas. Que se passe-t-il ? R : Tu satures un axe token (ITPM/OTPM), pas l'axe RPM — typiquement de gros prompts RAG. Le fix : un token bucket qui réserve des tokens (estimés via max_tokens + input) et réconcilie avec resp.usage, pas un bucket de requêtes. Le prompt caching (cache_read ~0,1×) doit entrer dans le calcul sinon tu sous-estimes ta capacité.
Q : Le SDK Anthropic retry déjà 429/5xx. Pourquoi (et comment) en ajouter un ? R : On n'empile jamais les deux — double retry = spike 2× et facture 2×. Soit on s'appuie sur le SDK (maxRetries, lit retry-after), soit on le coupe (maxRetries: 0) pour mettre une couche maison qui intègre token bucket, métriques usage, DLQ et priorités. La couche maison se justifie dès qu'on est multi-tenant ou qu'on veut observer/borner le coût.
Q : Comment garantis-tu zéro double-charge / doublon malgré les retries ? R : Idempotence à plusieurs niveaux — jobId BullMQ (dedup file), custom_id Anthropic dans le Batch API, et surtout une contrainte UNIQUE(tenant_id, doc_id, version) en base qui est le dernier rempart. Le retry redevient sûr car ré-écrire la même clé est un no-op. Sans cette clé d'unicité, un retry après écriture partielle crée un doublon.
Q : Circuit breaker — comment tu le règles pour qu'il n'ouvre ni trop ni trop peu ? R : Deux paramètres clés : errorThresholdPercentage (le ratio d'échec) et volumeThreshold (n'évalue qu'au-delà de N appels, sinon un blip à 1/1 fait 100 % et ouvre à tort). En half-open, un seul appel test sonde la reprise. On tune avec un chaos test à 2 % d'erreurs (ne doit pas ouvrir) et 100 % (doit ouvrir et router vers le fallback).
🔗 Liens
- BullMQ docs : https://docs.bullmq.io
- Inngest : https://www.inngest.com/docs
- Trigger.dev : https://trigger.dev/docs
- Temporal : https://docs.temporal.io
- opossum (circuit breaker) : https://github.com/nodeshift/opossum
- AWS exponential backoff & jitter : https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter
- Anthropic rate limits : https://docs.anthropic.com/claude/reference/rate-limits
- OpenAI rate limits : https://platform.openai.com/docs/guides/rate-limits
- Toxiproxy : https://github.com/Shopify/toxiproxy
- Adaptive concurrency (Netflix Concurrency Limits) : https://github.com/Netflix/concurrency-limits
- Outbox pattern : https://microservices.io/patterns/data/transactional-outbox.html
- Article FR : "Resilient LLM apps" (Octo Talks)