Retour au cours

backend / nodejs

Gestion d'erreurs centralisée

Leçon 91 exercice

Explication

Ce que vous allez apprendre

  • Distinguer une erreur métier "attendue" d'un bug véritable avec isOperational
  • Créer une hiérarchie de classes d'erreurs typées (NotFoundError, ValidationError...)
  • Centraliser le formatage de toutes les réponses d'erreur dans un middleware unique
  • Comprendre pourquoi uncaughtException/unhandledRejection sont un dernier filet, pas une stratégie
  • Éviter de dupliquer la logique de formatage d'erreur dans chaque contrôleur

Dans quel contexte ?

Sur une API en croissance, certaines routes renvoient { "error": "..." }, d'autres { "message": "..." }, et une route oublie même de renvoyer du JSON en cas d'erreur, cassant le client qui tente de parser la réponse. Cette incohérence vient du fait que chaque développeur a géré les erreurs à sa façon, route par route. Cette leçon montre comment centraliser ce traitement pour que toute l'API réponde aux erreurs de façon prévisible, avec les mêmes clés partout.

Le problème, d'abord

Sans stratégie claire, chaque route finit par gérer ses erreurs à sa façon. Certaines renvoient un texte brut, d'autres un JSON avec des clés différentes, d'autres encore laissent fuiter la stack trace complète au client.

Le résultat est une API incohérente, difficile à consommer, et potentiellement dangereuse. Des informations internes peuvent se retrouver exposées à n'importe quel client.

L'idée centrale pour corriger ça est de distinguer deux types d'erreurs très différents. Une erreur "métier", comme un utilisateur introuvable ou une validation qui échoue, est un événement NORMAL et PRÉVU du fonctionnement de l'application.

Elle mérite une réponse HTTP claire (404, 400), pas un traitement comme une catastrophe. Un vrai bug, comme une exception inattendue ou une connexion base de données perdue, est différent : il doit être loggé en détail pour investigation, mais SANS exposer ses détails techniques au client.

Type d'erreurExempleisOperationalRéponse au client
Métier (attendue)Utilisateur introuvable, validation échouéetrueStatus précis (404, 400) + message clair
Bug (inattendu)Exception non prévue, connexion DB perduefalse ou absent500 générique, détails loggés côté serveur

Prérequis

Cette leçon suppose que tu es à l'aise avec le middleware d'erreur central vu à la leçon précédente : ici, on structure les erreurs qu'il reçoit plutôt que le middleware lui-même.

Cette distinction se code très concrètement avec une propriété : isOperational. Elle marque une erreur comme "attendue" plutôt que comme un bug véritable.

Une fois cette distinction posée, comment l'organiser proprement dans le code ? Plutôt que des if dispersés partout, on crée une hiérarchie de classes d'erreurs typées (NotFoundError, ValidationError, UnauthorizedError) héritant toutes d'une classe AppError commune.

Ça permet au middleware d'erreur central de savoir immédiatement comment réagir. Un simple err instanceof AppError suffit, sans deviner ni dupliquer cette logique dans chaque route.

Grâce à ce système, chaque contrôleur peut rester simple. Il fait throw new NotFoundError(...) sans se soucier du formatage final de la réponse — cette responsabilité est entièrement déléguée au middleware d'erreur central vu dans la leçon précédente.

Il reste un dernier filet de sécurité à connaître, pour le pire des cas. uncaughtException et unhandledRejection ne sont PAS un mécanisme de gestion d'erreurs normal, mais des événements signalant qu'une erreur est passée à travers toutes les protections.

Un processus dans cet état est considéré comme incertain. La bonne pratique est de logger, fermer proprement les connexions, puis quitter le processus plutôt que de continuer à tourner dans un état potentiellement corrompu.

Piège dangereux

Ignorer unhandledRejection en se disant "ça n'arrivera jamais" laisse le processus continuer à tourner dans un état incertain après une vraie erreur non gérée. Toujours logger l'erreur, fermer proprement les connexions ouvertes, puis quitter le processus plutôt que de le laisser survivre dans un état potentiellement corrompu.

Commandes & code

Gestion d'erreurs centralisée

