Accueil / Articles / Correction des bugs de gestion des erreurs Async/Await dans le code en production Node.js

Correction des bugs de gestion des erreurs Async/Await dans le code en production Node.js

Découvrez cinq erreurs courantes de gestion des exceptions avec async/await en JavaScript et Node.js qui provoquent des échecs silencieux et des conditions de course, ainsi que des solutions concrètes.

1766 mots

Un système de paiement mis en place par une équipe au cours du dernier trimestre a fini par facturer les clients deux fois par semaine pour la même commande, pendant près d’un mois avant que le problème ne soit découvert. La cause racine n’était pas un passerelle de paiement peu fiable. Il s’agissait d’un bloc try/catch entourant une appel await qui faisait exactement ce pour quoi il avait été conçu : ignorer l’erreur et continuer, tandis que la logique de tentative située à plusieurs niveaux supérieurs supposait qu’une promesse résolue signifiait automatiquement succès.

Nul ne introduit délibérément un tel bug. Comme la syntaxe async/await ressemble à du code synchrone ordinaire, les développeurs en déduisent naturellement des conclusions similaires. Cependant, le modèle de gestion des erreurs sous-jacent repose toujours sur le rejet des promesses, l’planification des microtâches et des règles de suppression qui ne correspondent pas clairement à l’intuition liée aux try/catch. Même les ingénieurs expérimentés peuvent être pris au dépourvu par cela, souvent dans du code qui a déjà passé les vérifications, car ces bugs ne se manifestent que en situation de concurrence ou d’échecs partiels — précisément les scénarios que les tests unitaires ont tendance à ignorer.

Ci-dessous figurent cinq erreurs récurrentes observées dans des bases de code JavaScript et Node.js 22/24 en production, ainsi que des corrections qui fonctionnent efficacement sous un trafic réel.

Erreur 1 : Capturer les erreurs et continuer en silence

La mauvaise méthode :

async function getUserProfile(userId) {
  try {
    const res = await fetch(`/api/users/${userId}`);
    return await res.json();
  } catch (err) {
    console.error('Failed to fetch user', err);
    return null;
  }
}
async function renderDashboard(userId) {
  const profile = await getUserProfile(userId);
  // profile.name throws here if fetch failed — but the stack trace
  // now points at renderDashboard, not at the network call that actually failed
  document.title = `${profile.name}'s Dashboard`;
}

En enveloppant l’appel fetch dans un bloc catch, on transforme une erreur spécifique et traçable (une réponse 500 en provenance de /api/users/42) en une erreur vague (profile is null). Lorsqu’une exception apparaît réellement — comme TypeError: Cannot read properties of null — c’est bien loin de la cause réelle, sans aucun indice indiquant qu’une requête réseau a eu lieu. En environnement de production, cette lacune fait toute la différence entre une correction rapide en cinq minutes et une recherche fastidieuse dans les journaux d’erreur durant deux heures.

Utilisation correcte :

async function getUserProfile(userId) {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) {
    throw new Error(`Failed to fetch user ${userId}: ${res.status}`, {
      cause: { status: res.status, userId },
    });
  }
  return res.json();
}
async function renderDashboard(userId) {
  try {
    const profile = await getUserProfile(userId);
    document.title = `${profile.name}'s Dashboard`;
  } catch (err) {
    console.error('Dashboard render failed', err, err.cause);
    showErrorBanner('Could not load your profile. Please retry.');
  }
}

La fonction qui comprend le moins les requêtes réseau — la fonction de récupération des données — devrait simplement lancer une exception en cas d’erreur. La décision quant à ce que signifie réellement un « échec », qu’il s’agisse d’afficher une bannière, de réessayer la requête ou de recourir à des données en cache, relève de la fonction qui dispose d’une stratégie de récupération. L’utilisation de Error.cause (introduit en ES2022 et disponible dans tous les navigateurs modernes ainsi que dans Node.js 16.9 et ultérieurs) permet de conserver le contexte structuré au lieu de le réduire à un message de chaîne opaque.

Erreur 2 : Utiliser Promise.all alors que l’on a besoin de Promise.allSettled

La mauvaise méthode :

async function loadDashboardData(userId) {
  const [profile, orders, recommendations] = await Promise.all([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId), // a third-party service with a 2% error rate
  ]);
  return { profile, orders, recommendations };
}

