Mejor manejo de errores en NodeJS con clases de error

Publicado: 2022-03-10
Resumen rápido ↬ Este artículo es para desarrolladores de JavaScript y NodeJS que desean mejorar el manejo de errores en sus aplicaciones. Kelvin Omereshone explica el patrón de clase de error y cómo usarlo para una forma mejor y más eficiente de manejar errores en sus aplicaciones.

El manejo de errores es una de esas partes del desarrollo de software que no recibe la atención que realmente merece. Sin embargo, la creación de aplicaciones robustas requiere el tratamiento adecuado de los errores.

Puede arreglárselas en NodeJS sin manejar adecuadamente los errores, pero debido a la naturaleza asíncrona de NodeJS, el manejo inadecuado o los errores pueden causarle problemas muy pronto, especialmente al depurar aplicaciones.

Antes de continuar, me gustaría señalar el tipo de errores que discutiremos sobre cómo utilizar las clases de error.

Errores Operacionales

Estos son errores descubiertos durante el tiempo de ejecución de un programa. Los errores operativos no son errores y pueden ocurrir de vez en cuando principalmente debido a uno o una combinación de varios factores externos, como el tiempo de espera del servidor de la base de datos o que un usuario decida intentar una inyección SQL ingresando consultas SQL en un campo de entrada.

A continuación se muestran más ejemplos de errores operativos:

  • No se pudo conectar a un servidor de base de datos;
  • Entradas no válidas por parte del usuario (el servidor responde con un código de respuesta 400 );
  • Pide tiempo fuera;
  • Recurso no encontrado (el servidor responde con un código de respuesta 404);
  • El servidor regresa con una respuesta 500 .

También vale la pena mencionar brevemente la contraparte de los errores operativos.

Errores del programador

Estos son errores en el programa que se pueden resolver cambiando el código. Estos tipos de errores no se pueden manejar porque ocurren como resultado de la ruptura del código. Ejemplo de estos errores son:

  • Intentando leer una propiedad en un objeto que no está definido.
 const user = { firstName: 'Kelvin', lastName: 'Omereshone', } console.log(user.fullName) // throws 'undefined' because the property fullName is not defined
  • Invocar o llamar a una función asincrónica sin devolución de llamada.
  • Pasar una cadena donde se esperaba un número.

Este artículo trata sobre el manejo de errores operativos en NodeJS. El manejo de errores en NodeJS es significativamente diferente del manejo de errores en otros lenguajes. Esto se debe a la naturaleza asíncrona de JavaScript y la apertura de JavaScript con errores. Dejame explicar:

En JavaScript, las instancias de la clase de error no son lo único que puede lanzar. Literalmente, puede arrojar cualquier tipo de datos, esta apertura no está permitida en otros idiomas.

Por ejemplo, un desarrollador de JavaScript puede decidir incluir un número en lugar de una instancia de objeto de error, así:

 // bad throw 'Whoops :)'; // good throw new Error('Whoops :)')

Es posible que no vea el problema de arrojar otros tipos de datos, pero hacerlo hará que la depuración sea más difícil porque no obtendrá un seguimiento de la pila y otras propiedades que expone el objeto Error que son necesarias para la depuración.

Veamos algunos patrones incorrectos en el manejo de errores, antes de echar un vistazo al patrón de clase Error y cómo es una forma mucho mejor para el manejo de errores en NodeJS.

¡Más después del salto! Continúe leyendo a continuación ↓

Mal patrón de manejo de errores n.° 1: uso incorrecto de las devoluciones de llamada

Escenario del mundo real : su código depende de una API externa que requiere una devolución de llamada para obtener el resultado que espera que devuelva.

Tomemos el siguiente fragmento de código:

 'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder'); fs.writeFile('./writeFolder/foobar.txt', 'Hello World'); } write();

Hasta NodeJS 8 y versiones posteriores, el código anterior era legítimo y los desarrolladores simplemente activaban y olvidaban los comandos. Esto significa que los desarrolladores no estaban obligados a proporcionar una devolución de llamada a tales llamadas de función y, por lo tanto, podrían omitir el manejo de errores. ¿Qué sucede cuando no se ha creado writeFolder ? No se realizará la llamada a writeFile y no sabríamos nada al respecto. Esto también podría dar como resultado una condición de carrera porque es posible que el primer comando no haya terminado cuando el segundo comando comenzó nuevamente, no lo sabría.

