Patterns asynchrones avancés — primitives de concurrence, retry, circuit breaker, bulkhead
TL;DR — Maîtriser l'asynchrone en Node ne s'arrête pas à
await fetch(). Il faut connaître les combinateurs natifs (Promise.all,allSettled,race,any), savoir capper la concurrence (p-limit,p-map,p-queue), implémenter sémaphores et mutex pour des ressources limitées, coordonner l'annulation viaAbortController, retryer avec backoff exponentiel + jitter, protéger les dépendances externes avec circuit breaker (opossum) et bulkhead, garantir l'idempotence via des clés stables, et préserver des stack traces lisibles malgré les sauts asynchrones. Les pièges classiques :Promise.allqui échoue au premier rejet et perd les autres résultats, des appels concurrents qui saturent un downstream, des retries qui amplifient une panne (thundering herd), desawaitdans des boucles qui sérialisent ce qui devrait être parallèle, et des fonctions async qui avalent silencieusement les erreurs faute deawaitou.catch. Le mental model : chaque opération asynchrone est une promesse de ressource ; la gérer, c'est gérer le temps, l'annulation, l'échec, et la backpressure.
🧠 Mental model — ASCII + analogie
L'asynchrone Node se comporte comme une file de commandes au comptoir d'un fast-food. Chaque promesse est un ticket : on ne sait pas quand il sera prêt, mais on peut attendre, regrouper, annuler, retenter.
Les combinateurs sont les façons d'attendre :
Promise.all([a, b, c]) → Attendre tous OK. Si un fail, tout fail (les autres continuent en background).
Promise.allSettled([a, b, c]) → Attendre tous, succès ou échec, on récupère un tableau de résultats taggés.
Promise.race([a, b, c]) → Le premier qui termine (succès OU échec) gagne.
Promise.any([a, b, c]) → Le premier qui réussit gagne. Si tous fail, AggregateError.
a ──✓──┐
b ──✗──┤
c ──✓──┤
→ all : ✗ (le rejet de b gagne)
→ allSettled: [✓, ✗, ✓]
→ race : premier arrivé (souvent c ou a)
→ any : premier ✓ (a si plus rapide, sinon c)La concurrence sans cap est dangereuse. Imaginez 10 000 fichiers à uploader vers S3 : Promise.all(files.map(upload)) ouvre 10 000 sockets simultanées. Résultat : EMFILE, throttling, mémoire qui explose. La solution = un sémaphore. On limite à N en parallèle, les autres attendent en file.
┌── concurrency cap = 4 ──┐
│ slot 1: ▓▓▓▓▓▓▓▓▓ │
queue → │ slot 2: ▓▓▓▓▓▓ │ → done
│ slot 3: ▓▓▓▓▓▓▓▓▓▓ │
│ slot 4: ▓▓▓▓ │
└─────────────────────────┘Pour les pannes : le circuit breaker est l'analogue électrique. Tant que le downstream répond, le circuit est CLOSED. Après N échecs successifs, on l'ouvre (OPEN) : on rejette immédiatement sans appeler, on évite d'écraser un service déjà à terre. Après un cooldown, on passe en HALF_OPEN : on laisse passer une requête de test. Si elle réussit, retour à CLOSED, sinon retour à OPEN.
CLOSED ──échecs > seuil──→ OPEN
▲ │
│ │ cooldown
succès ▼
└──────── HALF_OPEN ←──────┘L'AbortController est devenu la pierre angulaire de la coordination moderne. Avant son arrivée en standard, chaque lib avait sa propre API d'annulation (request.abort(), controller.cancel(), etc.). Aujourd'hui, le pattern unique est : créer un AbortController, passer son signal à toutes les opérations en aval (fetch, setTimeout, pipeline, wait...), et appeler .abort() pour cascader l'annulation. C'est l'équivalent moderne du context.Context en Go : un mécanisme transversal pour propager des deadlines et des annulations à travers une stack d'appels asynchrones.
Le bulkhead pattern mérite un développement. Le terme vient des cloisons étanches des navires : si un compartiment se remplit, les autres restent secs. En programmation, on isole les pools de ressources par catégorie pour qu'une saturation locale ne contamine pas le reste. Concrètement, en Node : un pool HTTP undici pour les appels paiement, un autre pool pour les appels analytics, un troisième pour le legacy. Si l'API analytics part en cacahuète et que tous les sockets de son pool sont occupés, les paiements continuent à passer normalement. Sans bulkhead, un seul pool global est saturé par les appels lents, et tout le service souffre.
┌── pool: payments (16 sockets) ──→ API critical
request flow ──→ router ┼── pool: analytics (8 sockets) ──→ API best-effort
└── pool: legacy (4 sockets) ─────→ API slowLe circuit breaker est complémentaire. Sans breaker, le bulkhead aide mais ne suffit pas : si l'API analytics est totalement morte, on continue à essayer (et à échouer après timeout) jusqu'à ce que le pool soit saturé. Avec le breaker, on coupe court : après N échecs, on rejette en quelques µs sans même tenter. Combinés, ils donnent un système résilient qui dégrade gracieusement plutôt que de s'effondrer.
🛠️ Code minimal (ts/js)
// combinators.ts — les quatre combinateurs natifs
const a = Promise.resolve(1);
const b = Promise.reject(new Error('boom'));
const c = new Promise((r) => setTimeout(() => r(3), 100));
// all : fail-fast
try {
await Promise.all([a, b, c]); // throws Error('boom')
} catch (e) {
console.error('all failed:', e);
}
// allSettled : on récupère tout
const settled = await Promise.allSettled([a, b, c]);
// [{ status: 'fulfilled', value: 1 }, { status: 'rejected', reason: Error }, { status: 'fulfilled', value: 3 }]
// race : premier arrivé (succès ou échec)
const winner = await Promise.race([a, c]); // 1
// any : premier succès
const firstOk = await Promise.any([b, c]); // 3, ignore b// p-limit.ts — capper la concurrence
import pLimit from 'p-limit';
const limit = pLimit(5); // max 5 en parallèle
const urls = Array.from({ length: 1000 }, (_, i) => `https://api.example.com/items/${i}`);
const results = await Promise.all(
urls.map((url) => limit(() => fetch(url).then((r) => r.json())))
);// semaphore.ts — sémaphore maison
class Semaphore {
private permits: number;
private waiters: Array<() => void> = [];
constructor(initial: number) {
this.permits = initial;
}
async acquire(): Promise<void> {
if (this.permits > 0) {
this.permits--;
return;
}
await new Promise<void>((resolve) => this.waiters.push(resolve));
}
release(): void {
// Handoff direct : si un waiter attend, on lui « passe » le permit sans
// toucher `permits` (il n'a jamais fait permits-- en acquire). On
// n'incrémente que s'il n'y a personne. Inverser l'ordre (permits++ puis
// réveil) ouvrirait une race où un nouvel acquire vole le permit du waiter
// en attente → famine (starvation). FIFO ici = fairness.
const next = this.waiters.shift();
if (next) next();
else this.permits++;
}
async withPermit<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
}
const sem = new Semaphore(3);
await Promise.all(
urls.map((url) => sem.withPermit(() => fetch(url)))
);// abort.ts — annulation coordonnée
async function fetchWithTimeout(url: string, ms: number) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(new Error('timeout')), ms);
try {
return await fetch(url, { signal: ac.signal });
} finally {
clearTimeout(timer);
}
}
// Annulation cascade — un parent abort, tous les enfants abortent
const parent = new AbortController();
const child1 = fetch('/a', { signal: parent.signal });
const child2 = fetch('/b', { signal: parent.signal });
setTimeout(() => parent.abort(), 1000);
const [a, b] = await Promise.allSettled([child1, child2]);// retry-backoff.ts — retry avec backoff exponentiel + jitter
async function retry<T>(
fn: () => Promise<T>,
opts: { retries: number; baseMs: number; capMs: number } = {
retries: 5,
baseMs: 100,
capMs: 5000,
}
): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt < opts.retries; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (attempt === opts.retries - 1) break;
// backoff exponentiel + full jitter
const expo = Math.min(opts.capMs, opts.baseMs * 2 ** attempt);
const delay = Math.random() * expo;
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
}// circuit-breaker.ts — avec opossum
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker(
async (id: string) => fetch(`/api/users/${id}`).then((r) => r.json()),
{
timeout: 3000, // au-delà = échec
errorThresholdPercentage: 50, // 50% d'erreurs sur la fenêtre = OPEN
resetTimeout: 30_000, // attendre 30s avant HALF_OPEN
rollingCountTimeout: 10_000,
rollingCountBuckets: 10,
}
);
breaker.on('open', () => console.warn('circuit OPEN'));
breaker.on('halfOpen', () => console.warn('circuit HALF_OPEN'));
breaker.on('close', () => console.warn('circuit CLOSED'));
// Fallback automatique quand le circuit est ouvert ou échoue
breaker.fallback((id) => ({ id, name: 'unknown', cached: true }));
const user = await breaker.fire('42');// idempotency.ts — clé d'idempotence pour les opérations critiques
import { randomUUID } from 'node:crypto';
async function charge(orderId: string, amount: number) {
const idempotencyKey = `charge:${orderId}`;
const cached = await redis.get(idempotencyKey);
if (cached) return JSON.parse(cached);
const result = await payment.charge({ orderId, amount });
await redis.set(idempotencyKey, JSON.stringify(result), 'EX', 86400);
return result;
}// bulkhead.ts — isoler les pools par catégorie avec undici
import { Agent } from 'undici';
const paymentsPool = new Agent({
connections: 16,
pipelining: 1,
bodyTimeout: 5000,
headersTimeout: 5000,
});
const analyticsPool = new Agent({
connections: 8,
pipelining: 10, // tolère plus de pipelining
bodyTimeout: 30000,
});
const legacyPool = new Agent({
connections: 4, // strictement borné
bodyTimeout: 60000,
});
// Routes critiques utilisent paymentsPool
const result = await fetch('https://payments.example.com/charge', {
dispatcher: paymentsPool,
method: 'POST',
body: JSON.stringify({ amount: 100 }),
});// async-generator.ts — paginer une API sans tout charger
async function* paginatedFetch<T>(
baseUrl: string,
signal?: AbortSignal
): AsyncGenerator<T> {
let cursor: string | null = null;
do {
const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const page = (await res.json()) as { items: T[]; nextCursor: string | null };
for (const item of page.items) yield item;
cursor = page.nextCursor;
} while (cursor);
}
// Usage avec p-limit pour limiter le traitement
import pLimit from 'p-limit';
const limit = pLimit(10);
const tasks: Promise<void>[] = [];
for await (const order of paginatedFetch<Order>('/api/orders')) {
tasks.push(limit(() => processOrder(order)));
}
await Promise.all(tasks);// promise-withresolvers.ts — Node 22+, deferred pattern
function deferred<T>() {
// Avant Node 22 : on créait soi-même resolve/reject
return Promise.withResolvers<T>();
}
const d = deferred<string>();
setTimeout(() => d.resolve('hello'), 100);
const result = await d.promise;🎯 Patterns courants
1. Promise.allSettled par défaut pour les agrégations. Quand on fan-out N appels en parallèle et qu'on veut survivre aux échecs partiels (dashboard, batch ETL), allSettled est presque toujours préférable à all. On agrège ensuite les fulfilled et on logue/alerte sur les rejected. Réserver all aux cas où tout-ou-rien est la sémantique.
2. Promise.any pour des sources redondantes. Trois CDN, trois mirrors, plusieurs DNS resolvers : Promise.any retourne le premier succès et ignore les échecs. Idéal pour des stratégies "best of N".
3. Concurrence cap = sémaphore. p-limit(N) ou p-map(items, fn, { concurrency: N }) quand on parcourt une liste avec un downstream à protéger. Règle empirique : concurrency ≈ downstream_capacity / instances. Si l'API tolère 100 RPS et qu'on a 5 instances, c'est 20 RPS par instance.
4. p-queue pour les priorités. p-queue gère un cap + priorités + intervalles minimaux (rate limiting). Pratique pour respecter une quota par minute sur une API tierce.
5. Mutex pour la section critique. Quand deux flots concurrents modifient un état partagé in-memory (cache, compteur, configuration), un mutex (sémaphore à 1 permit) sérialise. Mais préférer des structures sans verrou (CAS, Map immutable) quand c'est possible.
6. AbortController + signal partout. Toutes les API modernes (fetch, Streams, fs.promises, events.on) acceptent { signal }. On propage le même signal du HTTP request handler jusqu'aux appels DB/HTTP downstream. Si le client se déconnecte, on annule la chaîne entière — gain CPU et mémoire massif sur des endpoints lents.
7. Retry avec backoff exponentiel + jitter. Sans jitter, N clients qui retentent après 1s tombent tous au même moment = thundering herd. Le jitter "full" (random entre 0 et expo) lisse la charge. Toujours capper le délai max (capMs), et limiter les retries (5-7 max). Ne retryer que sur des erreurs transientes (5xx, timeout, ECONNRESET), jamais sur 4xx.
8. Circuit breaker pour les dépendances. Devant chaque appel HTTP/DB externe : opossum ou équivalent. Le breaker absorbe les pannes amont en évitant d'inonder le downstream qui souffre. Fallback obligatoire : valeur par défaut, cache stale, ou erreur métier propre.
9. Bulkhead pattern. Isoler les pools de connexions par catégorie : un pool pour les requêtes critiques (paiement), un autre pour les non-critiques (analytics). Si l'analytics sature, le paiement reste disponible. C'est l'équivalent réseau des cloisons étanches d'un navire.
10. Idempotency keys. Toute opération mutative exposée à du retry doit accepter une clé d'idempotence (UUID côté client). Le serveur la stocke avec le résultat. Un même appel répété renvoie le même résultat sans re-exécuter — protège contre les doubles facturations sur retry réseau.
11. Async generators pour le streaming. async function* permet de produire des éléments à la demande, naturellement compatible avec for await. Idéal pour paginer une API ou consommer un Kafka topic sans tout charger en mémoire.
async function* paginated(base: string) {
let cursor: string | null = null;
do {
const url = cursor ? `${base}?cursor=${cursor}` : base;
const page = await fetch(url).then((r) => r.json());
for (const item of page.items) yield item;
cursor = page.nextCursor;
} while (cursor);
}
for await (const item of paginated('/api/orders')) {
await process(item);
}12. Préserver les stack traces. Depuis Node 12+, les async stack traces sont enrichies par V8 et présentent la chaîne await plutôt qu'un état tronqué. On peut les booster avec --async-stack-traces (activé par défaut) et Error.stackTraceLimit = 50. Ne jamais faire throw new Error('foo') à l'intérieur d'un setTimeout ou d'un setImmediate sans capturer le stack en amont.
13. AsyncLocalStorage pour le contexte par requête. node:async_hooks expose AsyncLocalStorage, qui permet de propager du contexte (trace_id, user_id, locale) à travers une chaîne async sans le passer en paramètre. C'est l'équivalent de ThreadLocal en Java mais pour les contextes async. Coût négligeable (< 1 %) avec les améliorations Node 22+, indispensable pour le tracing distribué et l'observabilité.
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage<{ traceId: string; userId?: string }>();
app.use((req, res, next) => {
requestContext.run({ traceId: req.headers['x-trace-id'] ?? randomUUID() }, () => next());
});
// N'importe où dans la chaîne async
function log(msg: string) {
const ctx = requestContext.getStore();
console.log({ msg, traceId: ctx?.traceId });
}14. Fan-out / fan-in avec join structure. Pattern Go-like : on lance N tâches concurrentes avec un join à la fin. En JS, c'est Promise.allSettled enrichi d'un timeout global. Pour des arbres de tâches complexes (parent attend ses enfants, qui attendent leurs propres enfants), on construit récursivement avec await Promise.all(children.map(processNode)).
15. Race avec timeout idiomatique. Promise.race([fetch(...), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000))]) est le pattern classique mais peu propre (le timer n'est pas annulé si fetch gagne). Le pattern moderne est AbortSignal.timeout(5000) passé directement à fetch — le signal est annulé automatiquement quand la promesse résoud.
🔄 Versions — Node 18 / 20 / 22 / 24
Node 18. AbortController et AbortSignal.timeout(ms) stables ; AbortSignal.any([signals]) arrive un peu plus tard (Node 20.3, rétroporté en 18.17). fetch global expérimental, accepte signal. Promise.any et AggregateError disponibles depuis longtemps. EventTarget global.
Node 20. AbortSignal.timeout partout. node:test stable avec helpers de mocking pour timers (mock.timers.enable()). Améliorations sur les async stack traces — moins de frames manquantes sur les await dans les Promise.all.
Node 22. fetch stable. WebSocket global expérimental. Promise.withResolvers() qui retourne { promise, resolve, reject } (utile pour les patterns deferred). Iterator.from(...) et helpers d'itérateur sync, qui se combinent bien avec for await.
Node 24. Promise.try(fn) qui exécute fn et retourne une promesse même si fn throw sync — élimine les try/catch redondants autour de fonctions mixtes sync/async. Performances V8 améliorées sur les await chains (microtask scheduling). AsyncLocalStorage plus performant et avec des hooks supplémentaires pour le contexte de trace.
// Promise.try uniformise le chemin d'erreur sync ET async dans la même promesse
function parse(input: string) {
if (!input) throw new SyntaxError('empty'); // throw synchrone
return JSON.parse(input); // peut throw sync aussi
}
// Sans Promise.try : il faut un try/catch AVANT l'await pour le throw sync.
// Avec : tout passe par la chaîne de promesse, un seul .catch suffit.
const data = await Promise.try(() => parse(raw)).catch((e) => {
log('parse failed', e);
return fallback;
});Sur les API tierces : axios reste largement utilisé mais avec un overhead notable vs fetch natif ; undici (le client HTTP sous-jacent à fetch) expose un Agent configurable pour le pool et les keep-alive. Pour la concurrence : p-limit, p-map, p-queue (Sindre Sorhus) sont des standards de facto.
⚠️ Pitfalls — 6-10
1. await dans une boucle qui devrait être parallèle. for (const x of items) await fn(x) sérialise. Si l'ordre n'importe pas, await Promise.all(items.map(fn)) parallélise — avec un cap si nécessaire.
2. Promise.all sur une liste énorme. 10 000 promesses concurrentes saturent le downstream et la mémoire. Toujours capper avec p-limit au-delà de quelques dizaines d'appels.
3. Promesses créées mais jamais awaited. Une promesse rejetée non capturée → unhandledRejection. En Node 16+, le default est throw qui crash le process. Toujours await ou .catch(...). Les fonctions async qu'on fire-and-forget doivent au minimum logger leurs erreurs.
4. Retry sur erreurs non-transientes. Retryer un 400 ou un 422 ne change rien — c'est gaspiller du temps et masquer des bugs métier. Whitelist : 5xx, timeout, ECONNRESET, ETIMEDOUT.
5. Circuit breaker sans fallback. Un breaker ouvert sans fallback renvoie une erreur claire mais n'aide pas. Toujours définir un fallback : cache stale, valeur par défaut, erreur métier explicite.
6. Thundering herd au retry. Sans jitter, tous les clients retentent au même moment. Avec jitter ("full" ou "decorrelated"), la charge se lisse sur l'intervalle de retry.
7. Mutex maison bogué. Un sémaphore mal écrit peut deadlock (release oublié) ou perdre des permits. Préférer async-mutex ou un pattern fonctionnel avec try/finally strict.
8. Mélange await + .then. await p.then(x => ...) est confus et perd de la stack trace. Choisir un style et s'y tenir — await partout dans le code applicatif moderne.
9. AbortController pas propagé. On reçoit un signal au handler HTTP mais on ne le passe pas aux appels DB/HTTP downstream. Quand le client coupe, le serveur continue à consommer des ressources inutilement.
10. Idempotency key mal stockée. TTL trop court = retry tardif refait l'opération. TTL trop long = saturation Redis. Choisir un TTL aligné avec la fenêtre de retry max du client (typiquement 24h).
11. Async function utilisée comme listener. emitter.on('data', async (chunk) => await save(chunk)) est tentant mais piégeux : si save() reject, l'erreur n'est pas capturée par l'EventEmitter (qui ne sait gérer que des throws synchrones). Le résultat : unhandledRejection. Toujours wrapper : emitter.on('data', (chunk) => { save(chunk).catch(handleErr) }).
12. AbortSignal créé mais jamais propagé. On crée un AbortController dans le handler, mais on ne passe pas son signal aux appels descendants. Résultat : le contrôleur n'a aucun effet, l'annulation ne cascade pas.
13. Trop de microtasks empilées. Une boucle qui fait for (...) await x avec des x qui résoudent vite (cache hit) empile des milliers de microtasks sur le scheduler. Préférer un for synchrone si possible, ou setImmediate toutes les N itérations pour donner l'air à la boucle d'I/O.
14. Confondre setImmediate, setTimeout(fn, 0) et process.nextTick. Trois APIs proches mais différentes : process.nextTick exécute avant le prochain tick (très tôt, peut starve l'I/O si abusé), setImmediate après l'I/O courant, setTimeout(fn, 0) après le délai minimum du timer (~1 ms). Connaître la différence pour bien céder le contrôle.
🧪 Testing — node --test, benchmarks
// tests/retry.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { retry } from '../src/retry.js';
test('retry succeeds after 2 failures', async () => {
let attempts = 0;
const fn = async () => {
attempts++;
if (attempts < 3) throw new Error('transient');
return 'ok';
};
const result = await retry(fn, { retries: 5, baseMs: 1, capMs: 10 });
assert.equal(result, 'ok');
assert.equal(attempts, 3);
});
test('retry gives up after max attempts', async () => {
const fn = async () => {
throw new Error('permanent');
};
await assert.rejects(() => retry(fn, { retries: 3, baseMs: 1, capMs: 10 }));
});// tests/circuit-breaker.test.ts
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
test('breaker opens after threshold', async () => {
const failing = async () => {
throw new Error('down');
};
const breaker = new CircuitBreaker(failing, {
errorThresholdPercentage: 50,
volumeThreshold: 5,
resetTimeout: 100,
});
for (let i = 0; i < 10; i++) {
await breaker.fire().catch(() => {});
}
assert.equal(breaker.opened, true);
});# Bench — comparer concurrence sans cap vs avec cap
node bench/concurrency.js
# Avec autocannon pour mesurer la latence p99 sous load
npx autocannon -c 100 -d 30 -R 1000 http://localhost:3000/usersPour stress-tester des combinateurs, simuler des latences variables avec setTimeout(resolve, Math.random() * 100) et vérifier l'ordre et le timing. mock.timers de node:test permet de tester les délais sans attendre.
// tests/abort.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('AbortController cancels downstream calls', async () => {
const ac = new AbortController();
const promise = new Promise((_, reject) => {
ac.signal.addEventListener('abort', () => reject(new Error('aborted')));
});
setTimeout(() => ac.abort(), 10);
await assert.rejects(promise, /aborted/);
});
test('Promise.allSettled resolves with mixed results', async () => {
const results = await Promise.allSettled([
Promise.resolve(1),
Promise.reject(new Error('fail')),
Promise.resolve(3),
]);
assert.equal(results[0].status, 'fulfilled');
assert.equal(results[1].status, 'rejected');
assert.equal(results[2].status, 'fulfilled');
});// tests/p-limit.test.ts — vérifier le cap effectif
import { test } from 'node:test';
import assert from 'node:assert/strict';
import pLimit from 'p-limit';
test('p-limit caps concurrent executions', async () => {
const limit = pLimit(3);
let active = 0;
let maxActive = 0;
const tasks = Array.from({ length: 100 }, (_, i) =>
limit(async () => {
active++;
maxActive = Math.max(maxActive, active);
await new Promise((r) => setTimeout(r, 5));
active--;
return i;
})
);
await Promise.all(tasks);
assert.equal(maxActive, 3, `max concurrent was ${maxActive}, expected 3`);
});Pour tester un circuit breaker, on injecte une fonction qui contrôle ses succès/échecs, on appelle N fois pour franchir le seuil, et on vérifie que les appels suivants sont rejetés instantanément (sans appeler le downstream). Avec mock.timers de node:test, on peut avancer le temps virtuel pour observer la transition vers HALF_OPEN sans attendre le cooldown réel.
🎬 Cas d'usage concrets
Scénario 1 — Crawl listings immo avec concurrence limitée
Plateforme immo qui re-crawle ses 80 000 annonces partenaires chaque jour pour vérifier disponibilité et mettre à jour les photos. Un Promise.all naïf sur 80 000 fetch = explosion mémoire, ban immédiat par les portails, saturation DNS.
Pattern : queue de concurrence limitée à 16 (équilibre throughput vs respect du partenaire). Pour chaque URL, fetch avec timeout 8 s, retry exponentiel (2 essais max), erreur → marquer en stale plutôt que crasher. La queue avance par Promise.race interne qui débloque un slot dès qu'un fetch termine (succès ou échec).
Bénéfice : 80 000 URLs traitées en 1h45 sans throttling, mémoire stable à 180 MB (vs OOM avec Promise.all). Avantage clé : on peut prioriser certains partenaires en injectant en tête de queue, sans changer la mécanique.
Scénario 2 — Batch traitement contrats cabinet juridique
Cabinet d'affaires qui re-classifie tous les contrats du fonds documentaire (200 000 contrats) après mise à jour du modèle de classification. Chaque contrat = 1 appel OpenAI (embedding + classification) + 1 write Postgres + 1 update Elasticsearch.
Promise.allSettled par batch de 50, avec p-limit(8) sur l'intérieur de chaque batch. Si OpenAI 429 → Retry-After, retry. Si erreur permanente (contrat corrompu) → log + dead letter queue Postgres. Le job persiste un curseur (last_processed_id) toutes les 500 unités pour reprise après crash sans repartir de zéro.
Concrètement : ce qui aurait été 200 000 × 800 ms = 44 h en sériel devient 5h30 en concurrence 8 + respect des rate limits OpenAI.
Scénario 3 — Ingestion factures Pennylane avec retry idempotent
Service de comptabilité qui sync les factures depuis Pennylane vers le SI client (Sage). 5000 factures/jour. Chaque facture = GET pennylane/invoices/:id + POST sage/invoices. Le POST Sage est idempotent grâce à un external_id unique côté Pennylane.
Pattern : Promise.allSettled sur tout le batch (on veut le rapport complet, pas s'arrêter au premier échec), avec circuit breaker sur Sage (si > 50 % d'échecs sur les 60 dernières secondes, on coupe et on alerte Slack). Backoff exponentiel + jitter sur les retries individuels. Le correlationId est propagé via AsyncLocalStorage pour tracer chaque facture de bout en bout dans les logs.
L'idempotence via external_id permet de re-rejouer la nuit suivante les factures en échec sans risque de doublons côté Sage — le SI rejette les external_id déjà connus avec un 409, intercepté et traité comme succès.
🛠️ Exemple end-to-end
Crawler concurrence-limitée avec retry exponentiel, timeout par appel, circuit breaker simple, et reporting structuré. Le tout sans dépendance externe (pas de p-limit).
import { setTimeout as delay } from "node:timers/promises";
import { performance } from "node:perf_hooks";
type Task<T> = () => Promise<T>;
class Semaphore {
private queue: Array<() => void> = [];
private inUse = 0;
constructor(private max: number) {}
async acquire() {
if (this.inUse < this.max) {
this.inUse++;
return;
}
await new Promise<void>((r) => this.queue.push(r));
this.inUse++;
}
release() {
this.inUse--;
this.queue.shift()?.();
}
}
class Breaker {
private failures: number[] = [];
private openUntil = 0;
constructor(private threshold = 10, private windowMs = 60_000, private cooldownMs = 30_000) {}
canCall() {
const now = Date.now();
if (now < this.openUntil) return false;
this.failures = this.failures.filter((t) => now - t < this.windowMs);
return this.failures.length < this.threshold;
}
recordFailure() {
this.failures.push(Date.now());
if (this.failures.length >= this.threshold) this.openUntil = Date.now() + this.cooldownMs;
}
recordSuccess() {
this.failures = [];
}
}
async function withRetry<T>(task: Task<T>, attempts = 3, baseMs = 100): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await task();
} catch (err) {
lastErr = err;
if (i < attempts - 1) {
const jitter = Math.random() * baseMs;
await delay(baseMs * 2 ** i + jitter);
}
}
}
throw lastErr;
}
async function fetchListing(url: string): Promise<{ url: string; status: number }> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 8000);
try {
const res = await fetch(url, { signal: ac.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { url, status: res.status };
} finally {
clearTimeout(t);
}
}
async function crawl(urls: string[]) {
const sem = new Semaphore(16);
const breaker = new Breaker(20, 60_000, 30_000);
let ok = 0;
let failed = 0;
let circuitDropped = 0;
const t0 = performance.now();
await Promise.allSettled(
urls.map(async (url) => {
await sem.acquire();
try {
if (!breaker.canCall()) {
circuitDropped++;
return;
}
await withRetry(() => fetchListing(url), 3, 200);
breaker.recordSuccess();
ok++;
} catch {
breaker.recordFailure();
failed++;
} finally {
sem.release();
}
})
);
const elapsedMs = performance.now() - t0;
return { ok, failed, circuitDropped, total: urls.length, elapsedMs };
}
// usage
const urls = Array.from({ length: 1000 }, (_, i) => `https://partner.example/listing/${i}`);
console.log(await crawl(urls));Points clés : Semaphore maison borne la concurrence, Breaker coupe la pression sur un downstream qui souffre, withRetry exponentiel + jitter évite le thundering herd, Promise.allSettled collecte tous les résultats pour reporting, AbortController cap chaque fetch à 8 s.
🔁 Quand utiliser / éviter
Utiliser Promise.all quand : tout-ou-rien est la sémantique métier (transaction distribuée légère, validation parallèle).
Utiliser Promise.allSettled quand : on tolère des échecs partiels (agrégation, dashboard, batch).
Utiliser Promise.race quand : on veut un timeout ou un fallback rapide (Promise.race([fetch, timeoutPromise])).
Utiliser Promise.any quand : on a N sources redondantes et on prend la première qui répond OK.
Utiliser p-limit/p-map quand : on a > 20 appels en parallèle vers une même ressource.
Utiliser un sémaphore maison quand : on a besoin de finesse (priorités, fairness, métriques).
Utiliser un circuit breaker quand : on appelle un service externe en chemin critique d'une requête utilisateur.
Éviter circuit breaker sur : appels internes en process (overhead inutile), opérations idempotentes pas critiques.
Utiliser le retry quand : l'erreur est transiente, l'opération est idempotente ou protégée par une clé d'idempotence.
Éviter le retry quand : l'opération mute un état sans idempotency key, ou l'erreur est métier (4xx).
Utiliser AsyncLocalStorage quand : on propage du contexte (trace, user, locale) à travers une chaîne async sans le passer en argument.
Éviter AsyncLocalStorage pour : du state mutable partagé entre requêtes (c'est un anti-pattern et un bug en série).
Utiliser async generators quand : on consomme un flux paginé, un Kafka topic, un fichier ligne par ligne, ou toute source de données dont la taille n'est pas connue à l'avance.
Éviter async generators quand : la donnée tient en mémoire et qu'on traite en batch — un Promise.all sur un array est plus simple et plus rapide.
Critères de dimensionnement. Pour p-limit, capper à downstream_capacity / instances_count × 0.7 (la marge 0.7 évite de saturer). Pour les circuit breakers, seuil typique 50 % d'erreurs sur fenêtre de 10s, cooldown 30s, volume threshold 10 (ne pas ouvrir sur 1 erreur si on n'a fait que 1 appel). Pour les retries, max 5 tentatives, base 100 ms, cap 5 s, jitter full. Ces nombres sont des points de départ — ajuster selon les SLO et les métriques observées.
📊 Tableau de décision — combinateurs et primitives
Choisir le bon outil est 80 % du travail. Ce tableau condense le raisonnement d'un staff engineer face à une agrégation asynchrone.
| Primitive | Sémantique | Échoue si | Annule les autres ? | Coût mémoire | Cas type |
|---|---|---|---|---|---|
Promise.all | tout-ou-rien | 1 rejet (fail-fast) | non (les autres tournent en background, fuite de ressources possible) | N résultats | validation parallèle, transaction légère |
Promise.allSettled | tout, taggé | jamais | non | N résultats (gardés) | agrégation, dashboard, batch ETL |
Promise.race | premier réglé | premier = rejet | non | 1 résultat | timeout, fallback rapide |
Promise.any | premier succès | tous rejettent → AggregateError | non | 1 résultat | sources redondantes (mirrors, CDN) |
Sémaphore / p-limit | cap concurrence | propagé par tâche | non | file d'attente | fan-out borné vers un downstream |
| Circuit breaker | court-circuite | seuil d'échec franchi | n/a | fenêtre glissante | protéger une dépendance critique |
| Async generator | flux paresseux | par item | via signal / return() | O(1) par item | pagination, Kafka, fichier ligne-à-ligne |
Le piège mémoire de Promise.all à retenir. Sur fail-fast, all rejette dès le premier échec, mais ne stoppe pas les promesses en cours : elles continuent jusqu'au bout, consommant sockets/CPU, et si elles rejettent à leur tour, ce sont des unhandledRejection. La parade staff : passer un AbortController partagé et ac.abort() dans le catch du all pour cascader l'annulation aux frères. C'est exactement ce que fait p-map avec son option signal / stopOnError.
const ac = new AbortController();
try {
await Promise.all(urls.map((u) => fetch(u, { signal: ac.signal })));
} catch (e) {
ac.abort(); // coupe les fetch encore en vol — sinon ils traînent
throw e;
}🧮 Modèle de coût — comment un staff raisonne sur la concurrence
Avant d'écrire concurrency: N, dérive N depuis une contrainte, pas un nombre magique :
- Borné par le downstream (cas le plus fréquent).
N ≈ (RPS_max_downstream / instances) × 0.7. La marge 0.7 absorbe les pics et le retry. Une API à 100 RPS, 5 instances → ~14 par instance. - Borné par la latence (loi de Little). Débit = concurrence / latence. Pour soutenir 500 req/s avec une latence downstream de 200 ms :
N = 500 × 0.2 = 100requêtes en vol. Si ton cap est inférieur, tu plafonnes le débit ; supérieur, tu bourres une file. - Borné par les fds/mémoire local. Chaque socket ≈ un fd + buffers. Au-delà de quelques milliers,
EMFILE(ajusterulimit -n) ou pression GC. Le cap protège ta machine autant que le downstream. - CPU-bound déguisé. Si chaque "tâche async" fait du JSON.parse de 5 MB, augmenter la concurrence ne sert à rien : l'event loop est mono-thread, tu sérialises sur le CPU. Mesure
event loop delay(perf_hooks.monitorEventLoopDelay()) ; si elle grimpe, déporte sur unworker_threadsou un Piscina pool.
Règle de décision : si tu ne peux pas nommer la ressource que tu protèges, tu n'as pas besoin d'un cap — tu as besoin d'une métrique.
🔬 Modes de défaillance subtils
Le sémaphore non-réentrant qui deadlock. Un withPermit qui, à l'intérieur, rappelle withPermit sur le même sémaphore avec un cap atteint : le permit interne attend qu'un permit se libère, mais le seul détenteur est le frame qui attend. Deadlock. Les sémaphores ne sont pas réentrants — ne jamais imbriquer l'acquisition du même verrou.
Le finally qui ne libère pas sur abort synchrone. Si acquire() peut throw (signal déjà aborté avant l'await), le try/finally doit englober l'acquisition correctement, sinon on libère un permit jamais acquis (inUse devient négatif). Pattern sûr : acquérir hors du try, ne mettre dans le finally que le release correspondant à un acquire réussi.
Backoff sans plafond de file. Retry + backoff sur une file de 100k items qui échouent tous : chaque item dort puis revient, la mémoire de la file ne se vide jamais (rétention des closures + timers). Cap la profondeur de retry ET la taille de file ; au-delà, dead-letter immédiat.
Circuit breaker en HALF_OPEN sous charge. En half-open, beaucoup d'implémentations laissent passer une seule requête de sonde. Si ton trafic est élevé, des milliers de requêtes arrivent pendant la sonde : elles doivent toutes être rejetées rapidement, pas mises en file (sinon thundering herd à la réouverture). opossum gère ça ; un breaker maison doit l'implémenter explicitement.
Jitter "full" vs "decorrelated". Le full jitter (random(0, expo)) peut, par malchance, redonner un délai quasi-nul et re-cogner immédiatement. Le decorrelated jitter (sleep = min(cap, random(base, sleep_prev × 3))) garde une mémoire du délai précédent et lisse mieux sous forte contention. Pour un downstream fragile, préfère le decorrelated.
// decorrelated jitter (AWS) — souvent supérieur au full jitter sous contention
let sleep = baseMs;
let lastErr: unknown;
for (let attempt = 0; attempt < retries; attempt++) {
try { return await fn(); }
catch (e) {
lastErr = e;
if (attempt === retries - 1) break;
// borne basse = baseMs, borne haute = sleep_prev × 3, plafonnée à capMs
sleep = Math.min(capMs, Math.floor(Math.random() * (sleep * 3 - baseMs)) + baseMs);
await delay(sleep);
}
}
throw lastErr;🏋️ Exercices
Progression : implémenter → production-grade → casser puis réparer. Fais-les dans l'ordre, chacun construit sur le précédent.
Exercice 1 — mapWithConcurrency sans dépendance (implémenter)
Objectif : écrire mapWithConcurrency<T, R>(items, fn, concurrency, { signal? }) qui borne la concurrence, préserve l'ordre des résultats, et propage l'annulation. Indice/Solution : un pointeur partagé let i = 0, lance concurrency "workers" qui font une boucle while (i < items.length) en consommant const idx = i++, écrivent dans results[idx]. Vérifie signal.aborted en tête de boucle et signal.addEventListener('abort', ...) pour rejeter en vol. Test : 1000 items, cap 5, asserter maxActive === 5 (cf. test p-limit plus haut).
Exercice 2 — Retry idempotent avec budget global (production-grade)
Objectif : enrichir retry() avec (a) classification d'erreurs (ne retry que 5xx/ECONNRESET/timeout), (b) respect du header Retry-After quand présent, (c) un retry budget partagé (token bucket) qui plafonne le total de retries/seconde sur tout le service pour éviter d'amplifier une panne. Indice/Solution : isRetryable(err) qui matche err.status/err.code. Pour Retry-After, parse secondes ou date HTTP et override le backoff calculé. Le budget : un compteur global rechargé à R tokens/s ; si vide → ne pas retry, échouer direct. C'est le pattern "retry budget" de gRPC/Envoy — sans lui, 1000 clients qui retry 5× transforment une panne 1× en panne 5×.
Exercice 3 — Circuit breaker maison avec les 3 états (production-grade)
Objectif : implémenter un breaker CLOSED → OPEN → HALF_OPEN → CLOSED avec fenêtre glissante (rolling window), volumeThreshold (ne pas ouvrir sur < K appels), sonde unique en half-open, et hook de métriques. Indice/Solution : stocker les outcomes récents dans un ring buffer horodaté ; errorRate = échecs / total sur la fenêtre. En OPEN, canCall() renvoie false jusqu'à openUntil. En HALF_OPEN, un flag probeInFlight : la première requête passe, les autres sont rejetées immédiatement. Succès de la sonde → CLOSED et reset ; échec → OPEN + nouveau cooldown. Émettre un event à chaque transition. Test avec mock.timers de node:test pour avancer le temps virtuel sans attendre le cooldown.
Exercice 4 — Casser puis réparer : la fuite de Promise.all (break-then-fix)
Objectif : reproduire la fuite de ressources de Promise.all fail-fast, l'observer, puis la corriger. Indice/Solution : crée 5 tâches dont une rejette à t=10ms et les autres résolvent à t=5000ms en incrémentant un compteur global sideEffects. await Promise.all(...) throw à t=10ms ; pourtant, attends 6s et constate que sideEffects a augmenté → les "perdantes" ont continué. Réparation : AbortController partagé + ac.abort() dans le catch, et chaque tâche vérifie signal pour s'arrêter. Assert : après le fix, sideEffects === 0.
Exercice 5 — Bulkhead + breaker sous panne injectée (break-then-fix, hard)
Objectif : monter deux pools undici (payments 16 / analytics 8), un serveur de test qui rend l'endpoint analytics infiniment lent (timeout), et démontrer que sans bulkhead un pool global se sature et fait tomber les paiements ; avec bulkhead + breaker, les paiements gardent leur latence p99. Indice/Solution : http.createServer avec une route /analytics qui ne répond jamais (setTimeout(() => {}, 1e9)) et /charge qui répond en 5ms. Lance 200 requêtes analytics + 200 charges concurrentes. Mesure la p99 des charges via perf_hooks. Cas global : un seul Agent partagé → p99 charges explose (file bloquée par les analytics morts). Cas bulkhead : Agent séparés + breaker sur analytics qui s'ouvre après N timeouts → p99 charges reste plate. C'est la démonstration empirique de la section mental model.
Exercice 6 — AsyncLocalStorage qui perd le contexte (break-then-fix, hard)
Objectif : trouver pourquoi un traceId disparaît au milieu d'une chaîne async, puis le réparer. Indice/Solution : casser le contexte en passant par une API qui rompt la chaîne async — un EventEmitter dont le listener est enregistré hors du run(), ou un pool de promesses créées avant le run(). getStore() renvoie undefined. Comprendre que l'ALS suit la chaîne de création des continuations, pas l'appelant au moment T. Réparation : enregistrer les callbacks à l'intérieur du run(), ou capturer le store et le re-injecter via als.run(captured, cb). Vérifie aussi que queueMicrotask/await préservent le contexte (oui) mais qu'un callback stocké et rappelé plus tard ne le fait pas.
🎤 En entretien
Q : Pourquoi Promise.all peut-il fuiter des ressources, et comment l'éviter ? R : Sur le premier rejet il rejette (fail-fast) mais ne stoppe pas les promesses sœurs — elles continuent à consommer sockets/CPU et leurs rejets deviennent unhandledRejection. Parade : AbortController partagé, abort() dans le catch, ou allSettled si on veut juste collecter sans annuler.
Q : Différence entre un sémaphore, un mutex et un circuit breaker — ils limitent tous quelque chose ? R : Le sémaphore borne la concurrence (N permits, régulation de débit). Le mutex est un sémaphore à 1 permit, il sérialise une section critique (correction). Le breaker borne les appels vers un downstream en panne (résilience) en court-circuitant après un seuil d'échec — il ne limite pas le débit nominal, il coupe sous panne. Trois axes : débit, exclusion, protection.
Q : Comment empêcher un retry d'amplifier une panne (retry storm) ? R : Trois leviers cumulés : backoff exponentiel + jitter (idéalement decorrelated) pour désynchroniser, un retry budget global (token bucket) qui plafonne le ratio retries/requêtes à l'échelle du service, et un circuit breaker qui coupe la source avant qu'elle ne génère des retries inutiles. Et ne retry que les erreurs transientes sur des opérations idempotentes.
Q : AsyncLocalStorage — comment ça marche et quand le contexte se perd ? R : Construit sur async_hooks, il attache un store à la chaîne de continuations async (init/resolve hooks). await, then, queueMicrotask, setTimeout propagent le contexte. Il se perd quand on franchit une frontière qui casse la chaîne : callback enregistré hors du run(), pool d'objets/connexions créé avant, ou code natif qui ne traverse pas async_hooks. C'est l'équivalent async du ThreadLocal, avec un coût < 1 % depuis Node 22.
🔗 Liens
- MDN Promise — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
- p-limit — https://github.com/sindresorhus/p-limit
- p-map — https://github.com/sindresorhus/p-map
- p-queue — https://github.com/sindresorhus/p-queue
- async-mutex — https://github.com/DirtyHairy/async-mutex
- opossum (circuit breaker) — https://github.com/nodeshift/opossum
- AWS — Exponential backoff and jitter — https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- Stripe — Idempotency in APIs — https://stripe.com/docs/api/idempotent_requests
- Hystrix patterns (Netflix tech blog, historique mais référence) — https://github.com/Netflix/Hystrix/wiki
Récapitulatif
Quatre combinateurs natifs (all, allSettled, race, any) couvrent 80 % des besoins ; allSettled est le défaut sain pour les agrégations. La concurrence sans cap est la première cause de saturation downstream — p-limit et compagnie cappent. Le retry doit utiliser backoff exponentiel + jitter et ne s'appliquer qu'à des erreurs transientes sur des opérations idempotentes. Le circuit breaker protège les dépendances et les utilisateurs en s'ouvrant avant que le downstream ne s'effondre, toujours avec un fallback. Le bulkhead isole les pools pour éviter qu'une catégorie ne contamine les autres. AbortController propage l'annulation de bout en bout — c'est la couche transversale moderne qui économise CPU et latence. Et l'idempotency key est la pièce manquante qui rend les retries sûrs côté écriture. Le tout doit être instrumenté : sans métriques (taux d'ouverture du breaker, taille de la file, temps en attente du sémaphore), on pilote à l'aveugle.
Sur l'instrumentation, exposer en Prometheus au minimum : circuit_breaker_state{name} (gauge 0/1/2 pour closed/half_open/open), circuit_breaker_failures_total, circuit_breaker_success_total, retry_attempts_histogram, semaphore_queue_size, semaphore_wait_seconds. Côté logs, taguer chaque tentative de retry avec attempt_number et le delay_ms calculé, ça aide énormément à diagnostiquer les patterns de thundering herd. Avec OpenTelemetry, créer des spans pour chaque tentative de retry permet de visualiser la cascade dans Jaeger/Tempo.
Sur le modèle mental global : penser le système async comme un graphe de dépendances temporelles. Chaque nœud est une promesse, chaque arête est un await ou une dépendance entre opérations. Les combinateurs all/any/race créent des structures de jonction. Le sémaphore est un nœud de régulation. Le breaker est un nœud de protection. L'AbortController est un signal qui se propage en amont depuis le client jusqu'aux feuilles du graphe. Quand on conçoit un endpoint, dessiner ce graphe avant de coder évite 80 % des bugs de concurrence.
En 2026, la combinaison gagnante pour un service Node moderne est : fetch natif avec undici Agent custom (bulkhead), p-limit/p-queue pour les bornes de concurrence, opossum pour les circuit breakers, AsyncLocalStorage pour le contexte de trace, OpenTelemetry pour l'observabilité, et node:test pour les tests avec mock.timers pour le contrôle temporel. Le tout sans framework lourd — Node propose désormais nativement ce qu'il fallait empiler de libs il y a 5 ans.