fetch natif, AbortController et undici en Node.js
TL;DR — Depuis Node 18,
fetch,Request,Response,Headers,FormDataetBlobsont natifs et stables, propulsés par undici. Le code client devient identique à celui du navigateur. Pour la résilience en production, tu combinesAbortSignal.timeout(ms)(Node 17.3+) avecAbortSignal.any([...])(Node 20+) pour composer des signaux (timeout par requête + annulation utilisateur + circuit breaker). Pour la perf, tu instancies unundici.Agentavec pooling et keep-alive, tu désactives le pipelining HTTP/1.1, et tu actives l'auto-retry sur les méthodes idempotentes. Les pièges principaux : body consommé une seule fois, fuite de socket si on n'abort pas correctement, et confusion entresignal.abortedetsignal.reason.
🧠 Mental model — ASCII + analogie
fetch est une commande dans un restaurant : tu passes commande (Request), un serveur (le runtime) la transmet en cuisine (le réseau), et tu reçois un ticket (Response) que tu peux consommer une seule fois. AbortController est ton bouton "annuler" — il propage un signal à travers toute la chaîne (DNS, TCP, TLS, attente du body) pour relâcher les ressources. AbortSignal.any([...]) est l'équivalent d'un "OU logique" entre plusieurs raisons d'annuler (timeout global, timeout per-try, fermeture de l'app, désabonnement utilisateur).
┌──────────────────────────────────────────────┐
│ Application Node.js │
│ │
user code │ fetch(url, { signal, dispatcher }) │
──────────▶ │ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ undici Dispatcher (Agent / Pool) │ │
│ │ ├── Pool d'origines (host:port) │ │
│ │ │ └─ Client (1+ sockets) │ │
│ │ ├── keep-alive timer │ │
│ │ ├── retry interceptor (optionnel) │ │
│ │ └── mock interceptor (en test) │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ net.Socket / tls.TLSSocket (libuv) │
└──────────────────────────────────────────────┘
AbortSignal flow :
AbortSignal.timeout(5000) ─┐
userCancelSignal ─┼─ AbortSignal.any([...]) ──▶ fetch(req, { signal })
appShutdownSignal ─┘ │
│ abort()
▼
┌─ DNS lookup cancel ──┐
├─ TCP connect cancel │ ─▶ rejette
├─ TLS handshake cancel│ avec
├─ socket.destroy() │ AbortError
└─ body stream cancel ─┘Mentalement, retiens trois choses : (1) fetch retourne dès que les en-têtes sont reçus, pas la fin du body — pour récupérer le corps tu appelles .json(), .text(), .arrayBuffer(), ou tu streames avec response.body (ReadableStream) ; (2) la propagation d'AbortSignal est end-to-end : annuler à 100 ms doit fermer le socket même si la réponse n'a pas commencé ; (3) un Agent (undici.Agent) gère un pool de connexions par origine — c'est l'objet à long terme à instancier une fois.
🛠️ Code minimal (ts/js)
// 1. fetch basique avec timeout et signal composé
const r = await fetch("https://api.example.com/v1/data", {
signal: AbortSignal.timeout(5_000), // throws après 5s
headers: { accept: "application/json" },
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = (await r.json()) as { id: string };
// 2. Composition de signaux (Node 20+)
const userCtrl = new AbortController();
const shutdownCtrl = new AbortController();
const combined = AbortSignal.any([
userCtrl.signal,
shutdownCtrl.signal,
AbortSignal.timeout(30_000),
]);
const r2 = await fetch(url, { signal: combined });
// 3. Streaming du body sans tout charger en mémoire
const r3 = await fetch("https://example.com/large.ndjson");
if (!r3.body) throw new Error("no body");
const reader = r3.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let idx: number;
while ((idx = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (line.trim()) handleEvent(JSON.parse(line));
}
}// 4. undici.Agent avec pooling explicite
import { Agent, fetch, setGlobalDispatcher } from "undici";
const agent = new Agent({
connect: { timeout: 10_000 }, // TCP+TLS handshake
connections: 64, // par origine
pipelining: 1, // 1 = pas de pipelining (recommandé)
keepAliveTimeout: 30_000,
keepAliveMaxTimeout: 600_000,
headersTimeout: 30_000,
bodyTimeout: 60_000,
maxRedirections: 3,
});
setGlobalDispatcher(agent); // tous les fetch() utilisent ce pool
// Surcharge ponctuelle :
await fetch(url, {
// @ts-expect-error — extension undici non standardisée
dispatcher: new Agent({ connections: 1 }),
});// 5. Retry idempotent avec backoff exponentiel + jitter
type FetchOpts = RequestInit & { tries?: number; baseDelayMs?: number };
async function fetchRetry(url: string, opts: FetchOpts = {}): Promise<Response> {
const tries = opts.tries ?? 4;
const base = opts.baseDelayMs ?? 200;
let lastErr: unknown;
for (let attempt = 0; attempt < tries; attempt++) {
try {
// Ne compose que les signaux réellement présents : un AbortController
// jamais aborté garderait inutilement un listener vivant.
const perTry = AbortSignal.timeout(10_000);
const signal = opts.signal
? AbortSignal.any([opts.signal, perTry])
: perTry;
const r = await fetch(url, { ...opts, signal });
// 5xx et 429 sont retriables, 4xx non
if (r.status >= 500 || r.status === 429) {
const retryAfter = Number(r.headers.get("retry-after")) * 1000;
if (!Number.isNaN(retryAfter) && retryAfter > 0) {
await sleep(retryAfter);
continue;
}
throw new Error(`retriable ${r.status}`);
}
return r;
} catch (e) {
lastErr = e;
if ((e as Error).name === "AbortError" && opts.signal?.aborted) throw e;
const delay = base * 2 ** attempt + Math.random() * base; // jitter full
await sleep(delay);
}
}
throw lastErr;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));🎯 Patterns courants
Timeout par tentative vs timeout global. Un client résilient combine les deux : un timeout par requête (5-10 s) qui peut déclencher une retry, et un timeout global (30-60 s) qui borne l'opération complète. Avec AbortSignal.any, tu exprimes cela en deux lignes. Sans, tu finis avec des opérations qui durent plusieurs minutes sous panne réseau.
Circuit breaker. Quand un service amont est en panne, continuer à le marteler aggrave la situation. Le pattern : compter les échecs sur une fenêtre glissante ; si > N erreurs, ouvrir le circuit pendant T secondes (toutes les requêtes échouent immédiatement) ; après T, passer en demi-ouvert (laisser passer 1 sondage) ; si succès, refermer. Implémentations : opossum, cockatiel, ou maison en 50 lignes.
Mock dispatcher pour les tests. Au lieu de mocker fetch avec un sinon ou un nock, utilise MockAgent d'undici. Tu déclares les routes attendues, tu y attaches des réponses, et tu remplaces le dispatcher global. Avantage : tu testes vraiment le code de prod (mêmes parsings, mêmes erreurs) sans réseau.
import { MockAgent, setGlobalDispatcher } from "undici";
const mockAgent = new MockAgent();
setGlobalDispatcher(mockAgent);
mockAgent.disableNetConnect(); // safety net : aucune vraie connexion
const pool = mockAgent.get("https://api.example.com");
pool
.intercept({ path: "/v1/users/42", method: "GET" })
.reply(200, { id: 42, name: "Alice" }, { headers: { "content-type": "application/json" } });
const r = await fetch("https://api.example.com/v1/users/42");
const u = await r.json(); // { id: 42, name: "Alice" }Proxy HTTP/HTTPS via dispatcher. Plutôt que process.env.HTTPS_PROXY qui dépend de chaque lib, utilise ProxyAgent d'undici qui marche pour fetch, undici.request, undici.stream. Très utile derrière un proxy d'entreprise.
import { ProxyAgent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new ProxyAgent("http://proxy.corp:8080"));Upload streaming. Pour envoyer un gros fichier en POST sans le charger en mémoire, passe un ReadableStream ou un AsyncIterable au champ body. Node convertit automatiquement.
import { createReadStream } from "node:fs";
import { Readable } from "node:stream";
const file = Readable.toWeb(createReadStream("./big.bin")); // ReadableStream Web
const r = await fetch("https://upload.example.com", {
method: "PUT",
body: file as unknown as BodyInit,
duplex: "half", // OBLIGATOIRE pour les requêtes streamées en fetch Node
headers: { "content-type": "application/octet-stream" },
});Hedged requests. Pour des cibles à latence variable, lance 2 requêtes en parallèle, prends la première qui répond, annule l'autre. Utile pour des reads idempotents critiques (read-after-write checks).
async function hedged(url: string, signal?: AbortSignal): Promise<Response> {
const ctrl1 = new AbortController();
const ctrl2 = new AbortController();
const sig1 = signal ? AbortSignal.any([signal, ctrl1.signal]) : ctrl1.signal;
const sig2 = signal ? AbortSignal.any([signal, ctrl2.signal]) : ctrl2.signal;
const p1 = fetch(url, { signal: sig1 });
// Lance la deuxième après 50 ms si la première n'a pas répondu
const p2 = new Promise<Response>((resolve, reject) => {
setTimeout(() => fetch(url, { signal: sig2 }).then(resolve, reject), 50);
});
try {
const winner = await Promise.race([p1, p2]);
// Annule celle qui a perdu
ctrl1.abort();
ctrl2.abort();
return winner;
} catch (e) {
ctrl1.abort();
ctrl2.abort();
throw e;
}
}Bulkhead. Sépare les pools de connexions par criticité. Une API mineure (analytics) qui sature ne doit pas affamer une API critique (paiement). Crée deux Agent distincts avec des limites de connexions séparées. Si l'analytics est down, son Agent sature mais le pool de paiement reste intact.
Caching côté client. fetch ne fait pas de cache automatique côté Node (contrairement au navigateur). Si tu veux respecter Cache-Control et ETag, implémente un wrapper qui stocke { url, etag, response } en mémoire (LRU avec lru-cache) ou disque. Pour des données qui changent peu et sont coûteuses à fetch (config, schémas), c'est crucial.
Pagination streaming. Pour parcourir une API paginée (cursor-based), expose un async iterable :
async function* paginate<T>(baseUrl: string, signal?: AbortSignal): AsyncGenerator<T> {
let cursor: string | undefined;
do {
const u = new URL(baseUrl);
if (cursor) u.searchParams.set("cursor", cursor);
const r = await fetch(u, { signal });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const { items, next } = (await r.json()) as { items: T[]; next?: string };
for (const item of items) yield item;
cursor = next;
} while (cursor);
}
// Usage : itère sans charger toute la liste en mémoire
for await (const user of paginate<User>("https://api/users")) {
process(user);
}🔄 Versions — Node 18 / 20 / 22 / 24
Node 18. fetch global stable (basé sur undici ~5.x). AbortSignal.timeout() disponible (depuis 17.3). Pas encore d'AbortSignal.any : il faut écrire un helper manuel avec plusieurs addEventListener('abort'). Blob et FormData natifs.
Node 20. AbortSignal.any([signals]) natif et stable — révolutionne la composition. File natif (sous-classe de Blob avec name et lastModified). fetch se met à jour vers undici 6.x avec corrections sur les redirects et le streaming des bodies.
Node 22. WebSocket natif global (basé sur undici), tu peux écrire new WebSocket("wss://...") sans dépendre de ws. fetch gagne le support stable de Connection: Upgrade et des 103 Early Hints côté client (callback via dispatcher). Le globalAgent HTTP en client active keepAlive: true par défaut. URLPattern arrive (utile pour matcher des routes côté client/edge).
Node 24. Undici 7 intégré : amélioration significative du parsing (10-20 % de gain), nouveau EnvHttpProxyAgent qui lit HTTP_PROXY/HTTPS_PROXY/NO_PROXY automatiquement, et support expérimental HTTP/3 côté client via --experimental-http3. Headers.getSetCookie() retourne un tableau (les cookies multiples ne sont plus écrasés). Modèle de permission stable : --allow-net=domain.tld limite ce que fetch peut atteindre.
Tableau comparatif des features par version :
| Feature | Node 18 | Node 20 | Node 22 | Node 24 |
|---|---|---|---|---|
fetch global stable | ✅ | ✅ | ✅ | ✅ |
AbortSignal.timeout | ✅ | ✅ | ✅ | ✅ |
AbortSignal.any | ❌ | ✅ | ✅ | ✅ |
WebSocket global | ❌ | ❌ | ✅ | ✅ |
Headers.getSetCookie | partiel | ✅ | ✅ | ✅ |
EnvHttpProxyAgent | ❌ | ❌ | ❌ | ✅ |
| HTTP/3 client (flag) | ❌ | ❌ | ❌ | ✅ |
Permission model stable (--allow-net) | ❌ | ❌ | partiel | ✅ |
// Helper de compat AbortSignal.any pour Node 18 (sans la lib native)
function anySignal(signals: AbortSignal[]): AbortSignal {
const ctrl = new AbortController();
for (const s of signals) {
if (s.aborted) {
ctrl.abort(s.reason);
return ctrl.signal;
}
s.addEventListener("abort", () => ctrl.abort(s.reason), { once: true });
}
return ctrl.signal;
}⚠️ Pitfalls — 6 à 10
- Body consommé une seule fois.
await r.json()etawait r.text()consomment le stream. Si tu veux logger ET parser, faisconst body = await r.text(); JSON.parse(body). Ou clone avecconst r2 = r.clone()AVANT la première lecture. - Erreur HTTP n'est pas une exception.
fetchne rejette que sur erreur réseau ; un500est uneResponseordinaire avecok === false. Toujours testerif (!r.ok) throw …. - Fuite sur abort non gérée. Si tu annules pendant un streaming
for await (const chunk of r.body), sans consommer le stream restant ou appelerreader.cancel(), tu peux garder le socket bloqué. Pattern défensif :try { ... } finally { r.body?.cancel().catch(() => {}); }. signal.abortedne porte pas la raison. Pour savoir POURQUOI on a annulé (timeout ? user ? shutdown ?), utilisesignal.reason(DOMException 'TimeoutError'ou ta propreError). Ne te fie pas au seul booléen.- Pipelining HTTP/1.1. Garde
pipelining: 1dans tes Agents. Le pipelining est cassé sur la plupart des serveurs/proxies et provoque des head-of-line blocking. Si tu veux multiplexer, prends HTTP/2. AbortSignal.timeoutn'a pas de cleanup explicite. Il utilise unsetTimeoutinterne. Si tu crées 100k signaux par seconde sans les composer, tu accumules des timers. Préfère un signal partagé avecAbortSignal.any.- Headers :
Set-Cookiemultiples. Avant Node 22,headers.get('set-cookie')retournait une seule string concaténée (problématique car cookies peuvent contenir des virgules). Utiliseheaders.getSetCookie()(Node 19.7+, stable Node 22) qui retourne unstring[]. duplex: 'half'requis pour body streams. Si tu passes unReadableStreamenbody, tu DOIS spécifierduplex: 'half'(sinon erreur depuis Node 20). Spec Web fait pareil.- Pool partagé fuyant entre tenants. Comme côté serveur : un
Agentglobal est partagé. Si tu fais du multi-tenant et que tu réutilises les sockets, tu peux fuiter des credentials (proxy auth, cookies via redirects). Isole par contexte sensible. fetchne suit pas systématiquement les redirects POST. Par défaut, après un 301/302 sur POST, undici suit la redirection en GET (conformément à la spec). Si tu veux préserver la méthode, utiliserequestundici avecmaxRedirections: 0et gère manuellement.- Concurrence non bornée.
Promise.all(urls.map(fetch))sur 10 000 URLs ouvre 10 000 sockets — tu satures le pool, l'OS, ou la cible. Borne avecp-limitou un sémaphore manuel. - Pas de gestion du
Retry-After. Quand une API renvoie un 429 ou 503 avec un headerRetry-After, ton client doit respecter ce délai. Sinon tu aggraves la surcharge et tu peux te faire ban.
🔐 Sécurité du client HTTP
Quand ton code consomme des URLs partiellement contrôlées par un utilisateur, ou même quand il interagit avec des services tiers, plusieurs précautions s'imposent.
SSRF (rappel). Voir le document HTTP server : valider le schéma, bloquer les ranges privées (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16), et re-vérifier l'IP après résolution DNS.
Validation du Content-Type. Avant await r.json(), vérifie r.headers.get('content-type')?.startsWith('application/json'). Sinon, une réponse HTML d'erreur de proxy fera planter r.json() avec une SyntaxError peu informative. Pour les binaires (images, PDFs), valide la taille et le type avant de stocker.
TLS et CA pinning. Pour des APIs critiques (paiement, auth), tu peux vouloir épingler le certificat ou son hash. Avec undici, passe connect: { ca: ..., checkServerIdentity: ... } au Agent. Attention : la rotation de certificats devient une opération coordonnée.
Secrets en URL. Ne mets jamais de token dans l'URL (?api_key=...) — il finit dans les logs proxy, l'historique, les referrers. Utilise toujours un header Authorization. Si tu dois exposer une URL signée publiquement, fais-la expirer rapidement (< 5 min).
Désérialisation safe. JSON.parse est sûr en JavaScript (pas de code exec, contrairement à pickle/Marshal dans d'autres langages). Mais valide la STRUCTURE avec Zod, ajv ou Valibot avant d'utiliser. Une réponse manipulée peut avoir des champs inattendus qui cassent ta logique.
🧪 Testing — node --test + supertest pour HTTP
// fetch-retry.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from "undici";
let mock: MockAgent;
let prev: ReturnType<typeof getGlobalDispatcher>;
before(() => {
prev = getGlobalDispatcher();
mock = new MockAgent({ connections: 1 });
mock.disableNetConnect();
setGlobalDispatcher(mock);
});
after(async () => {
setGlobalDispatcher(prev);
await mock.close();
});
test("retry sur 503, succès au 2e essai", async () => {
const pool = mock.get("https://api.example.com");
pool.intercept({ path: "/v1/x", method: "GET" }).reply(503, "down");
pool.intercept({ path: "/v1/x", method: "GET" }).reply(200, { ok: true });
// Suppose une fonction fetchRetry comme dans la section patterns
const r = await fetchRetry("https://api.example.com/v1/x", { tries: 3, baseDelayMs: 1 });
assert.equal(r.status, 200);
assert.deepEqual(await r.json(), { ok: true });
});
test("AbortSignal.timeout déclenche un AbortError", async () => {
const pool = mock.get("https://api.example.com");
pool
.intercept({ path: "/slow", method: "GET" })
.reply(200, "late")
.delay(500);
await assert.rejects(
() => fetch("https://api.example.com/slow", { signal: AbortSignal.timeout(50) }),
(e: Error) => e.name === "AbortError" || e.name === "TimeoutError"
);
});
test("AbortSignal.any : user cancel gagne sur timeout", async () => {
const ctrl = new AbortController();
const signal = AbortSignal.any([ctrl.signal, AbortSignal.timeout(10_000)]);
setTimeout(() => ctrl.abort(new Error("user cancel")), 10);
await assert.rejects(
() => fetch("https://api.example.com/anything", { signal }),
/user cancel/
);
});node --test --test-reporter=spec fetch-retry.test.tsPour tester un client HTTP de manière exhaustive : ajoute des scénarios de réseau dégradé (latence, perte de paquets) avec tc netem sous Linux, ou pumba en Docker. Mock seul ne couvre pas les bugs subtils (TCP RST en milieu de réponse, TLS handshake interrompu).
Tests de charge. Pour valider qu'un pool tient la charge, utilise autocannon ou un mini-benchmark Node :
import { performance } from "node:perf_hooks";
async function bench(n: number, url: string) {
const start = performance.now();
const tasks = Array.from({ length: n }, () => fetch(url).then((r) => r.text()));
await Promise.all(tasks);
const ms = performance.now() - start;
console.log(`${n} requêtes en ${ms.toFixed(0)} ms (${(n / (ms / 1000)).toFixed(0)} req/s)`);
}Surveille la mémoire pendant le bench avec process.memoryUsage().heapUsed. Une croissance linéaire sans plateau indique un leak (souvent un Response non consommé, un setTimeout non clearé, ou un event listener accumulé).
🎬 Cas d'usage concrets
Scénario 1 — Aggregator e-commerce avec timeout strict
Plateforme e-commerce avec un page produit qui appelle en parallèle : stock (entrepôt), prix dynamique (pricing engine), recommandations (ML service), avis client (Trustpilot). SLA front : 250 ms total ; n'importe quel service au-delà = on dégrade (placeholder, prix cached, pas de reco).
Promise.allSettled + AbortController armé à 200 ms global. Chaque fetch reçoit le même signal ; le premier qui résout son endpoint reste, les autres timeout proprement. keepalive undici (agent par défaut) maintient les 4 pools chauds. Les réponses partielles sont mergées : si reco timeout, on retourne le produit sans bloc "Vous aimerez aussi".
Détail crucial : signal.throwIfAborted() avant chaque await response.json() — un fetch peut retourner les headers sous 50 ms mais le body lent. Sans ce check, la lecture body bloque même après abort des connexions amont.
Scénario 2 — Scraping Legifrance cabinet avec retry exponentiel
Cabinet juridique qui interroge l'API Legifrance (rate-limitée, parfois 429/503) pour pré-charger les références citées dans un mémo en cours de rédaction. 50 à 200 références par mémo, en arrière-plan.
Pattern : fetch avec AbortController timeout 8 s par appel, retry avec backoff exponentiel + jitter (base * 2^attempt + random(0, 1000)), respect du header Retry-After quand présent. Plafond 5 tentatives, sinon on marque la référence "à valider manuellement" dans la note du juriste.
Concurrency limitée à 4 via une mini-queue maison (ou p-limit). Sans limite, l'API ban après 30 req en 1 s. Avec 4 concurrent + backoff, on tient 250 req en 90 s sans 429. Le signal global du job (annulation par l'utilisateur qui ferme le mémo) cascade dans chaque fetch.
Scénario 3 — Intégration banque Bridge API (open banking)
Service de comptabilité qui agrège les transactions bancaires via Bridge API (PSD2). Synchronisation horaire de 12 000 comptes, chaque compte = 1 à 5 appels HTTP (auth, accounts, transactions paginées).
fetch + custom Agent undici avec connections: 50, pipelining: 1 (Bridge n'aime pas pipelining). Token OAuth en cache (Redis, TTL 25 min) ; si fetch retourne 401, on invalide le token, refetch un, retry une fois. Pagination via next cursor extrait du body — boucle bornée à 50 itérations pour éviter une page corrompue qui pointerait en boucle.
Tous les fetch sont instrumentés (AsyncLocalStorage propage un correlationId dans les logs). En cas de timeout, on log la durée DNS + TLS + TTFB (extraits via performance.timeOrigin + hook diagnostics_channel) pour diagnostiquer côté Bridge ou côté nous.
🛠️ Exemple end-to-end
Aggregator e-commerce avec timeout global, retry par-service, dégradation gracieuse et instrumentation.
import { setTimeout as delay } from "node:timers/promises";
import { request } from "undici";
type ProductView = {
product: { id: string; name: string };
stock: number | null;
price: { cents: number; currency: "EUR" } | null;
reco: Array<{ id: string; name: string }>;
reviews: { avg: number; count: number } | null;
partial: string[];
};
async function fetchJson<T>(url: string, signal: AbortSignal, attempts = 2): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
if (signal.aborted) throw new DOMException("aborted", "AbortError");
try {
const res = await fetch(url, { signal, keepalive: true });
if (res.status === 429) {
const ra = Number(res.headers.get("retry-after") ?? "1");
await delay(ra * 1000, undefined, { signal });
continue;
}
if (!res.ok) throw new Error(`${url} ${res.status}`);
signal.throwIfAborted();
return (await res.json()) as T;
} catch (err) {
lastErr = err;
if ((err as any)?.name === "AbortError") throw err;
if (i < attempts - 1) await delay(50 * 2 ** i + Math.random() * 50, undefined, { signal });
}
}
throw lastErr;
}
export async function buildProductView(productId: string): Promise<ProductView> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(new Error("global 200ms budget")), 200);
const partial: string[] = [];
const tasks = {
stock: fetchJson<{ qty: number }>(`http://wms/stock/${productId}`, ac.signal),
price: fetchJson<{ cents: number }>(`http://pricing/price/${productId}`, ac.signal),
reco: fetchJson<Array<{ id: string; name: string }>>(`http://reco/for/${productId}`, ac.signal),
reviews: fetchJson<{ avg: number; count: number }>(`http://reviews/${productId}`, ac.signal),
};
const [stockR, priceR, recoR, reviewsR] = await Promise.allSettled([
tasks.stock,
tasks.price,
tasks.reco,
tasks.reviews,
]);
clearTimeout(t);
const view: ProductView = {
product: { id: productId, name: "" },
stock: null,
price: null,
reco: [],
reviews: null,
partial,
};
if (stockR.status === "fulfilled") view.stock = stockR.value.qty;
else partial.push("stock");
if (priceR.status === "fulfilled") view.price = { cents: priceR.value.cents, currency: "EUR" };
else partial.push("price");
if (recoR.status === "fulfilled") view.reco = recoR.value;
else partial.push("reco");
if (reviewsR.status === "fulfilled") view.reviews = reviewsR.value;
else partial.push("reviews");
return view;
}
// example
const view = await buildProductView("sku-12345");
console.log(view);Points clés : AbortController global qui cascade, Promise.allSettled pour partial responses, retry 429 avec respect Retry-After, dégradation explicite via partial[], keepalive pour ne pas relancer DNS+TLS sur chaque requête.
🔁 Quand utiliser / éviter
Utilise fetch natif pour 95 % des cas : appels REST/GraphQL, webhooks, SSE consommé en streaming. Plus besoin d'axios, node-fetch, got ou request. L'API est connue (identique au navigateur), elle est stable, et undici gère le pooling automatiquement.
Utilise undici.request quand : tu veux gratter chaque microseconde (pas d'overhead de Response qui crée des ReadableStream), tu fais du proxying haute fréquence, ou tu veux accéder à des features avancées (Connection: Upgrade, écoute des 103 Early Hints).
Utilise axios ou got quand : tu hérites d'une base qui les utilise déjà, ou tu veux une fonctionnalité spécifique non couverte (interception bidirectionnelle de transforms, hooks riches). Pour un nouveau projet en 2026, fetch + undici suffit.
Évite de désactiver keepAlive. C'est tentant en debug ("c'est plus déterministe"), mais coûteux en prod. Tu refais TCP + TLS à chaque requête (~100-300 ms perdus en latence intercontinentale).
Évite les retries sur méthodes non idempotentes. POST de paiement, par exemple — tu pourrais débiter deux fois. Utilise des clés d'idempotence (Idempotency-Key header type Stripe) côté serveur, et retry uniquement avec cette clé.
Évite AbortController non propagé. Si tu wrappes fetch dans une fonction qui ne reçoit pas de signal, tu retires la capacité d'annuler en amont. Toujours faire passer le signal comme argument optionnel dans tes APIs internes.
Évite de stocker Response en mémoire. Un Response non consommé maintient une référence au socket. Si tu fais Promise.all([fetch, fetch, fetch]) et tu n'attends pas chaque body, tu fuites.
📚 Études de cas
Cas 1 : appel à une API externe lente bloque un endpoint. Le service A appelle le service B dans son handler /checkout. Quand B devient lent (3 s), A voit sa latence p99 exploser et sa concurrence augmenter (toutes les requêtes attendent B). Solution : timeout court (1 s) + retry idempotent + circuit breaker. Si B est non critique, fail-open avec valeur par défaut. Si B est critique, propager une erreur 503 immédiatement plutôt que de faire attendre l'utilisateur.
Cas 2 : leak de sockets après refactor. Après avoir migré d'axios à fetch, l'équipe constate une augmentation graduelle du nombre de sockets CLOSE_WAIT (visible avec ss -s). Cause : un code path qui consomme r.text() dans certains cas mais pas dans d'autres (early return). Solution : toujours consommer ou explicitement r.body?.cancel(). Un wrapper safeFetch qui garantit la consommation aide.
Cas 3 : retry storm. Lors d'un incident chez le provider d'authentification, tous les services qui dépendent de lui retry sans coordination, multipliant le trafic vers le provider et empêchant sa récupération. Solution : retry avec jitter (déjà mentionné) ET circuit breaker (encore plus important ici — quand le breaker est ouvert, tu ne retry plus du tout). Et toujours respecter Retry-After.
Cas 4 : DNS cache stale. Le service A fetch api.partner.com. Le partenaire change son IP, le DNS TTL est de 60 s, mais Node garde la résolution en cache durant 0 secondes (pas de cache DNS par défaut !) — donc chaque connexion refait un lookup. C'est inefficient mais correct. Inversement, si tu utilises cacheable-lookup pour éviter ces lookups, tu peux te retrouver avec des IPs périmées. Compromis : un cache de 30 s respecte 95 % des cas avec un gain perf substantiel.
🔬 Comparaison fetch vs alternatives
| Critère | fetch natif | undici.request | axios | got |
|---|---|---|---|---|
| Standardisation | Web standard | spécifique Node | spécifique | spécifique |
| Perf brute | Bonne | Excellente | Moyenne | Bonne |
| Streaming body | ReadableStream | Node.Readable | Node.Readable | Node.Readable |
| Annulation | AbortSignal | AbortSignal | CancelToken+Abort | AbortSignal |
| Retry intégré | Non | Via interceptor | Plugin | Oui |
| Hooks | Non | Limités | Interceptors | Riches |
| Mock testing | MockAgent | MockAgent | mock-adapter | Nock |
| Taille dépendance | 0 (natif) | déjà bundled | ~250 KB | ~200 KB |
Le choix pragmatique en 2026 : fetch natif pour 95 % des cas, undici.request pour le code performance-critical, garde got ou axios uniquement si tu hérites d'une base existante ou tu as besoin de leurs hooks spécifiques.
🎛️ Tuning d'un undici.Agent en production
Les valeurs par défaut d'undici sont raisonnables, mais peuvent être affinées selon le profil de trafic.
connections: nombre max de sockets simultanées vers une même origine. Défaut 0 (illimité par origine). Pour un service qui fait beaucoup d'appels vers une même API, fixe à 50-100 pour profiter du keep-alive. Trop bas → contention ; trop haut → pression réseau et serveur cible.pipelining: 1 par défaut. Garde à 1 (le pipelining HTTP/1.1 est rarement bien supporté).keepAliveTimeout: 4 s par défaut. Le serveur ferme souvent après 5-30 s ; aligne-toi en dessous.keepAliveMaxTimeout: 10 min par défaut. Limite supérieure si le serveur n'envoie pasKeep-Aliveexplicite.headersTimeout: 5 min par défaut. Réduis à 10-30 s pour échouer vite.bodyTimeout: 5 min par défaut. Idem, à ajuster selon tes payloads.maxResponseSize: -1 (illimité). Pour éviter qu'une réponse géante remplisse la mémoire, mets une limite (10-100 MiB selon usage).maxRedirections: 0 par défaut sur undici (mais 20 surfetch). Mets 3-5 pour être sûr d'aboutir mais pas trop bouclé.
import { Agent } from "undici";
export const apiAgent = new Agent({
connections: 100,
keepAliveTimeout: 3_000,
keepAliveMaxTimeout: 60_000,
headersTimeout: 10_000,
bodyTimeout: 30_000,
maxResponseSize: 50 * 1024 * 1024,
connect: { timeout: 5_000 },
});🔗 Liens
- MDN
fetch: https://developer.mozilla.org/docs/Web/API/Fetch_API - MDN
AbortController: https://developer.mozilla.org/docs/Web/API/AbortController - Undici docs : https://undici.nodejs.org
AbortSignal.any(TC39 + Web) : https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static- Cockatiel (resilience) : https://github.com/connor4312/cockatiel
- Opossum (circuit breaker) : https://nodeshift.dev/opossum/
- Stripe idempotency : https://docs.stripe.com/api/idempotent_requests
- Best practices (Cloudflare) : https://blog.cloudflare.com/
🧰 Helper de production complet
Voici un wrapper qui combine les bonnes pratiques évoquées :
import { Agent, fetch } from "undici";
interface FetchJSONOpts {
signal?: AbortSignal;
timeoutMs?: number;
retries?: number;
baseDelayMs?: number;
agent?: Agent;
}
export async function fetchJSON<T>(url: string, init: RequestInit = {}, opts: FetchJSONOpts = {}): Promise<T> {
const tries = opts.retries ?? 3;
const base = opts.baseDelayMs ?? 200;
const timeoutMs = opts.timeoutMs ?? 10_000;
let lastErr: unknown;
for (let attempt = 0; attempt < tries; attempt++) {
const signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)];
if (opts.signal) signals.push(opts.signal);
const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
try {
const r = await fetch(url, { ...init, signal, dispatcher: opts.agent } as RequestInit);
if (r.status >= 500 || r.status === 429) {
const ra = Number(r.headers.get("retry-after"));
if (Number.isFinite(ra) && ra > 0) await sleep(ra * 1000);
throw new Error(`retriable ${r.status}`);
}
if (!r.ok) {
const body = await r.text().catch(() => "");
throw new Error(`HTTP ${r.status}: ${body.slice(0, 200)}`);
}
const ct = r.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) {
throw new Error(`unexpected content-type: ${ct}`);
}
return (await r.json()) as T;
} catch (e: any) {
lastErr = e;
if (e.name === "AbortError" && opts.signal?.aborted) throw e;
if (attempt < tries - 1) {
const delay = base * 2 ** attempt + Math.random() * base;
await sleep(delay);
}
}
}
throw lastErr;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));Ce helper gère : timeout par tentative, propagation correcte de l'abort utilisateur (sans retry), retry sur 5xx/429 avec respect du Retry-After, backoff exponentiel avec jitter, validation du content-type, et propagation de l'erreur originale après épuisement des retries.
Le piège subtil que 9 implémentations sur 10 ratent. Discriminer pourquoi on a été aborté. Trois sources possibles partagent le même AbortError côté fetch :
| Source d'abort | signal.reason | Décision |
|---|---|---|
Timeout per-try (AbortSignal.timeout) | DOMException name: "TimeoutError" | Retry — c'est exactement le cas qu'on veut réessayer |
| Annulation utilisateur / shutdown | ton Error ou AbortError custom | Stop immédiat — l'appelant ne veut plus du résultat |
| Erreur réseau (ECONNRESET, DNS) | TypeError (cause undici) | Retry si idempotent |
Le discriminateur fiable n'est PAS e.name (le runtime collapse tout en AbortError au niveau fetch), mais opts.signal?.aborted : si le signal fourni par l'appelant est aborté, on arrête ; sinon l'abort vient forcément du timeout interne, donc on retry. C'est précisément la ligne if (e.name === "AbortError" && opts.signal?.aborted) throw e;. Inverser cette condition transforme un client résilient en machine à retry infini sur annulation utilisateur — un incident classique en production.
// Helper de classification réutilisable
function classifyAbort(e: unknown, userSignal?: AbortSignal): "user" | "timeout" | "network" | "other" {
if (userSignal?.aborted) return "user";
const name = (e as Error)?.name;
if (name === "TimeoutError" || name === "AbortError") return "timeout";
if (e instanceof TypeError) return "network"; // fetch wrappe les erreurs réseau en TypeError
return "other";
}🏋️ Exercices
Progression d'« implémente » vers « production-grade » puis « casse-puis-répare ». Chaque exercice se teste avec node --test + MockAgent (aucun réseau requis).
Exercice 1 — withTimeout composable (implémente)
Objectif : écrire withTimeout(promiseFactory, ms, parentSignal?) qui borne n'importe quelle opération fetch et compose proprement un signal parent optionnel, sans fuiter de timer.
Indice/Solution : const t = AbortSignal.timeout(ms); const sig = parentSignal ? AbortSignal.any([parentSignal, t]) : t; puis promiseFactory(sig). Vérifie en test que signal.reason.name === "TimeoutError" quand le délai expire, et que le reason parent est préservé quand c'est l'annulation utilisateur qui gagne la course (AbortSignal.any propage le reason du premier signal aborté).
Exercice 2 — safeFetch qui ne fuit jamais de socket (production-grade)
Objectif : un wrapper qui garantit qu'aucun Response ne reste non consommé, même sur les early-returns et les exceptions de parsing.
Indice/Solution : try { ... return await r.json(); } finally { if (!r.bodyUsed) r.body?.cancel().catch(() => {}); }. Teste avec un MockAgent puis vérifie process.getActiveResourcesInfo() ou compte les sockets via diagnostics_channel (undici:client:connected / undici:request:trailers). Bonus : prouve la fuite EN PREMIER (sans le finally) en observant les sockets CLOSE_WAIT rester ouverts, puis répare.
Exercice 3 — Limiteur de concurrence borné (production-grade)
Objectif : mapLimit(items, limit, async fn) qui traite N éléments avec au plus limit fetch simultanés, propage un AbortSignal global, et s'arrête à la première erreur fatale (fail-fast) tout en laissant les in-flight se terminer proprement.
Indice/Solution : un pool de workers qui tirent dans une queue partagée (let i = 0; const next = () => items[i++]). Pas de Promise.all(items.map(fn)) — ça ouvre tout d'un coup. Compare empiriquement le nombre de sockets ouverts (via connections de l'Agent) entre Promise.all non borné et ta version. Vérifie qu'un abort du signal global rejette les tâches non démarrées sans lancer de nouveaux fetch.
Exercice 4 — Circuit breaker maison (production-grade)
Objectif : implémenter un breaker à 3 états (closed / open / half-open) avec fenêtre glissante d'erreurs, en moins de 60 lignes, et le brancher devant fetchJSON.
Indice/Solution : compteur d'échecs sur une fenêtre temporelle ; au-delà du seuil → open pendant cooldownMs (toute requête rejette immédiatement sans toucher le réseau) ; après cooldown → half-open (laisse passer 1 sonde) ; succès → closed, échec → re-open. Teste le triangle d'états : sature → open rejette en < 1 ms → attends le cooldown → half-open → 1 succès → closed. Pourquoi c'est crucial : sans breaker, le retry+jitter de l'ex. 3 amplifie quand même le trafic vers un amont déjà mort (retry storm).
Exercice 5 — Casse-puis-répare : le streaming NDJSON qui fuit la mémoire (break-then-fix)
Objectif : on te donne un parseur NDJSON qui charge tout en RAM via await r.text() puis split("\n"). Sur un flux de 2 Go, il OOM. Répare-le en streaming incrémental borné en mémoire.
Indice/Solution : remplace par r.body.pipeThrough(new TextDecoderStream()).getReader() avec un buffer de ligne (cf. exemple §Code minimal). Mesure process.memoryUsage().heapUsed avant/après sur un flux simulé de 100k lignes : la version naïve croît linéairement, la version streaming reste plate. Piège à éviter pendant la réparation : ne jamais faire buffer.split("\n") sur tout le buffer (recrée des copies O(n²)) — indexOf + slice incrémental.
Exercice 6 — Casse-puis-répare : hedged requests qui double-débite (break-then-fix)
Objectif : le pattern hedged de la section Patterns, appliqué naïvement à un POST, déclenche deux fois l'effet de bord. Diagnostique et corrige.
Indice/Solution : le hedging n'est légitime que sur des opérations idempotentes (GET, ou POST avec Idempotency-Key). Répare en (a) restreignant le hedging aux GET, OU (b) en propageant un même Idempotency-Key aux deux requêtes pour que le serveur déduplique. Démontre le double-effet avec un MockAgent qui compte les appels, puis prouve qu'avec la clé d'idempotence le compteur d'effet logique reste à 1 même si 2 requêtes HTTP partent.
🎤 En entretien
Q : fetch ne rejette pas sur un HTTP 500. Pourquoi ce choix, et quel bug ça cause en pratique ? R : la spec Fetch ne rejette QUE sur erreur réseau/protocole — un statut HTTP (même 500) est une réponse réseau réussie, donc une Response avec ok === false. Le bug classique : un try/catch autour de fetch qui suppose que toute erreur serveur lève une exception, et qui parse donc allègrement le body d'une page d'erreur HTML en JSON. Réflexe senior : toujours if (!r.ok) throw + valider le content-type avant .json().
Q : Tu composes un timeout per-try et une annulation utilisateur. Quand le fetch rejette avec AbortError, comment décides-tu de retry ou non ? R : on ne se fie pas à e.name (le runtime collapse timeout interne et annulation externe en AbortError côté fetch). Le discriminateur est userSignal.aborted : si le signal fourni par l'appelant est aborté, on stoppe (l'appelant ne veut plus du résultat) ; sinon l'abort vient du timeout interne et on retry. Confondre les deux donne soit du retry infini sur annulation, soit l'absence de retry sur timeout.
Q : Pourquoi un undici.Agent partagé globalement, et quel est son principal risque ? R : un Agent maintient un pool de sockets keep-alive par origine — il amortit DNS+TCP+TLS (100-300 ms) sur tout le process. Risque principal : le partage de sockets entre contextes de sécurité (multi-tenant), où des credentials propagés via redirect/proxy-auth peuvent fuiter d'un tenant à l'autre ; on isole alors par Agent dédié (bulkhead). Second risque : connections trop haut sature l'amont, trop bas crée de la contention — d'où le tuning par profil de trafic.
Q : Tu vois des sockets en CLOSE_WAIT qui s'accumulent après une migration axios → fetch. Diagnostic ? R : CLOSE_WAIT côté client signifie que le pair a fermé mais qu'on n'a pas lu/fermé notre extrémité — typiquement un Response dont le body n'est jamais consommé (un early-return qui zappe .json()/.text()). Le socket reste épinglé au pool, plus réutilisable. Fix : consommer systématiquement, ou un wrapper safeFetch avec finally { if (!r.bodyUsed) r.body?.cancel() }. Outils : ss -s, diagnostics_channel undici, process.getActiveResourcesInfo().
Récap final. fetch natif a aboli l'écosystème pléthorique des clients HTTP Node. Le combo recommandé en 2026 : fetch global + undici.Agent configuré (keepAlive, pooling, timeouts cohérents) + AbortSignal.any pour composer timeouts/cancellations + retry idempotent avec jitter + circuit breaker pour les dépendances critiques. Les pièges récurrents tournent autour du body consommable une seule fois, des aborts mal propagés (fuites de sockets), et du partage d'Agent entre tenants. En test, MockAgent remplace nock et donne un fidèle équivalent du runtime de production sans réseau.