Comencemos a resolver este problema resolviendo la condición de carrera. Lo haríamos dando una devolución de llamada al primer comando mkdir para asegurarnos de que el directorio realmente existe antes de escribirlo con el segundo comando. Entonces nuestro código se vería como el siguiente:

 'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder', () => { fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write();

Aunque resolvimos la condición de carrera, aún no hemos terminado. Nuestro código sigue siendo problemático porque, aunque usamos una devolución de llamada para el primer comando, no tenemos forma de saber si la carpeta writeFolder se creó o no. Si no se creó la carpeta, la segunda llamada fallará nuevamente, pero aún así ignoramos el error una vez más. Resolvemos esto por…

Manejo de errores con devoluciones de llamada

Para manejar el error correctamente con las devoluciones de llamada, debe asegurarse de usar siempre el enfoque de error primero. Lo que esto significa es que primero debe verificar si la función devolvió un error antes de continuar con el uso de los datos (si corresponde). Veamos la forma incorrecta de hacer esto:

 '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);

El patrón anterior es incorrecto porque, a veces, la API a la que está llamando puede no devolver ningún valor o puede devolver un valor falso como un valor de retorno válido. Esto lo haría terminar en un caso de error aunque aparentemente tenga una llamada exitosa de la función o API.

El patrón anterior también es malo porque su uso consumiría su error (sus errores no se llamarán aunque podría haber sucedido). Tampoco tendrá idea de lo que está sucediendo en su código como resultado de este tipo de patrón de manejo de errores. Entonces, la forma correcta para el código anterior sería:

 '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);

Patrón de manejo de errores incorrecto n.° 2: uso incorrecto de las promesas

Escenario del mundo real : entonces descubrió Promises y cree que son mucho mejores que las devoluciones de llamada debido al infierno de devolución de llamada y decidió prometer alguna API externa de la que dependía su base de código. O está consumiendo una promesa de una API externa o una API de navegador como la función fetch().

En estos días, realmente no usamos devoluciones de llamada en nuestras bases de código de NodeJS, usamos promesas. Entonces, reimplementemos nuestro código de ejemplo con una promesa:

 '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) }) }

Pongamos el código anterior bajo un microscopio: podemos ver que estamos bifurcando la promesa fs.mkdir en otra cadena de promesa (la llamada a fs.writeFile) sin siquiera manejar esa llamada de promesa. Podrías pensar que una mejor manera de hacerlo sería:

 '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) }) }

Pero lo anterior no escalaría. Esto se debe a que si tuviéramos más cadenas de promesas para llamar, terminaríamos con algo similar al infierno de devolución de llamada que se hizo para resolver las promesas. Esto significa que nuestro código seguirá sangrando hacia la derecha. Tendríamos un infierno de promesas en nuestras manos.

Promete una API basada en devolución de llamada

La mayoría de las veces querrá prometer una API basada en devolución de llamada por su cuenta para manejar mejor los errores en esa API. Sin embargo, esto no es realmente fácil de hacer. Tomemos un ejemplo a continuación para explicar por qué.

 function doesWillNotAlwaysSettle(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { if (err) { return reject(err); } if (arg === true) { resolve('I am Done') } }); }); }

De lo anterior, si arg no es true y no tenemos un error de la llamada a la función doATask , entonces esta promesa simplemente se colgará, lo que es una pérdida de memoria en su aplicación.

Errores de sincronización tragados en Promises

Usar el constructor Promise tiene varias dificultades, una de estas dificultades es; tan pronto como se resuelva o se rechace, no podrá obtener otro estado. Esto se debe a que una promesa solo puede obtener un único estado: está pendiente o está resuelta/rechazada. Esto significa que podemos tener zonas muertas en nuestras promesas. Veamos esto en código:

 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 }); }); }

De lo anterior, vemos que tan pronto como se resuelve la promesa, la siguiente línea es una zona muerta y nunca se alcanzará. Esto significa que cualquier siguiente manejo de errores sincrónicos realizado en sus promesas simplemente se tragará y nunca se lanzará.

Ejemplos del mundo real

Los ejemplos anteriores ayudan a explicar los malos patrones de manejo de errores, echemos un vistazo al tipo de problemas que puede encontrar en la vida real.

Ejemplo del mundo real n.º 1: transformación de error en cadena

Escenario : decidió que el error devuelto por una API no es lo suficientemente bueno para usted, por lo que decidió agregarle su propio mensaje.

 'use strict'; function readTemplate() { return new Promise(() => { databaseGet('query', function(err, data) { if (err) { reject('Template not found. Error: ', + err); } else { resolve(data); } }); }); } readTemplate();

Veamos qué está mal con el código anterior. De lo anterior, vemos que el desarrollador está tratando de mejorar el error arrojado por la API de databaseGet de datosGet concatenando el error devuelto con la cadena "Plantilla no encontrada". Este enfoque tiene muchas desventajas porque cuando se realizó la concatenación, el desarrollador ejecuta implícitamente toString en el objeto de error devuelto. De esta manera, pierde cualquier información adicional devuelta por el error (di adiós al seguimiento de la pila). Entonces, lo que el desarrollador tiene en este momento es solo una cadena que no es útil al depurar.

