Meilleure gestion des erreurs dans NodeJS avec les classes d'erreurs
Publié: 2022-03-10error
et comment l'utiliser pour une meilleure gestion plus efficace des erreurs dans vos applications.La gestion des erreurs est l'une de ces parties du développement logiciel qui ne reçoit pas tout à fait l'attention qu'elle mérite vraiment. Cependant, la construction d'applications robustes nécessite de gérer correctement les erreurs.
Vous pouvez vous débrouiller dans NodeJS sans gérer correctement les erreurs, mais en raison de la nature asynchrone de NodeJS, une mauvaise manipulation ou des erreurs peuvent vous causer des problèmes assez tôt, en particulier lors du débogage d'applications.
Avant de continuer, je voudrais souligner le type d'erreurs dont nous allons discuter sur la façon d'utiliser les classes d'erreurs.
Erreurs opérationnelles
Il s'agit d'erreurs découvertes lors de l'exécution d'un programme. Les erreurs opérationnelles ne sont pas des bogues et peuvent survenir de temps à autre, principalement en raison d'un ou d'une combinaison de plusieurs facteurs externes, comme le dépassement du délai d'attente d'un serveur de base de données ou un utilisateur décidant de tenter une injection SQL en saisissant des requêtes SQL dans un champ de saisie.
Vous trouverez ci-dessous d'autres exemples d'erreurs opérationnelles :
- Échec de la connexion à un serveur de base de données ;
- Entrées invalides par l'utilisateur (le serveur répond avec un code de réponse
400
) ; - Délai d'expiration de la demande ;
- Ressource introuvable (le serveur répond avec un code de réponse 404) ;
- Le serveur revient avec une réponse
500
.
Il est également intéressant de noter brièvement la contrepartie des erreurs opérationnelles.
Erreurs du programmeur
Ce sont des bogues dans le programme qui peuvent être résolus en modifiant le code. Ces types d'erreurs ne peuvent pas être gérés car ils se produisent à la suite de la rupture du code. Exemple de ces erreurs :
- Essayer de lire une propriété sur un objet qui n'est pas défini.
const user = { firstName: 'Kelvin', lastName: 'Omereshone', } console.log(user.fullName) // throws 'undefined' because the property fullName is not defined
- Invoquer ou appeler une fonction asynchrone sans rappel.
- Passer une chaîne là où un nombre était attendu.
Cet article concerne la gestion des erreurs opérationnelles dans NodeJS. La gestion des erreurs dans NodeJS est très différente de la gestion des erreurs dans d'autres langages. Cela est dû à la nature asynchrone de JavaScript et à l'ouverture de JavaScript aux erreurs. Laisse-moi expliquer:
En JavaScript, les instances de la classe d' error
ne sont pas la seule chose que vous pouvez lancer. Vous pouvez littéralement lancer n'importe quel type de données, cette ouverture n'est pas autorisée par d'autres langages.
Par exemple, un développeur JavaScript peut décider de lancer un nombre au lieu d'une instance d'objet d'erreur, comme ceci :
// bad throw 'Whoops :)'; // good throw new Error('Whoops :)')
Vous ne verrez peut-être pas le problème en lançant d'autres types de données, mais cela entraînera un temps de débogage plus difficile car vous n'obtiendrez pas de trace de pile et d'autres propriétés que l'objet Error expose et qui sont nécessaires au débogage.
Examinons quelques modèles incorrects dans la gestion des erreurs, avant de jeter un œil au modèle de classe Error et comment c'est une bien meilleure façon de gérer les erreurs dans NodeJS.
Mauvais modèle de gestion des erreurs n° 1 : mauvaise utilisation des rappels
Scénario réel : votre code dépend d'une API externe nécessitant un rappel pour obtenir le résultat attendu.
Prenons l'extrait de code ci-dessous :
'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder'); fs.writeFile('./writeFolder/foobar.txt', 'Hello World'); } write();
Jusqu'à NodeJS 8 et versions ultérieures, le code ci-dessus était légitime et les développeurs lançaient et oubliaient simplement les commandes. Cela signifie que les développeurs n'étaient pas tenus de fournir un rappel à ces appels de fonction et pouvaient donc omettre la gestion des erreurs. Que se passe-t-il lorsque le writeFolder
n'a pas été créé ? L'appel à writeFile
ne sera pas effectué et nous n'en saurions rien. Cela peut également entraîner une condition de concurrence car la première commande peut ne pas s'être terminée lorsque la deuxième commande a recommencé, vous ne le sauriez pas.
Commençons à résoudre ce problème en résolvant la condition de concurrence. Nous le ferions en rappelant la première commande mkdir
pour nous assurer que le répertoire existe bien avant d'y écrire avec la deuxième commande. Notre code ressemblerait donc à celui ci-dessous :
'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder', () => { fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write();
Bien que nous ayons résolu la condition de concurrence, nous n'avons pas encore tout à fait terminé. Notre code est toujours problématique car même si nous avons utilisé un rappel pour la première commande, nous n'avons aucun moyen de savoir si le dossier writeFolder
a été créé ou non. Si le dossier n'a pas été créé, le deuxième appel échouera à nouveau, mais nous avons encore une fois ignoré l'erreur. Nous résolvons cela en…
Gestion des erreurs avec les rappels
Afin de gérer correctement les erreurs avec les rappels, vous devez vous assurer que vous utilisez toujours l'approche de l'erreur en premier. Cela signifie que vous devez d'abord vérifier s'il y a une erreur renvoyée par la fonction avant de continuer à utiliser les données (le cas échéant) renvoyées. Voyons la mauvaise façon de procéder :
'use strict'; // Wrong const fs = require('fs'); const write = function (callback) { fs.mkdir('./writeFolder', (err, data) => { if (data) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); else callback(err) }); } write(console.log);
Le modèle ci-dessus est erroné car parfois l'API que vous appelez peut ne renvoyer aucune valeur ou renvoyer une valeur fausse comme valeur de retour valide. Cela vous ferait vous retrouver dans un cas d'erreur même si vous pourriez apparemment avoir un appel réussi de la fonction ou de l'API.
Le modèle ci-dessus est également mauvais car son utilisation consommerait votre erreur (vos erreurs ne seront pas appelées même si cela a pu se produire). Vous n'aurez également aucune idée de ce qui se passe dans votre code à la suite de ce type de modèle de gestion des erreurs. Donc, la bonne façon pour le code ci-dessus serait:
'use strict'; // Right const fs = require('fs'); const write = function (callback) { fs.mkdir('./writeFolder', (err, data) => { if (err) return callback(err) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write(console.log);
Mauvais modèle de gestion des erreurs n° 2 : mauvaise utilisation des promesses
Scénario du monde réel : vous avez donc découvert les promesses et vous pensez qu'elles sont bien meilleures que les rappels à cause de l'enfer des rappels et vous avez décidé de promettre une API externe dont dépendait votre base de code. Ou vous consommez une promesse d'une API externe ou d'une API de navigateur comme la fonction fetch().
De nos jours, nous n'utilisons pas vraiment de rappels dans nos bases de code NodeJS, nous utilisons des promesses. Réimplémentons donc notre exemple de code avec une promesse :
'use strict'; const fs = require('fs').promises; const write = function () { return fs.mkdir('./writeFolder').then(() => { fs.writeFile('./writeFolder/foobar.txt', 'Hello world!') }).catch((err) => { // catch all potential errors console.error(err) }) }
Mettons le code ci-dessus sous un microscope - nous pouvons voir que nous bifurquons de la promesse fs.mkdir
vers une autre chaîne de promesses (l'appel à fs.writeFile) sans même gérer cet appel de promesse. Vous pourriez penser qu'une meilleure façon de le faire serait:
'use strict'; const fs = require('fs').promises; const write = function () { return fs.mkdir('./writeFolder').then(() => { fs.writeFile('./writeFolder/foobar.txt', 'Hello world!').then(() => { // do something }).catch((err) => { console.error(err); }) }).catch((err) => { // catch all potential errors console.error(err) }) }
Mais ce qui précède ne serait pas à l'échelle. En effet, si nous avons plus de chaîne de promesses à appeler, nous nous retrouverions avec quelque chose de similaire à l'enfer des rappels que les promesses ont été faites pour résoudre. Cela signifie que notre code continuera à s'indenter à droite. Nous aurions une promesse d'enfer sur nos mains.
Promesse d'une API basée sur le rappel
La plupart du temps, vous voudriez promettre vous-même une API basée sur le rappel afin de mieux gérer les erreurs sur cette API. Cependant, ce n'est pas vraiment facile à faire. Prenons un exemple ci-dessous pour expliquer pourquoi.
function doesWillNotAlwaysSettle(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { if (err) { return reject(err); } if (arg === true) { resolve('I am Done') } }); }); }
D'après ce qui précède, si arg
n'est pas true
et que nous n'avons pas d'erreur lors de l'appel à la fonction doATask
, cette promesse restera bloquée, ce qui est une fuite de mémoire dans votre application.
Erreurs de synchronisation avalées dans les promesses
L'utilisation du constructeur Promise présente plusieurs difficultés. L'une de ces difficultés est ; dès qu'il est résolu ou rejeté, il ne peut pas obtenir un autre état. En effet, une promesse ne peut obtenir qu'un seul état - soit elle est en attente, soit elle est résolue/rejetée. Cela signifie que nous pouvons avoir des zones mortes dans nos promesses. Voyons cela dans le code :
function deadZonePromise(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { resolve('I'm all Done'); throw new Error('I am never reached') // Dead Zone }); }); }
D'après ce qui précède, nous voyons dès que la promesse est résolue, la ligne suivante est une zone morte et ne sera jamais atteinte. Cela signifie que toute gestion d'erreur synchrone suivante effectuée dans vos promesses sera simplement avalée et ne sera jamais levée.
Exemples concrets
Les exemples ci-dessus aident à expliquer les mauvais schémas de gestion des erreurs. Examinons le type de problèmes que vous pourriez rencontrer dans la vie réelle.
Exemple du monde réel #1 - Transformer une erreur en chaîne
Scénario : Vous avez décidé que l'erreur renvoyée par une API n'est pas vraiment assez bonne pour vous, vous avez donc décidé d'y ajouter votre propre message.
'use strict'; function readTemplate() { return new Promise(() => { databaseGet('query', function(err, data) { if (err) { reject('Template not found. Error: ', + err); } else { resolve(data); } }); }); } readTemplate();
Regardons ce qui ne va pas avec le code ci-dessus. D'après ce qui précède, nous voyons que le développeur essaie d'améliorer l'erreur générée par l'API databaseGet
en concaténant l'erreur renvoyée avec la chaîne "Template not found". Cette approche présente de nombreux inconvénients car lorsque la concaténation a été effectuée, le développeur exécute implicitement toString
sur l'objet d'erreur renvoyé. De cette façon, il perd toute information supplémentaire renvoyée par l'erreur (dites adieu à la trace de la pile). Donc, ce que le développeur a actuellement n'est qu'une chaîne qui n'est pas utile lors du débogage.
Un meilleur moyen consiste à conserver l'erreur telle quelle ou à l'envelopper dans une autre erreur que vous avez créée et à lui attacher l'erreur générée à partir de l'appel databaseGet en tant que propriété.
Exemple concret #2 : Ignorer complètement l'erreur
Scénario : Peut-être qu'un utilisateur s'inscrivant dans votre application, si une erreur se produit, vous souhaitez simplement intercepter l'erreur et afficher un message personnalisé, mais vous avez complètement ignoré l'erreur détectée sans même l'enregistrer à des fins de débogage.
router.get('/:id', function (req, res, next) { database.getData(req.params.userId) .then(function (data) { if (data.length) { res.status(200).json(data); } else { res.status(404).end(); } }) .catch(() => { log.error('db.rest/get: could not get data: ', req.params.userId); res.status(500).json({error: 'Internal server error'}); }) });
D'après ce qui précède, nous pouvons voir que l'erreur est complètement ignorée et que le code envoie 500 à l'utilisateur si l'appel à la base de données a échoué. Mais en réalité, la cause de l'échec de la base de données peut être des données malformées envoyées par l'utilisateur qui est une erreur avec le code d'état de 400.

Dans le cas ci-dessus, nous nous retrouverions dans une horreur de débogage car vous, en tant que développeur, ne sauriez pas ce qui n'allait pas. L'utilisateur ne pourra pas donner un rapport décent car l'erreur 500 interne du serveur est toujours renvoyée. Vous finiriez par perdre des heures à trouver le problème, ce qui équivaudrait à un gaspillage de temps et d'argent pour votre employeur.
Exemple concret #3 : Ne pas accepter l'erreur émise par une API
Scénario : une erreur a été générée à partir d'une API que vous utilisiez, mais vous n'acceptez pas cette erreur. Au lieu de cela, vous rassemblez et transformez l'erreur de manière à la rendre inutile à des fins de débogage.
Prenez l'exemple de code suivant ci-dessous :
async function doThings(input) { try { validate(input); try { await db.create(input); } catch (error) { error.message = `Inner error: ${error.message}` if (error instanceof Klass) { error.isKlass = true; } throw error } } catch (error) { error.message = `Could not do things: ${error.message}`; await rollback(input); throw error; } }
Il se passe beaucoup de choses dans le code ci-dessus qui conduiraient à une horreur de débogage. Nous allons jeter un coup d'oeil:
- Envelopper les blocs
try/catch
: Vous pouvez voir d'après ce qui précède que nous encapsulons le bloctry/catch
, ce qui est une très mauvaise idée. Nous essayons normalement de réduire l'utilisation des blocstry/catch
pour réduire la surface où nous aurions à gérer notre erreur (pensez-y comme la gestion des erreurs DRY) ; - Nous manipulons également le message d'erreur dans le but d'améliorer ce qui n'est pas non plus une bonne idée ;
- Nous vérifions si l'erreur est une instance de type
Klass
et dans ce cas, nous définissons une propriété booléenne de l'erreurisKlass
sur truev (mais si cette vérification réussit, l'erreur est de typeKlass
); - Nous annulons également la base de données trop tôt car, d'après la structure du code, il y a une forte tendance à ce que nous n'ayons même pas atteint la base de données lorsque l'erreur a été générée.
Voici une meilleure façon d'écrire le code ci-dessus :
async function doThings(input) { validate(input); try { await db.create(input); } catch (error) { try { await rollback(); } catch (error) { logger.log('Rollback failed', error, 'input:', input); } throw error; } }
Analysons ce que nous faisons dans l'extrait ci-dessus :
- Nous utilisons un bloc
try/catch
et ce n'est que dans le bloc catch que nous utilisons un autre bloctry/catch
qui sert de garde au cas où quelque chose se passe avec cette fonction de restauration et nous l'enregistrons ; - Enfin, nous lançons notre erreur reçue d'origine, ce qui signifie que nous ne perdons pas le message inclus dans cette erreur.
Essai
Nous voulons surtout tester notre code (soit manuellement, soit automatiquement). Mais la plupart du temps, nous ne testons que les choses positives. Pour un test robuste, vous devez également tester les erreurs et les cas extrêmes. Cette négligence est responsable des bogues qui se retrouvent dans la production, ce qui coûterait plus de temps de débogage supplémentaire.
Astuce : Assurez-vous toujours de tester non seulement les éléments positifs (obtenir un code d'état de 200 à partir d'un point de terminaison), mais également tous les cas d'erreur et tous les cas extrêmes.
Exemple concret n° 4 : Rejets non gérés
Si vous avez déjà utilisé des promesses, vous avez probablement rencontré des unhandled rejections
gérés .
Voici une introduction rapide sur les rejets non gérés. Les refus non gérés sont des refus de promesse qui n'ont pas été gérés. Cela signifie que la promesse a été rejetée mais que votre code continuera à s'exécuter.
Examinons un exemple courant du monde réel qui conduit à des rejets non gérés.
'use strict'; async function foobar() { throw new Error('foobar'); } async function baz() { throw new Error('baz') } (async function doThings() { const a = foobar(); const b = baz(); try { await a; await b; } catch (error) { // ignore all errors! } })();
Le code ci-dessus à première vue peut sembler non sujet aux erreurs. Mais en y regardant de plus près, on commence à voir un défaut. Laissez-moi vous expliquer : que se passe-t-il a
est rejeté ? Cela signifie que l' await b
n'est jamais atteinte et cela signifie que c'est un rejet non géré. Une solution possible consiste à utiliser Promise.all
sur les deux promesses. Donc, le code se lirait comme suit :
'use strict'; async function foobar() { throw new Error('foobar'); } async function baz() { throw new Error('baz') } (async function doThings() { const a = foobar(); const b = baz(); try { await Promise.all([a, b]); } catch (error) { // ignore all errors! } })();
Voici un autre scénario réel qui conduirait à une erreur de rejet de promesse non gérée :
'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return foobar() } catch { // ignoring errors again ! } } doThings();
Si vous exécutez l'extrait de code ci-dessus, vous obtiendrez un rejet de promesse non géré, et voici pourquoi : bien que ce ne soit pas évident, nous renvoyons une promesse (foobar) avant de la gérer avec le try/catch
. Ce que nous devrions faire est d'attendre la promesse que nous gérons avec le try/catch
pour que le code lise :
'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return await foobar() } catch { // ignoring errors again ! } } doThings();
Résumer les choses négatives
Maintenant que vous avez vu les mauvais modèles de gestion des erreurs et les correctifs possibles, plongeons maintenant dans le modèle de classe Error et comment il résout le problème de la mauvaise gestion des erreurs dans NodeJS.
Classes d'erreur
Dans ce modèle, nous démarrerions notre application avec une classe ApplicationError
de cette façon, nous savons que toutes les erreurs dans nos applications que nous lançons explicitement vont en hériter. Nous commencerions donc avec les classes d'erreur suivantes :
-
ApplicationError
C'est l'ancêtre de toutes les autres classes d'erreurs, c'est-à-dire que toutes les autres classes d'erreurs en héritent. -
DatabaseError
Toute erreur relative aux opérations de la base de données héritera de cette classe. -
UserFacingError
Toute erreur produite suite à l'interaction d'un utilisateur avec l'application serait héritée de cette classe.
Voici à quoi ressemblerait notre fichier de classe error
:
'use strict'; // Here is the base error classes to extend from class ApplicationError extends Error { get name() { return this.constructor.name; } } class DatabaseError extends ApplicationError { } class UserFacingError extends ApplicationError { } module.exports = { ApplicationError, DatabaseError, UserFacingError }
Cette approche nous permet de distinguer les erreurs levées par notre application. Alors maintenant, si nous voulons gérer une mauvaise erreur de requête (entrée utilisateur invalide) ou une erreur introuvable (ressource introuvable), nous pouvons hériter de la classe de base qui est UserFacingError
(comme dans le code ci-dessous).
const { UserFacingError } = require('./baseErrors') class BadRequestError extends UserFacingError { constructor(message, options = {}) { super(message); // You can attach relevant information to the error instance // (eg. the username) for (const [key, value] of Object.entries(options)) { this[key] = value; } } get statusCode() { return 400; } } class NotFoundError extends UserFacingError { constructor(message, options = {}) { super(message); // You can attach relevant information to the error instance // (eg. the username) for (const [key, value] of Object.entries(options)) { this[key] = value; } } get statusCode() { return 404 } } module.exports = { BadRequestError, NotFoundError }
L'un des avantages de l'approche par classe d' error
est que si nous lançons l'une de ces erreurs, par exemple, une NotFoundError
, chaque développeur lisant cette base de code serait en mesure de comprendre ce qui se passe à ce moment précis (s'il lit le code ).
Vous pourrez également transmettre plusieurs propriétés spécifiques à chaque classe d'erreur lors de l'instanciation de cette erreur.
Un autre avantage clé est que vous pouvez avoir des propriétés qui font toujours partie d'une classe d'erreur, par exemple, si vous recevez une erreur UserFacing, vous saurez qu'un statusCode fait toujours partie de cette classe d'erreur maintenant vous pouvez simplement l'utiliser directement dans le code plus tard.
Conseils sur l'utilisation des classes d'erreurs
- Créez votre propre module (éventuellement privé) pour chaque classe d'erreur de cette façon, vous pouvez simplement l'importer dans votre application et l'utiliser partout.
- Lancez uniquement les erreurs qui vous intéressent (les erreurs qui sont des instances de vos classes d'erreurs). De cette façon, vous savez que vos classes d'erreurs sont votre seule source de vérité et qu'elles contiennent toutes les informations nécessaires pour déboguer votre application.
- Avoir un module d'erreur abstrait est très utile car nous savons maintenant que toutes les informations nécessaires concernant les erreurs que nos applications peuvent générer sont au même endroit.
- Gérer les erreurs dans les calques. Si vous gérez des erreurs partout, vous avez une approche incohérente de la gestion des erreurs qui est difficile à suivre. Par couches, j'entends comme la base de données, les couches express/fastify/HTTP, etc.
Voyons à quoi ressemblent les classes d'erreur dans le code. Voici un exemple en express :
const { DatabaseError } = require('./error') const { NotFoundError } = require('./userFacingErrors') const { UserFacingError } = require('./error') // Express app.get('/:id', async function (req, res, next) { let data try { data = await database.getData(req.params.userId) } catch (err) { return next(err); } if (!data.length) { return next(new NotFoundError('Dataset not found')); } res.status(200).json(data) }) app.use(function (err, req, res, next) { if (err instanceof UserFacingError) { res.sendStatus(err.statusCode); // or res.status(err.statusCode).send(err.errorCode) } else { res.sendStatus(500) } // do your logic logger.error(err, 'Parameters: ', req.params, 'User data: ', req.user) });
De ce qui précède, nous tirons parti du fait qu'Express expose un gestionnaire d'erreurs global qui vous permet de gérer toutes vos erreurs en un seul endroit. Vous pouvez voir l'appel à next()
aux endroits où nous traitons les erreurs. Cet appel transmettra les erreurs au gestionnaire défini dans la section app.use
. Parce qu'express ne prend pas en charge async/wait, nous utilisons des blocs try/catch
.
Donc, à partir du code ci-dessus, pour gérer nos erreurs, nous avons juste besoin de vérifier si l'erreur qui a été lancée est une instance UserFacingError
et automatiquement nous savons qu'il y aurait un statusCode dans l'objet d'erreur et nous l'envoyons à l'utilisateur (vous voudrez peut-être d'avoir également un code d'erreur spécifique que vous pouvez transmettre au client) et c'est à peu près tout.
Vous remarquerez également que dans ce modèle (modèle de classe d' error
), toutes les autres erreurs que vous n'avez pas générées explicitement sont une erreur 500
car c'est quelque chose d'inattendu qui signifie que vous n'avez pas explicitement généré cette erreur dans votre application. De cette façon, nous sommes en mesure de distinguer les types d'erreurs qui se produisent dans nos applications.
Conclusion
Une bonne gestion des erreurs dans votre application peut vous permettre de mieux dormir la nuit et de gagner du temps de débogage. Voici quelques points clés à retenir de cet article :
- Utilisez des classes d'erreurs spécifiquement configurées pour votre application ;
- Implémenter des gestionnaires d'erreurs abstraits ;
- Utilisez toujours async/wait ;
- Rendre les erreurs expressives ;
- L'utilisateur s'engage si nécessaire ;
- Renvoyer les statuts et codes d'erreur appropriés ;
- Utilisez des crochets de promesse.
Des éléments frontaux et UX utiles, livrés une fois par semaine.
Avec des outils pour vous aider à mieux faire votre travail. Abonnez-vous et recevez les listes de contrôle de conception d'interface intelligente de Vitaly au format PDF par e-mail.
Sur le front-end et l'UX. Reconnu par 190 000 personnes.