Promise.all est conçu pour échouer rapidement : dès qu’une des promesses est rejetée, toute l’appelation échoue, ce qui entraîne le rejet des résultats des autres appels, même s’ils ont déjà été résolus avec succès. Ainsi, si fetchRecommendations expire, l’utilisateur perd également accès à son profil et à son historique de commandes, bien que ces deux requêtes aient en réalité été traitées sans erreur. Ce schéma est à l’origine d’une grande partie des plaintes concernant des « tableaux de bord instables » pour du code qui serait sinon parfaitement fonctionnel.

Utilisation correcte :

async function loadDashboardData(userId) {
  const results = await Promise.allSettled([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId),
  ]);

const [profile, orders, recommendations] = results.map((r) =>
    r.status === 'fulfilled' ? r.value : null
  );
  results.forEach((r, i) => {
    if (r.status === 'rejected') {
      logNonFatal(['profile', 'orders', 'recommendations'][i], r.reason);
    }
  });
  return { profile, orders, recommendations };
}

Un bon principe à retenir : n’utilisez Promise.all que lorsque chaque opération est véritablement nécessaire et qu’un résultat partiel serait sans signification, comme par exemple trois écritures constituant une seule transaction atomique. Préférez Promise.allSettled lorsque les opérations sont indépendantes les unes des autres et qu’une interface utilisateur partiellement fonctionnelle vaut mieux qu’aucune interface — ce qui, en pratique, couvre la plupart des tableaux de bord, des points d’entrée pour l’agrégation de données et des tâches de traitement par lots.

Erreur 3 : Lancer des tâches asynchrones sans jamais gérer leurs échecs

Le schéma problématique :

function handleClick(event) {
  logAnalyticsEvent(event); // returns a promise, nobody awaits it
  updateUI();
}

Ici, logAnalyticsEvent est déclaré async, ce qui signifie qu’il renvoie une promesse, que cela intéresse quelqu’un ou non. Comme personne n’ajoute de .catch, le rejet de cette promesse n’a nulle part où aller. Si le service d’analyse s’avère inaccessible, ce rejet devient un rejet de promesse non géré — que certains navigateurs ignorent discrètement, mais qui provoque immédiatement une panne dans Node.js, où les rejets non gérés terminent par défaut le processus depuis Node.js 15. Dans un gestionnaire de requête, cela signifie que chaque utilisateur qui accède à cette route reçoit une erreur 500, tout simplement à cause d’une appel en arrière-plan pour lequel personne ne s’attendait à rien.

Une version plus sûre :

function handleClick(event) {
  void logAnalyticsEvent(event).catch((err) => {
    logNonFatal('analytics', err);
  });
  updateUI();
}

Préfixer l’appel par void indique aux futurs mainteneurs ainsi qu’aux outils tels que @typescript-eslint/no-floating-promises que l’omission de await ici est intentionnelle et non due à une erreur. C’est cependant le bloc .catch qui effectue réellement le travail consistant à empêcher qu’une erreur en arrière-plan ne se transforme en panne visible. Si votre code s’exécute sous Node.js, il est également judicieux d’ajouter un écouteur de niveau supérieur process.on('unhandledRejection', ...) en tant que dernière ligne de défense — mais considérez-le comme un filet de sécurité et non comme votre stratégie principale. Son rôle est de journaliser le problème et d’avertir quelqu’un, pas de compenser l’absence d’un bloc .catch que vous auriez oublié d’écrire.

Erreur 4 : Laisser les appels asynchrones se concurrencer sans annuler ceux qui échouent

Le schéma problématique :

async function search(query) {
  const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
  renderResults(results);
}

searchInput.addEventListener('input', (e) => search(e.target.value));

Chaque frappe déclenche une nouvelle requête réseau. Comme les réponses ne sont pas garanties d’arriver dans l’ordre où elles ont été envoyées, si la recherche de "reac" se termine après celle de "react", les résultats obsolètes finissent par écraser ceux qui sont corrects à l’écran. Ce n’est pas un cas rare : sur une connexion lente ou limitée, cela se produit constamment, et c’est l’une des causes les plus fréquentes des signalements de bugs du type « la boîte de recherche affiche des résultats incorrects » dans toute application disposant d’une zone de recherche en temps réel.

Une version plus sûre :

let activeController = null;

async function search(query) {
  activeController?.abort();
  activeController = new AbortController();
  const { signal } = activeController;

try {
    const res = await fetch(`/api/search?q=${query}`, { signal });
    const results = await res.json();
    renderResults(results);
  } catch (err) {
    if (err.name !== 'AbortError') {
      logNonFatal('search', err);
    }
  }
}
searchInput.addEventListener('input', (e) => search(e.target.value));