Una mejor manera es mantener el error tal como está o envolverlo en otro error que haya creado y adjuntar el error generado desde la base de datos Obtener llamada como una propiedad.

Ejemplo del mundo real n.º 2: Ignorar por completo el error

Escenario : tal vez cuando un usuario se registra en su aplicación, si ocurre un error, solo desea detectar el error y mostrar un mensaje personalizado, pero ignoró por completo el error que se detectó sin siquiera iniciar sesión con fines de depuración.

 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'}); }) });

De lo anterior, podemos ver que el error se ignora por completo y el código envía 500 al usuario si falla la llamada a la base de datos. Pero en realidad, la causa de la falla de la base de datos podría ser información malformada enviada por el usuario, que es un error con el código de estado 400.

En el caso anterior, terminaríamos en un horror de depuración porque usted, como desarrollador, no sabría qué salió mal. El usuario no podrá dar un informe decente porque siempre se arroja un error interno del servidor 500. Usted terminaría perdiendo horas en encontrar el problema que equivaldrá a la pérdida de tiempo y dinero de su empleador.

Ejemplo del mundo real n.º 3: no aceptar el error arrojado desde una API

Escenario : se generó un error de una API que estaba utilizando, pero no acepta ese error, en lugar de eso, ordena y transforma el error de manera que lo haga inútil para fines de depuración.

Tome el siguiente ejemplo de código a continuación:

 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; } }

Están sucediendo muchas cosas en el código anterior que conducirían a la depuración del horror. Vamos a ver:

  • Envolviendo bloques try/catch : Puede ver en lo anterior que estamos envolviendo el bloque try/catch , lo cual es una muy mala idea. Normalmente tratamos de reducir el uso de bloques try/catch para minimizar la superficie donde tendríamos que manejar nuestro error (piense en ello como el manejo de errores SECO);
  • También estamos manipulando el mensaje de error en un intento de mejorar, lo que tampoco es una buena idea;
  • Estamos verificando si el error es una instancia de tipo Klass y, en este caso, estamos configurando una propiedad booleana del error isKlass en truev (pero si pasa esa verificación, entonces el error es del tipo Klass );
  • También estamos revirtiendo la base de datos demasiado pronto porque, a partir de la estructura del código, existe una alta tendencia a que ni siquiera hayamos accedido a la base de datos cuando se produjo el error.

A continuación se muestra una mejor manera de escribir el código anterior:

 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; } }

Analicemos lo que estamos haciendo bien en el fragmento anterior:

  • Estamos usando un bloque try/catch y solo en el bloque catch estamos usando otro bloque try/catch que sirve como protección en caso de que algo suceda con esa función de reversión y lo estamos registrando;
  • Finalmente, lanzamos nuestro error original recibido, lo que significa que no perdemos el mensaje incluido en ese error.

Pruebas

Principalmente queremos probar nuestro código (ya sea manual o automáticamente). Pero la mayoría de las veces solo estamos probando las cosas positivas. Para una prueba robusta, también debe probar errores y casos límite. Esta negligencia es responsable de que los errores lleguen a la producción, lo que costaría más tiempo de depuración adicional.

Sugerencia : siempre asegúrese de probar no solo las cosas positivas (obtener un código de estado de 200 desde un punto final), sino también todos los casos de error y todos los casos extremos.

Ejemplo del mundo real #4: Rechazos no manejados

Si ha usado promesas antes, probablemente se haya topado con unhandled rejections .

Aquí hay una introducción rápida sobre los rechazos no controlados. Los rechazos no controlados son rechazos de promesas que no se controlaron. Esto significa que la promesa fue rechazada pero su código seguirá ejecutándose.

Veamos un ejemplo común del mundo real que conduce a rechazos no controlados.

 '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! } })();

El código anterior a primera vista puede parecer que no es propenso a errores. Pero en una mirada más cercana, comenzamos a ver un defecto. Me explico: ¿Qué sucede cuando se rechaza a ? Eso significa que await b nunca se alcanza y eso significa que es un rechazo no controlado. Una posible solución es usar Promise.all en ambas promesas. Entonces el código se leería así:

 '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! } })();

Aquí hay otro escenario del mundo real que conduciría a un error de rechazo de promesa no controlado:

 'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return foobar() } catch { // ignoring errors again ! } } doThings();

Si ejecuta el fragmento de código anterior, obtendrá un rechazo de promesa no controlado, y este es el motivo: aunque no es obvio, devolvemos una promesa (foobar) antes de manejarla con try/catch . Lo que debemos hacer es esperar la promesa que estamos manejando con el try/catch para que el código diga:

 'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return await foobar() } catch { // ignoring errors again ! } } doThings();

