Accueil / Articles

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.

Éditeur : REACTAPP.TOP5 min de lecture1240 mots

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.all ne 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’utilisation de la CPU et de la mémoire reste stable
  • 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
  • La tentative de réessai avec un délai d’attente permet au système de se remettre en marche de manière plus fluide.
  • 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-map pour limiter la concurrence
    • Utiliser Bottleneck pour 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.