AbortController, disponible nativement dans Node.js depuis la version 15 et pris en charge par toutes les implémentations modernes de fetch, transforme une concurrence implicite en une concurrence explicite et contrôlée : la demande la plus récente l’emporte car toutes les demandes précédentes sont activement annulées, et non parce qu’elles ont eu de la chance de gagner en termes de timing. La même logique s’applique lorsque un composant est démonté au milieu d’une demande dans React, Angular ou Vue — il faut annuler la demande lors du nettoyage plutôt que de compter sur le fait que sa réponse n’arrive jamais.

Erreur 5 : S’appuyer sur finally à la place d’un traitement correct des erreurs

Le schéma problématique :

async function processOrder(order) {
  let lock;
  try {
    lock = await acquireLock(order.id);
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    releaseLock(lock);
  }
}

À première vue, cela semble sûr, car le bloc finally s’exécute toujours et devrait toujours libérer le verrou. Mais si la fonction acquireLock lance une exception avant d’assigner une valeur, la variable lock reste undefined au moment où le bloc finally est exécuté. Appeler releaseLock(undefined) à ce stade provoque soit une deuxième erreur non liée qui masque l’échec initial, soit aucun effet — selon la manière dont le mécanisme de verrouillage est implémenté — tandis qu’un verrou complètement différent reste bloqué indéfiniment. Le bloc finally ne garantit que son exécution ; il ne précise pas si la logique de nettoyage qu’il contient est réellement valide pour tous les scénarios possibles.

Une version plus sûre :

async function processOrder(order) {
  const lock = await acquireLock(order.id); // outside try — nothing to release yet
  try {
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    await releaseLock(lock);
  }
}

Seules les opérations qui dépendent du fait qu’un verrou ait réellement été acquis devraient se trouver à l’intérieur du bloc try qui déclenche le nettoyage. Il s’agit d’un petit changement structurel, mais il sépare le nettoyage qui s’exécute précisément lorsque c’est nécessaire de celui qui peut être déclenché dans des conditions où il endommagerait plutôt que de corriger l’état.

Le résumé

Async/await n’a jamais éliminé les difficultés de gestion des erreurs en JavaScript — il les a simplement dissimulées derrière une syntaxe qui ressemble à du code synchrone. Cinq habitudes permettent de résoudre la plupart des problèmes rencontrés en production causés par ces dissimulations : lancer des types d’erreurs bien définis au lieu de les ignorer, utiliser Error.cause pour conserver la cause initiale de l’échec ; utiliser Promise.allSettled lorsque les opérations sous-jacentes ne dépendent pas les unes des autres ; ne jamais laisser une promesse non gérée sans bloc .catch ; annuler les tâches asynchrones obsolètes à l’aide de AbortController au lieu de compter sur le timing réseau pour tout résoudre ; et limiter les blocs finally à la nettoyage des états réellement acquis.

Aucune de ces pratiques n’est inhabituelle ou avancée. Ce qu’elles illustrent, c’est l’écart entre du code asynchrone capable de fonctionner dans des conditions réseau réelles et du code asynchrone qui ne fonctionne que lors d’une démonstration.

Lectures complémentaires

  • Les attaques de la chaîne d’approvisionnement npm : comment elles fonctionnent et comment se protéger avec Node.js — Explique le fonctionnement des attaques de ce type, telles que la prise de contrôle de comptes, le typosquatting et les confusions liées aux dépendances, ainsi que des mesures concrètes pour renforcer les installations de Node.js.
  • Développement local d’Azure Functions : comment résoudre les points de défaillance courants — Découvrez comment les outils principaux, les environnements de exécution linguistiques et Azurite doivent être synchronisés, et obtenez des solutions pratiques pour local.settings.json, les déclencheurs et les erreurs de débogage.
  • Choisir entre Promise.all, Promise.race et les attentes séquentielles — Découvrez quand Promise.all() accélère les API Node.js, pourquoi il échoue rapidement en cas de rejet, ainsi qu’un cadre décisionnel pour choisir le bon schéma asynchrone.
  • Corréler les journaux entre les appels asynchrones avec AsyncLocalStorage — Apprenez comment Node.js AsyncLocalStorage suit le contexte par requête, comme requestId, au-delà des limites d’await, sans devoir le transmettre manuellement à chaque fonction.