Async JavaScript - 08 - Gestion des erreurs asynchrones

try/catch, unhandled rejections, le silent catch anti-pattern, et comment ne plus perdre d'erreurs dans le vide.

08 - Gestion des erreurs asynchrones

Ce que tu vas apprendre

  • Pourquoi les erreurs async disparaissent silencieusement
  • Les unhandled rejections et comment les intercepter
  • Le placement stratégique du .catch()
  • L'anti-pattern du silent catch et comment l'éviter

Prerequisites

Avoir lu l'article 07 - async/await.


J'ai passe un apres-midi entier a chercher un bug en production. L'API repondait 500, les logs serveur etaient vides, Sentry ne captait rien. Le coupable : un .catch(() => {}) planque dans un service, écrit par quelqu'un (moi, six mois plus tot) qui voulait "éviter que ca plante". Ca n'a pas plante. Ca a juste silencieusement avale l'erreur et corrompu les donnees.

try/catch avec await : les bases

Le try/catch avec async/await fonctionne comme tu l'attends :

typescriptasync function fetchUser(id: string) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    logger.error("fetchUser failed", { id, error });
    throw error; // re-throw pour que l'appelant sache
  }
}

Deux détails souvent oublies :

  1. return await vs return : si tu ecris return response.json() (sans await), la Promise est retournee directement et le catch ne l'intercepte pas. Ecris return await response.json() pour que le catch couvre aussi le parsing JSON.

  2. Le catch attrape tout dans le bloc try, erreurs synchrones incluses. Un JSON.parse qui echoue, un TypeError sur un null, tout passe par le meme chemin.

Le problème des Promises non gerees

Quand une Promise est rejetee et que personne ne la catch, tu obtiens un "unhandled rejection". Avant Node.js 15, ca affichait un warning. Depuis, ca crash le process.

typescript// Ce code crash en Node.js >= 15
async function risque() {
  throw new Error("pas catchee");
}

risque(); // Promise rejetee, personne ne la gere

Le navigateur, lui, ne crash pas mais affiche une erreur dans la console. Le problème est le meme : l'erreur disparaît dans le bruit.

Intercepter les rejections non gerees

Node.js

typescriptprocess.on("unhandledRejection", (reason, promise) => {
  logger.error("Unhandled rejection", { reason });
  // En prod, tu veux probablement crasher proprement
  process.exit(1);
});

Depuis Node.js 15, le flag --unhandled-rejections=strict est le comportement par défaut : le process crash. Mais tu peux aussi utiliser --unhandled-rejections=warn pour revenir aux warnings (déconseillé en prod).

typescriptwindow.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled rejection :", event.reason);
  // Envoie a ton service de monitoring
  sendToSentry(event.reason);
  event.preventDefault(); // empeche l'erreur par defaut dans la console
});

Ces handlers sont ton filet de sécurité, pas ta stratégie de gestion d'erreurs. Si tu comptes dessus pour gerer les cas normaux, tu as un problème d'architecture.

Le placement du .catch() dans une chaîne

L'endroit ou tu places .catch() change radicalement le comportement :

typescript// Cas 1 : catch a la fin
fetch("/api/data")
  .then((r) => r.json())
  .then((data) => process(data))
  .catch((error) => handleError(error));
// Le catch intercepte les erreurs de fetch, json() ET process()

// Cas 2 : catch au milieu
fetch("/api/data")
  .then((r) => r.json())
  .catch((error) => {
    return { fallback: true }; // valeur de remplacement
  })
  .then((data) => process(data));
// Si fetch echoue, process() recoit { fallback: true }
// Si process() echoue, personne ne la catch !

Le cas 2 est un piège classique. Le .catch() retourne une valeur, ce qui resolve la Promise suivante. Le .then() qui suit s'exécuté avec cette valeur de fallback, et toute erreur dans ce .then() est non geree.

L'anti-pattern du silent catch

Le pire pattern que je vois dans du code de production :

typescript// NE FAIS PAS CA
async function loadData() {
  try {
    const data = await fetchCriticalData();
    return data;
  } catch {
    return null; // "au moins ca crash pas"
  }
}

Le problème : l'appelant recoit null et ne sait pas pourquoi. Il fait un check if (!data), affiche un message générique "donnees indisponibles", et le vrai problème (token expire, DNS qui ne resolve plus, base de donnees en feu) reste invisible.

La version correcte :

typescriptasync function loadData() {
  try {
    const data = await fetchCriticalData();
    return data;
  } catch (error) {
    logger.error("loadData failed", { error });
    throw new ApplicationError("DATA_LOAD_FAILED", { cause: error });
  }
}

Log l'erreur, re-throw avec du contexte. L'appelant décidé quoi faire : afficher un message, retenter, degrader gracieusement. Mais il sait qu'il y a eu un problème.

Erreur dans une chaîne .then()

Un piège subtil avec les callbacks de .then() :

typescriptpromise.then(
  (value) => {
    throw new Error("erreur dans le callback de succes");
  },
  (error) => {
    // Ce handler NE CATCH PAS l'erreur ci-dessus
    // Il ne gere que le rejet de `promise`
  }
);

Les deux callbacks du .then(onFulfilled, onRejected) ne se couvrent pas mutuellement. Si onFulfilled lance une erreur, onRejected ne la voit pas. C'est pour ca que .catch() a la fin de la chaîne est plus sur :

typescriptpromise
  .then((value) => {
    throw new Error("cette erreur est catchee");
  })
  .catch((error) => {
    // Intercepte les rejets de promise ET les erreurs du .then()
  });

Pattern : erreur avec contexte

Sur paltemps.fr, j'utilise des erreurs typees qui portent du contexte métier :

typescriptclass ApiError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly endpoint: string,
    message: string,
    options?: ErrorOptions
  ) {
    super(message, options);
    this.name = "ApiError";
  }
}

async function fetchPlace(id: string) {
  const response = await fetch(`/api/places/${id}`);
  if (!response.ok) {
    throw new ApiError(
      response.status,
      `/api/places/${id}`,
      `Place ${id} non trouvee`,
      { cause: await response.text() }
    );
  }
  return response.json();
}

Le cause (ES2022) permet de chainer les erreurs sans perdre la trace originale. error.cause contient l'erreur parente, comme les exceptions chainees en Java.

Le global rejection handler en pratique

Voici un pattern complet pour Node.js en production :

typescriptlet isShuttingDown = false;

process.on("unhandledRejection", (reason) => {
  console.error("UNHANDLED REJECTION:", reason);
  if (!isShuttingDown) {
    isShuttingDown = true;
    gracefulShutdown().finally(() => process.exit(1));
  }
});

process.on("uncaughtException", (error) => {
  console.error("UNCAUGHT EXCEPTION:", error);
  if (!isShuttingDown) {
    isShuttingDown = true;
    gracefulShutdown().finally(() => process.exit(1));
  }
});

Les deux handlers suivent la meme logique : log, shutdown propre, exit avec code d'erreur. Le flag isShuttingDown évité les shutdowns en cascade.

Résumé

  • return await dans un try/catch, sinon le catch ne couvre pas la Promise retournee
  • Les unhandled rejections crashent Node.js >= 15 par défaut
  • Intercepte-les avec process.on("unhandledRejection") ou window.addEventListener("unhandledrejection")
  • Le .catch() au milieu d'une chaîne resolve la suite, attention au placement
  • Le silent catch (catch { return null }) est un anti-pattern qui cache les vrais problèmes
  • Utilise Error.cause pour chainer les erreurs avec du contexte

Article précédent : 07 - async/await Article suivant : 09 - Les combinateurs de Promises

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.