Maîtriser la concurrence dans Node.js : Éviter les pannes d’API avec p-map et Bottleneck
Promise.all sans limite peut endommager les APIs. Combinez p-map et Bottleneck dans Node.js pour limiter la concurrence, respecter les restrictions de fréquence et garantir le bon fonctionnement des systèmes en aval.
Sources: Stop Melting Your APIs: Smart Concurrency Control in Node.js with p-map & Bottleneck
La plupart des applications Node.js ne tombent pas en panne à cause d’une logique erronée. Elles échouent parce qu’elles tentent d’exécuter trop d’opérations en même temps, de manière trop agressive.
Imaginons un scénario : une équipe déploie une fonctionnalité qui envoie environ 10 000 appels API en quelques secondes. Sur une machine locale, tout fonctionne parfaitement et rapidement, et les résultats sont impressionnants.
Lorsque ce même code est mis en production, la situation change :
- Des erreurs 429 apparaissent partout
- L’API tierce commence à rejeter les requêtes
- La consommation de mémoire augmente fortement
- La logique de tentative répétée aggrave plutôt que d’améliorer la situation
Le véritable problème n’a jamais été l’API externe elle-même. C’était la concurrence sans aucune limite.
Le piège caché dans Promise.all
De nombreux développeurs recourent d’abord à ce pattern :
await Promise.all(urls.map(fetchData));
Cela se lit clairement et semble performant.
Mais regardez de plus près ce qui est réellement déclenché :
- Chaque requête est exécutée simultanément
- Il n’y a pas de limite au nombre de requêtes exécutées en même temps
- Il n’existe aucun mécanisme de contrôle des charges
- Les limites de vitesse définies par l’API sont complètement ignorées
En pratique, ce schéma agit comme un générateur de pics de trafic soudains.
Promise.allne vous offre pas de contrôle de la concurrence. Il provoque plutôt une explosion de la concurrence.
Étape un : Limiter la concurrence avec p-map
Si votre objectif est de restreindre le nombre d’opérations exécutées en parallèle, p-map est l’outil adapté à cette tâche.
import pMap from 'p-map';
const result = await pMap(
urls,
async (url) => {
const res = await fetch(url);
return res.json();
},
{ concurrency: 5 }
);
Ce que cela résout
- Seulement cinq requêtes s’exécutent en parallèle à tout moment
- Le reste attend dans une file d’attente jusqu’à ce qu’une place se libère
L’idée fondamentale
La concurrence ne vise pas vraiment à obtenir plus de vitesse. Elle consiste plutôt à maintenir la pression exercée sur le système sous contrôle.
Pourquoi cela seul n’est pas suffisant
La définition de concurrency: 5 ne garantit pas d’éviter les limites de fréquence.
Les API ne suivent pas seulement le nombre de requêtes arrivant en même temps, mais aussi celui qui arrive dans une fenêtre temporelle donnée.
Cela signifie qu’il faut gérer le temps, et non seulement le nombre d’appels parallèles.
Deuxième étape : Imposer des limites de fréquence avec Bottleneck
import Bottleneck from "bottleneck";
const limiter = new Bottleneck({
maxConcurrent: 5,
minTime: 200 // ~5 requests per second
});
const fetchWithLimit = limiter.wrap(async (url) => {
const res = await fetch(url);
return res.json();
});
const results = await Promise.all(urls.map(fetchWithLimit));
Ce que cela permet d’obtenir
- Restreint la concurrence
- Distribue les requêtes dans le temps
- Arrête les pics avant qu’ils ne se produisent
Au lieu d’envoyer un grand nombre de requêtes à l’API en même temps, vous les envoyez maintenant à un rythme régulier et prévisible.
p-map contre Bottleneck
Chaque outil résout un problème différent :
- p-map gère le cas où trop de promesses s’exécutent en même temps
- Bottleneck gère le cas où la limite de vitesse de l’API provoque des erreurs 429
- Lorsque vous avez besoin des deux types de protection, combinez les deux bibliothèques
Un modèle digne d’être utilisé en production
import pMap from 'p-map';
import Bottleneck from 'bottleneck';
const limiter = new Bottleneck({
minTime: 200
});
const task = limiter.wrap(async (url) => {
const res = await fetch(url);
return res.json();
});
await pMap(urls, task, { concurrency: 5 });
Cette combinaison offre :
- Une concurrence stable et limitée
- Une protection contre les dépassements des limites de vitesse
- Un profil de charge que l’on peut prédire à l’avance
Gérer les pannes : tentatives répétées avec backoff
Même un système soigneusement régulé rencontrera parfois des pannes. Ce qui distingue un système résilient d’un système fragile, c’est la manière dont il réagit à ces pannes.
async function fetchWithRetry(url, retries = 3) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Failed');
return res.json();
} catch (err) {
if (retries === 0) throw err;
// exponential backoff
const delay = 2 ** (3 - retries) * 100;
await new Promise(r => setTimeout(r, delay));
return fetchWithRetry(url, retries - 1);
}
}
Pourquoi le backoff est important ici
- Tenter à nouveau sans aucun délai a tendance à créer des tempêtes de tentatives
L’équilibre entre débit et latence
C’est ici que les véritables décisions de conception de système entrent en jeu.
- Une concurrence plus faible offre un pipeline plus sûr mais plus lent
- Une concurrence plus élevée accélère les choses mais augmente les risques
Par exemple :
- Une concurrence de 2 maintient la stabilité, bien que la vitesse soit faible
- Une concurrence de 20 accélère l’exécution mais peut dépasser les limites de fréquence
Il n’existe pas de valeur idéale universelle. Il faut ajuster ce paramètre en fonction de ce que votre système et l’API cible peuvent réellement gérer.
L’observabilité : l’étape que la plupart des équipes ignorent
Ne vous contentez pas de déployer cette configuration puis d’oublier son suivi en production.
Surveillez :
- la taille croissante de la file d’attente
- le temps moyen d’attente des requêtes
- le rapport entre les succès et les échecs
En l’absence de visibilité sur ce que fait réellement votre système, son ajustement correct devient une affaire de suppositions.
Où s’inscrivent des files d’attente comme BullMQ ou Kafka ?
C’est une question légitime à se poser.
- Les files de messages offrent un traitement asynchrone et fiable
- p-map et Bottleneck permettent un contrôle au sein d’un même service
Recourrez à une file d’attente lorsque :
- les tâches doivent survivre à une panne ou un redémarrage
- la charge de travail est à grande échelle ou répartie sur plusieurs services
Préférez l’approche p-map et Bottleneck lorsque :
- vous avez besoin d’une exécution rapide et contrôlée restant au sein d’un seul service
Impact mesuré après application de cette solution
Après avoir introduit à la fois les limites de concurrence et les limitations de débit, un système en production a constaté :
- Une baisse des taux d’erreur 429 de près de 18 % à moins de 1 %
- Une stabilité nettement améliorée lors des communications avec l’API externe
- La disparition complète des tempêtes de tentatives
La logique de l’application est restée identique. Ce qui a changé, c’est la discipline appliquée à son exécution.
Pensée finale
Rédiger du code asynchrone est une compétence de base pour les développeurs Node.js. Savoir gérer la vitesse d’exécution de ce code est ce qui distingue les bons ingénieurs backend.
Si votre service communique avec :
- des API externes
- des bases de données
- d’autres microservices
alors le contrôle de concurrence cesse d’être un plus et devient une composante essentielle de votre architecture.
Conclusion
Si votre code de production continue d’utiliser Promise.all sans aucune limitation, un pic de trafic suffit pour provoquer une panne.
Un point de départ pratique pourrait être le suivant :
- Introduire
p-mappour limiter la concurrence - Utiliser
Bottleneckpour appliquer des limites de vitesse - Ajouter une logique de tentative pour renforcer la résilience
C’est le chemin qui mène d’un code qui fonctionne simplement à un code véritablement scalable.