Accueil / Articles / Création d’un mécanisme de gestion des erreurs de niveau professionnel dans les applications Node.js

Création d’un mécanisme de gestion des erreurs de niveau professionnel dans les applications Node.js

Apprenez à classer les erreurs Node.js, à concevoir une hiérarchie d’erreurs personnalisée, à centraliser le traitement des erreurs asynchrones et à protéger les traces d’exécution afin d’assurer la résilience en production.

1840 mots

Dans une étape antérieure de la création d’une application Node.js résiliente, vous avez probablement organisé votre base de code en modules axés sur le domaine et séparé les aspects liés au transport des préoccupations liées à la logique métier. Cette discipline structurelle est importante, mais elle ne vous sauvera pas si une exception non capturée ou une promesse rejetée silencieusement fait planter le processus. Comme Node.js exécute le code de votre application sur un boucle d’événements à thread unique, une erreur non gérée peut provoquer la panne de l’ensemble du processus ou le laisser fonctionner dans un état corrompu et imprévisible. Cette partie se concentre sur la mise en place d’un mécanisme de gestion des erreurs et d’une résilience asynchrone adaptés aux systèmes en production.

1. Erreurs opérationnelles vs erreurs de programmation : la distinction fondamentale

                          ┌────────────────────────┐
                          │     Application        │
                          │   Encountered Error    │
                          └───────────┬────────────┘
                                      │
                     ┌────────────────┴────────────────┐
                     ▼                                 ▼
         ┌───────────────────────┐         ┌───────────────────────┐
         │  Operational Error    │         │   Programmer Error    │
         ├───────────────────────┤         ├───────────────────────┤
         │ • Invalid Input       │         │ • Syntax/Logic Bugs   │
         │ • Resource Not Found  │         │ • Cannot read null    │
         │ • DB Connection Timeout│        │ • Out of memory       │
         │ • External API Down   │         │ • Broken invariants   │
         └───────────┬───────────┘         └───────────┬───────────┘
                     │                                 │
                     ▼                                 ▼
          Handle Gracefully                 Log Stack Trace, Stop
         (Return HTTP 4xx/5xx)              Process & Let Orchestrator
                                            (PM2/K8s) Restart Node

Erreurs opérationnelles

Les erreurs opérationnelles sont les pannes que toute application fonctionnelle doit rencontrer de temps en temps. Il s’agit d’événements non mortels déclenchés par des facteurs tels que des entrées utilisateur incorrectes, un API intermédiaire hors ligne, ou un enregistrement simplement absent de la base de données.

  • Que faire : les capturer, les transformer en codes d’état ou en réponses au niveau du domaine appropriés, les enregistrer avec le degré de gravité adéquat, et permettre au processus de continuer à gérer le trafic.

Erreurs de programmation

Les erreurs de programmation sont de véritables bugs — accéder à une propriété sur undefined, passer le mauvais type de données dans une fonction, ou perdre progressivement de la mémoire.

  • Que faire : capturer l’ensemble des informations d’erreur, alerter votre système de surveillance (Sentry, Datadog, etc.), arrêter proprement le processus, et faire appel à un outil d’orchestration tel que Kubernetes, Docker Swarm ou PM2 pour lancer une instance de remplacement. Vous ne devez jamais essayer de continuer à traiter les requêtes après ce type d’erreur, car l’état en mémoire du processus ne peut plus être fiable.

2. Créer une hiérarchie d’erreurs personnalisées standardisée

Les instances Error de JavaScript basique ne contiennent pas les métadonnées nécessaires — pas de code d’état HTTP, pas de flag opérationnel, pas de code d’erreur spécifique au domaine. L’utilisation de chaînes de caractères brutes ou d’appels génériques du type new Error('Quelque chose a échoué') rend le traitement des erreurs fragile et difficile à comprendre.

La solution : Base AppError & sous-classes spécialisées

Préférez plutôt définir une classe de base AppError extensible qui enregistre le contexte d’exécution et conserve la trace d’erreur originale de V8 à l’aide de Error.captureStackTrace.

// shared/errors/AppError.js

/**
 * Base Application Error
 * All custom domain errors extend this class.
 */