js
// errors/AppError.js — hiérarchie d'erreurs métier typées
export class AppError extends Error {
  constructor(message, status = 500, code = "INTERNAL_ERROR") {
    super(message);
    this.name = this.constructor.name;
    this.status = status;
    this.code = code;
    this.isOperational = true; // distingue erreur "attendue" vs bug
    Error.captureStackTrace(this, this.constructor);
  }
}

export class NotFoundError extends AppError {
  constructor(resource = "Ressource") {
    super(`${resource} introuvable`, 404, "NOT_FOUND");
  }
}

export class ValidationError extends AppError {
  constructor(details) {
    super("Données invalides", 400, "VALIDATION_ERROR");
    this.details = details;
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = "Authentification requise") {
    super(message, 401, "UNAUTHORIZED");
  }
}
js
// Utilisation dans un contrôleur — pas de try/catch dispersé partout
import { NotFoundError, ValidationError } from "../errors/AppError.js";

export async function getOrder(req, res, next) {
  try {
    const order = await db.orders.findById(req.params.id);
    if (!order) throw new NotFoundError("Commande");

    res.json(order);
  } catch (err) {
    next(err); // toujours transmettre au middleware d'erreur central
  }
}

export async function createOrder(req, res, next) {
  try {
    const { items } = req.body;
    if (!Array.isArray(items) || items.length === 0) {
      throw new ValidationError({ items: "Doit contenir au moins un article" });
    }

    const order = await db.orders.create({ items });
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
}
js
// middlewares/errorHandler.js — point d'entrée UNIQUE pour toutes les erreurs
import { AppError } from "../errors/AppError.js";

export function errorHandler(err, req, res, next) {
  // Erreur métier connue : réponse propre et prévisible
  if (err instanceof AppError) {
    const body = { error: err.message, code: err.code };
    if (err.details) body.details = err.details;
    return res.status(err.status).json(body);
  }

  // Erreur de validation d'une lib tierce (ex. Zod)
  if (err.name === "ZodError") {
    return res.status(400).json({ error: "Validation échouée", details: err.flatten() });
  }

  // Erreur inattendue : log complet, réponse générique (pas de fuite d'infos internes)
  console.error("Erreur non gérée:", err);
  res.status(500).json({ error: "Erreur interne du serveur" });
}
js
// Capturer les erreurs globales non gérées — dernier filet de sécurité
process.on("uncaughtException", (err) => {
  console.error("Exception non capturée:", err);
  // fermeture propre puis arrêt : un uncaughtException laisse le process dans un état incertain
  gracefulShutdown(() => process.exit(1));
});

process.on("unhandledRejection", (reason) => {
  console.error("Promise rejetée non gérée:", reason);
  gracefulShutdown(() => process.exit(1));
});

function gracefulShutdown(callback) {
  server.close(() => {
    db.close();
    callback();
  });
  setTimeout(() => callback(), 10_000); // force l'arrêt après 10s si le close traîne
}

Résumé

  • Une hiérarchie de classes d'erreurs (AppError et sous-classes) rend les erreurs métier explicites et typées.
  • Un middleware d'erreur central évite de dupliquer la logique de formatage de réponse d'erreur.
  • isOperational distingue une erreur "attendue" (404, validation) d'un bug véritable à investiguer.
  • uncaughtException/unhandledRejection sont un filet de sécurité, pas un mécanisme de gestion d'erreurs normal.

Exercices pratiques

1 disponible
1

Mission : unifier des réponses d'erreur incohérentes avant un audit client

Objectif : Remplacer une gestion d'erreurs incohérente par une hiérarchie AppError typée et un middleware d'erreur central unique, puis sécuriser l'arrêt du processus.

Contexte

Un client externe qui consomme l'API se plaint : GET /orders/999 (commande inexistante) renvoie {"message": "not found"}, tandis que POST /orders avec un body invalide renvoie un texte brut "Bad request: items required" sans JSON du tout, et une troisième route laisse fuiter err.stack complet dans la réponse 500 en cas de bug. Aucune route ne suit le même format, ce qui casse le SDK client généré automatiquement à partir d'un format attendu unique.

Résoudre l’exercice →