Terminando con las cosas negativas

Ahora que ha visto patrones de manejo de errores incorrectos y posibles soluciones, profundicemos en el patrón de clase Error y cómo resuelve el problema del manejo de errores incorrecto en NodeJS.

Clases de error

En este patrón, comenzaríamos nuestra aplicación con una clase ApplicationError de esta manera sabemos que todos los errores en nuestras aplicaciones que lanzamos explícitamente se heredarán de ella. Así que empezaríamos con las siguientes clases de error:

  • ApplicationError
    Este es el ancestro de todas las demás clases de error, es decir, todas las demás clases de error heredan de él.
  • DatabaseError
    Cualquier error relacionado con las operaciones de la base de datos se heredará de esta clase.
  • UserFacingError
    Cualquier error producido como resultado de la interacción de un usuario con la aplicación se heredaría de esta clase.

Así es como se vería nuestro archivo de clase de 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 }

Este enfoque nos permite distinguir los errores arrojados por nuestra aplicación. Entonces, ahora, si queremos manejar un error de solicitud incorrecto (entrada de usuario no válida) o un error no encontrado (recurso no encontrado), podemos heredar de la clase base que es UserFacingError (como en el código a continuación).

 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 }

Uno de los beneficios del enfoque de clase de error es que si arrojamos uno de estos errores, por ejemplo, un NotFoundError , cada desarrollador que lea este código base podría comprender lo que está sucediendo en este momento (si lee el código ).

También podría pasar múltiples propiedades específicas para cada clase de error durante la instanciación de ese error.

Otro beneficio clave es que puede tener propiedades que siempre son parte de una clase de error, por ejemplo, si recibe un error de UserFacing, sabrá que un código de estado siempre es parte de esta clase de error, ahora puede usarlo directamente en el código más adelante.

Sugerencias sobre el uso de clases de error

  • Cree su propio módulo (posiblemente uno privado) para cada clase de error de esa manera, simplemente puede importarlo en su aplicación y usarlo en todas partes.
  • Lance solo los errores que le interesen (errores que son instancias de sus clases de error). De esta manera sabrá que sus clases de error son su única fuente de verdad y contiene toda la información necesaria para depurar su aplicación.
  • Tener un módulo de error abstracto es bastante útil porque ahora sabemos que toda la información necesaria sobre los errores que pueden arrojar nuestras aplicaciones está en un solo lugar.
  • Manejar errores en capas. Si maneja errores en todas partes, tiene un enfoque inconsistente para el manejo de errores que es difícil de rastrear. Por capas me refiero a como base de datos, express/fastify/capas HTTP, etc.

Veamos cómo se ven las clases de error en el código. Aquí hay un ejemplo 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 lo anterior, estamos aprovechando que Express expone un controlador de errores global que le permite manejar todos sus errores en un solo lugar. Puede ver la llamada a next() en los lugares donde estamos manejando errores. Esta llamada pasaría los errores al controlador que se define en la sección app.use . Debido a que express no es compatible con async/await, estamos usando bloques try/catch .

Entonces, a partir del código anterior, para manejar nuestros errores, solo necesitamos verificar si el error que se lanzó es una instancia de UserFacingError y automáticamente sabemos que habría un código de estado en el objeto de error y lo enviamos al usuario (es posible que desee tener un código de error específico también que puede pasar al cliente) y eso es todo.

También notará que en este patrón (patrón de clase de error ) cada otro error que no lanzó explícitamente es un error 500 porque es algo inesperado que significa que no lanzó explícitamente ese error en su aplicación. De esta manera, podemos distinguir los tipos de error que ocurren en nuestras aplicaciones.

Conclusión

El manejo adecuado de errores en su aplicación puede ayudarlo a dormir mejor por la noche y ahorrar tiempo de depuración. Aquí hay algunos puntos clave para llevar de este artículo:

  • Use clases de error configuradas específicamente para su aplicación;
  • Implementar controladores de errores abstractos;
  • Utilice siempre async/await;
  • Haga que los errores sean expresivos;
  • Usuario prometer si es necesario;
  • Devolver estados y códigos de error adecuados;
  • Haz uso de ganchos de promesa.

The Smashing Cat explorando nuevos conocimientos, en Smashing Workshops, por supuesto.

Bits útiles de front-end y UX, entregados una vez a la semana.

Con herramientas para ayudarlo a hacer mejor su trabajo. Suscríbase y obtenga el PDF de listas de verificación de diseño de interfaz inteligente de Vitaly por correo electrónico.

En front-end y UX. Con la confianza de 190.000 personas.