06 - Timeouts et resilience : quand le réseau lache
Ce que tu vas apprendre
- Gerer les timeouts proprement avec AbortController
- Implementer des retries avec backoff exponentiel
- Le pattern circuit breaker et quand l'utiliser
- Tester la resilience en cassant des trucs volontairement
Prerequisites
Connaitre les bases de TypeScript et les Promises. Avoir lu les articles précédents de la serie, en particulier le stress test.
Le fetch qui ne revient jamais
Octobre 2025. J'ai une route sur paltemps.fr qui appelle une API externe pour récupérer des metadonnees. L'API en question a décidé de ne plus répondre. Pas une erreur, pas un timeout, juste... le silence. La connexion TCP restait ouverte, la Promise de fetch() ne se resolvait jamais.
Résultat : chaque requête utilisateur vers cette route creait un fetch qui restait suspendu indefiniment. En 10 minutes, j'avais 300 connexions pendantes, la RAM montait, et les autres routes du serveur Elysia commencaient a ralentir parce que les event loops etaient saturees de Promises en attente.
Le fix : un timeout de 5 secondes. Quand l'API externe ne répond pas en 5 secondes, on abandonne et on retourne une réponse par défaut.
AbortController : le timeout propre
fetch() en JavaScript n'a pas de paramètre timeout natif. Il faut utiliser AbortController :
typescriptasync function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
try {
const res = await fetch(url, { signal: controller.signal });
return res;
} finally {
clearTimeout(timeout);
}
}
// Utilisation
try {
const res = await fetchWithTimeout("https://api.externe.com/data", 5000);
const data = await res.json();
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
console.log("Timeout : l'API n'a pas repondu en 5 secondes");
}
}
Le finally est la pour nettoyer le timer meme si la requête reussit avant le timeout. Sans ca, tu laisses un timer orphelin qui declenchera un abort() inutile.
Chaque fetch() dans ton code devrait avoir un timeout. Pas d'exception. Meme si l'API est "rapide" et "fiable". Un jour elle ne le sera plus.
Retries avec backoff exponentiel
Quand une requête echoue, la première reaction est de reessayer. Mais reessayer immédiatement sur un serveur sature, c'est comme klaxonner dans un embouteillage : ca empire la situation.
Le backoff exponentiel attend de plus en plus longtemps entre chaque retry. Le jitter ajoute un delai aleatoire pour éviter que tous les clients reessaient au meme moment :
typescriptasync function fetchWithRetry(
url: string,
options: {
maxRetries?: number;
baseDelay?: number;
timeout?: number;
} = {}
): Promise<Response> {
const { maxRetries = 3, baseDelay = 1000, timeout = 5000 } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fetchWithTimeout(url, timeout);
} catch (err) {
if (attempt === maxRetries) throw err;
const delay = baseDelay * Math.pow(2, attempt);
const jitter = delay * 0.5 * Math.random();
const waitTime = delay + jitter;
console.log(
`Tentative ${attempt + 1} echouee, retry dans ${Math.round(waitTime)}ms`
);
await new Promise((r) => setTimeout(r, waitTime));
}
}
throw new Error("Unreachable");
}
Avec baseDelay = 1000 et 3 retries, les delais sont environ : 1s, 2s, 4s (plus le jitter). Au total, tu attends 7 secondes avant d'abandonner. C'est raisonnable pour un appel API backend. Pour une requête utilisateur temps réel, reduis a 2 retries max.
Le circuit breaker
Les retries resolvent les erreurs ponctuelles. Mais si l'API externe est down pendant 2 heures, tu vas retry chaque requête 3 fois pendant 2 heures. Ca fait beaucoup de requêtes inutiles vers un serveur deja mort.
Le circuit breaker coupe le circuit quand trop d'erreurs s'accumulent :
typescriptclass CircuitBreaker {
private failures = 0;
private lastFailure = 0;
private state: "closed" | "open" | "half-open" = "closed";
constructor(
private readonly threshold: number = 5,
private readonly resetTimeout: number = 30000
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.lastFailure > this.resetTimeout) {
this.state = "half-open";
} else {
throw new Error("Circuit is open");
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess() {
this.failures = 0;
this.state = "closed";
}
private onFailure() {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = "open";
}
}
}
Trois états :
Closed : tout fonctionne, les requêtes passent normalement. Chaque erreur incremente un compteur.
Open : le compteur a atteint le seuil (5 erreurs). Toutes les requêtes sont refusees immédiatement sans meme essayer. Le serveur externe a le temps de récupérer.
Half-open : apres le resetTimeout (30 secondes), on laisse passer une requête test. Si elle reussit, on repasse en closed. Si elle echoue, on repart en open.
En pratique :
typescriptconst apiBreaker = new CircuitBreaker(5, 30000);
app.get("/data", async () => {
try {
const data = await apiBreaker.call(() =>
fetchWithTimeout("https://api.externe.com/data", 5000)
);
return Response.json(await data.json());
} catch (err) {
if (err.message === "Circuit is open") {
// retourner les donnees en cache
return Response.json(getCachedData(), {
headers: { "X-Data-Source": "cache" },
});
}
throw err;
}
});
Tester la resilience
Comment tu sais que ton timeout, tes retries et ton circuit breaker fonctionnent ? Tu les testes. En cassant des trucs.
Tuer un container pendant un test k6. Lance un test de charge, puis pendant qu'il tourne :
bashdocker stop mon-api-externe
Regarde ce qui se passe. Les requêtes echouent ? Combien de temps avant que le circuit breaker s'active ? Est-ce que le cache prend le relais ? C'est du chaos testing a petite échelle.
Simuler un réseau lent. Sous Linux, tc (traffic control) permet de simuler de la latence :
bash# Ajouter 500ms de latence sur l'interface
tc qdisc add dev eth0 root netem delay 500ms
# Retirer
tc qdisc del dev eth0 root
Lance un test k6 avec cette latence artificielle et vérifié que tes timeouts se declenchent correctement.
Test k6 pendant une panne simulee. Ecris un script k6 qui appelle ton endpoint pendant 5 minutes. A la minute 2, arrêté le service dont il depend. A la minute 4, relance-le. Verifie que :
- Les erreurs apparaissent rapidement (pas de requêtes bloquees 60 secondes)
- Le système se remet a fonctionner quand le service revient
- Le taux d'erreur global reste sous un seuil acceptable
Degradation gracieuse
Le but n'est jamais de faire croire que tout va bien quand ca ne va pas. C'est de continuer a fournir un service, meme réduit. Sur paltemps.fr, quand l'API de meteo externe est down, je ne renvoie pas une erreur 500. Je renvoie les dernières donnees connues avec un header X-Data-Age: 3600 pour indiquer qu'elles datent d'une heure.
L'utilisateur a une donnee un peu vieille plutot que pas de donnee du tout. C'est presque toujours préférable.
Article précédent : 05 - Profiling mémoire : détecter les fuites Article suivant : 07 - Soak tests : l'endurance longue duree