class AppError extends Error {
  constructor(message, statusCode = 500, errorCode = 'INTERNAL_ERROR', isOperational = true) {
    super(message);

    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.isOperational = isOperational;

    // Retain clean stack trace in V8 engine (Node.js)
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message = 'Invalid request payload', details = []) {
    super(message, 400, 'VALIDATION_ERROR', true);
    this.details = details;
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} was not found`, 404, 'NOT_FOUND', true);
  }
}


class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'UNAUTHORIZED', true);
  }
}

class SystemBugError extends AppError {
  constructor(message = 'Critical system error encountered') {
    // Programmer errors are marked as non-operational (isOperational = false)
    super(message, 500, 'CRITICAL_BUG', false);
  }
}

module.exports = {
  AppError,
  ValidationError,
  NotFoundError,
  UnauthorizedError,
  SystemBugError
};

Pourquoi c’est important dans la logique métier

Avec cette hiérarchie en place, vos services métier peuvent générer des erreurs claires et dotées d’un sens sémantique sans avoir besoin de connaître les frameworks HTTP ou web :

// components/orders/orders.service.js
const { NotFoundError, ValidationError } = require('../../shared/errors/AppError');

async function cancelOrder({ orderId, userId }) {
  const order = await orderRepo.findById(orderId);

  if (!order) {
    throw new NotFoundError('Order');
  }

  if (order.userId !== userId) {
    throw new ValidationError('You do not have permission to cancel this order.');
  }

  if (order.status === 'SHIPPED') {
    throw new ValidationError('Cannot cancel an order that has already shipped.');
  }

  return await orderRepo.updateStatus(orderId, 'CANCELLED');
}

3. Enveloppement centralisé des erreurs par le contrôleur et middleware

Écrire try { ... } catch (err) { next(err); } dans chaque gestionnaire de route crée beaucoup de code redondant, et il est facile d’oublier un bloc catch quelque part.

La mauvaise méthode : du code générique verbeux Try-Catch

// orders.controller.js
async function getOrder(req, res, next) {
  try {
    const order = await orderService.getOrder(req.params.id);
    return res.json(order);
  } catch (err) {
    // Repeated in every single handler!
    next(err);
  }
}

La bonne méthode : gestionnaire asynchrone de haut niveau

Au lieu de cela, enveloppez vos contrôleurs dans une petite utilité asynchrone, ou utilisez le support natif des routes asynchrones disponible dans Express 5 et versions ultérieures :

// shared/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = asyncHandler;
// orders.controller.js
const asyncHandler = require('../../shared/utils/asyncHandler');
const orderService = require('./orders.service');

// Clean, zero try/catch boilerplate
const getOrder = asyncHandler(async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  res.status(200).json({ status: 'success', data: order });
});

module.exports = { getOrder };

Middleware centralisé de gestion des erreurs

Envoyez chaque erreur opérationnelle non capturée via un middleware unique et centralisé de gestion des erreurs, qui convertit ces dernières en un format de réponse JSON cohérent pour votre API.

// shared/middleware/errorHandler.js
const { logger } = require('../logger');
const { AppError } = require('../errors/AppError');

function globalErrorHandler(err, req, res, next) {
  err.statusCode = err.statusCode || 500;
  err.errorCode = err.errorCode || 'INTERNAL_SERVER_ERROR';

  // Log all errors internally
  if (err.isOperational) {
    logger.warn(`[Operational Error] ${err.name} (${err.errorCode}): ${err.message}`);
  } else {
    logger.error(`[CRITICAL PROGRAMMER ERROR] ${err.stack}`);
  }

  // Response for Operational Errors
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      status: 'error',
      code: err.errorCode,
      message: err.message,
      ...(err.details && { details: err.details })
    });
  }

  // Generic Response for Programmer/System Failures (Hide internal stacks in production)
  return res.status(500).json({
    status: 'error',
    code: 'INTERNAL_SERVER_ERROR',
    message: process.env.NODE_ENV === 'production'
      ? 'An unexpected error occurred on our server.'
      : err.message
  });
}

module.exports = globalErrorHandler;

4. Préservation des traces d’exception à travers les frontières asynchrones

L’un des bugs les plus subtils affectant à la fois le débogage et les performances dans Node.js est la perte de la trace d’exception, due au retour d’une promesse à l’intérieur d’un bloc try/catch ou d’une fonction asynchrone sans qu’elle ne soit d’abord attendue.

Le piège de la trace d’exception : return vs return await

Dans du JavaScript pur, retourner une promesse directement depuis l’intérieur d’une fonction async — sans l’attendre — fait passer outre le contexte d’appel async de cette fonction si la promesse finit par être rejetée plus loin dans la chaîne.

Dangereux : Retourner des promesses non attendues dans Try/Catch

// order.repository.js
async function findOrderById(id) {
  try {
    // ❌ BAD: Returning promise directly inside try block.
    // If db.query fails, the catch block in THIS function will NOT execute!
    return db.query('SELECT * FROM orders WHERE id = $1', [id]);
  } catch (err) {
    logger.error('Failed to query order database', err);
    throw new CustomDatabaseError(err.message);
  }
}

Pourquoi cela pose-t-il des problèmes ? Parce que db.query renvoie une promesse en attente, findOrderById retourne immédiatement, transmettant cette promesse en attente à celui qui l’a appelée. Lorsque la promesse est finalement rejetée, le bloc catch défini à l’intérieur de findOrderById n’est plus dans la pile d’appel et ne s’exécute jamais.

Correct : Attendre explicitement avant de retourner

// order.repository.js
async function findOrderById(id) {
  try {
    // ✅ GOOD: Awaiting resolves or rejects WITHIN this async frame.
    return await db.query('SELECT * FROM orders WHERE id = $1', [id]);
  } catch (err) {
    logger.error('Failed to query order database', err);
    throw new CustomDatabaseError(err.message);
  }
}

En règle générale : à l’intérieur de toute fonction async utilisant try/catch, écrivez toujours return await lorsqu’on appelle une opération asynchrone imbriquée. Cela permet de conserver l’encadrement de la pile intact et garantit que le code de nettoyage ou d’enregistrement local s’exécute réellement en cas d’échec.

5. Terminaison propre du processus et mécanismes de sécurité au niveau du runtime

Node.js met à disposition deux points d’ancrage au niveau du processus qui vous permettent de réagir lorsque quelque chose échappe à toutes les autres mesures de sécurité : uncaughtException et unhandledRejection.

Gestion des échecs au niveau du processus

// server.js
const app = require('./app');
const { logger } = require('./shared/logger');

const PORT = process.env.PORT || 3000;

const server = app.listen(PORT, () => {
  logger.info(`Server running on port ${PORT}`);
});

// 1. Intercept Unhandled Promise Rejections
process.on('unhandledRejection', (reason, promise) => {
  logger.error('UNHANDLED REJECTION! 💥 Shutting down...', reason);
  // Trigger graceful shutdown
  gracefulShutdown(1);
});

// 2. Intercept Uncaught Exceptions (Programmer Errors)
process.on('uncaughtException', (error) => {
  logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', error);
  // Trigger graceful shutdown immediately
  gracefulShutdown(1);
});

// 3. Graceful Shutdown Flow
function gracefulShutdown(exitCode = 0) {
  logger.info('Closing HTTP server and cleaning up active connections...');

  server.close(async () => {
    try {
      // Close Database Connections, Redis clients, Message Consumers
      await db.disconnect();
      await redis.quit();
      logger.info('All database connections closed cleanly.');
      process.exit(exitCode);
    } catch (err) {
      logger.error('Error during shutdown:', err);
      process.exit(1);
    }
  });

  // Force shutdown after 10 seconds if connections refuse to close
  setTimeout(() => {
    logger.error('Forced shutdown due to timeout.');
    process.exit(1);
  }, 10000);
}

Checklist d’audit de la résilience

Au préalable de définir une stratégie de test, vérifiez votre codebase en fonction des points suivants :

  • Classification des erreurs : séparez-vous clairement les pannes opérationnelles des bugs de programmation ?
  • Erreurs structurées : lancez-vous des instances de type AppError contenant des codes d’état et un indicateur opérationnel, plutôt que de simples chaînes de caractères ?
  • Gestion centralisée : les contrôleurs sont-ils enveloppés dans un gestionnaire asynchrone qui redirige les échecs vers un middleware d’erreurs partagé ?
  • Intégrité de la pile d’appel : utilisez-vous return await à l’intérieur des blocs try/catch afin que les frames de la pile asynchrone restent intacts ?
  • Discipline de fermeture : votre application écoute-t-elle les événements uncaughtException et unhandledRejection, ferme-t-elle les connexions de base de données et se termine-t-elle proprement pour permettre au gestionnaire de processus de la redémarrer ?
  • Lectures complémentaires

  • Neuf patterns de Promise pour un JavaScript asynchrone fiable en production — Découvrez des patterns pratiques de Promise — requêtes parallèles, délais d’expiration, tentatives répétées, limites de concurrence et annulation — pour créer du JavaScript asynchrone résilient, adapté à la production.
  • Corriger les problèmes de gestion des erreurs Async/Await dans le code production Node.js — Apprenez cinq erreurs courantes de gestion des erreurs avec async/await en JavaScript et Node.js qui provoquent des échecs silencieux et des conditions de course, ainsi que des solutions concrètes.
  • Découvrir le véritable problème de déficitaire dans un point de fin-fonctionnel lent en Node.js — Apprenez une méthode systématique pour suivre la latence du backend tout au long du chemin de la requête — du code Node.js aux requêtes de base de données — en utilisant des mesures de temps et EXPLAIN ANALYZE.
  • Stratégie de jeton de réapprovisionnement pour les systèmes d’authentification en Node.js — Apprenez comment concevoir, faire tourner, révoquer et stocker de manière sécurisée des jetons de réapprovisionnement en Node.js afin que le vol de jetons et la déconnexion fonctionnent réellement comme prévu.
  • Le journalisation structurée en Node.js : transformer le chaos du débogage en production en clarté — Découvrez pourquoi console.log échoue dans les applications Node.js en production et comment la journalisation structurée, les niveaux de journal et les IDs de corrélation transforment les bugs complexes en corrections rapides.