Como construir um sistema de tratamento de erros Node.js

Publicados: 2022-03-11

Não é difícil ver que algumas pessoas estão lutando para lidar com erros, e algumas estão até mesmo perdendo totalmente. Lidar com erros corretamente significa não apenas reduzir o tempo de desenvolvimento ao encontrar bugs e erros facilmente, mas também desenvolver uma base de código robusta para aplicativos de grande escala.

Em particular, os desenvolvedores do Node.js às vezes se encontram trabalhando com códigos não tão limpos enquanto lidam com vários tipos de erros, aplicando incorretamente a mesma lógica em todos os lugares para lidar com eles. Eles continuam se perguntando "O Node.js é ruim em lidar com erros?" ou se não, como lidar com eles?” Minha resposta para eles é “Não, o Node.js não é nada ruim. Isso depende de nós desenvolvedores.”

Aqui está uma das minhas soluções favoritas para isso.

Tipos de erros no Node.js

Em primeiro lugar, é necessário ter uma compreensão clara dos erros no Node.js. Em geral, os erros do Node.js são divididos em duas categorias distintas: erros operacionais e erros do programador .

  • Erros operacionais representam problemas de tempo de execução cujos resultados são esperados e devem ser tratados de forma adequada. Erros operacionais não significam que o aplicativo em si tenha bugs, mas os desenvolvedores precisam lidar com eles cuidadosamente. Exemplos de erros operacionais incluem “falta de memória”, “uma entrada inválida para um endpoint de API” e assim por diante.
  • Erros do programador representam bugs inesperados em código mal escrito. Eles significam que o próprio código tem alguns problemas para resolver e foi codificado errado. Um bom exemplo é tentar ler uma propriedade de “indefinido”. Para corrigir o problema, o código deve ser alterado. Isso é um bug que um desenvolvedor fez, não um erro operacional.

Com isso em mente, você não deve ter problemas para distinguir entre essas duas categorias de erros: erros operacionais são uma parte natural de um aplicativo e erros de programador são erros causados ​​por desenvolvedores. Uma pergunta lógica que se segue é: “Por que é útil dividi-los em duas categorias e lidar com eles?”

Sem uma compreensão clara dos erros, você pode ter vontade de reiniciar um aplicativo sempre que ocorrer um erro. Faz sentido reiniciar um aplicativo devido a erros de “Arquivo não encontrado” quando milhares de usuários estão aproveitando o aplicativo? Absolutamente não.

Mas e os erros do programador? Faz sentido manter um aplicativo em execução quando aparece um bug desconhecido que pode resultar em um efeito inesperado de bola de neve no aplicativo? Novamente, definitivamente não!

É hora de lidar com erros adequadamente

Supondo que você tenha alguma experiência com JavaScript assíncrono e Node.js, você pode ter enfrentado desvantagens ao usar retornos de chamada para lidar com erros. Eles forçam você a verificar os erros até os aninhados, causando problemas notórios de "inferno de retorno de chamada" que dificultam o acompanhamento do fluxo de código.

Usar promessas ou async/await é um bom substituto para retornos de chamada. O fluxo de código típico de async/await se parece com o seguinte:

 const doAsyncJobs = async () => { try { const result1 = await job1(); const result2 = await job2(result1); const result3 = await job3(result2); return await job4(result3); } catch (error) { console.error(error); } finally { await anywayDoThisJob(); } }

Usar o objeto Error integrado do Node.js é uma boa prática porque inclui informações intuitivas e claras sobre erros como o StackTrace, do qual a maioria dos desenvolvedores depende para rastrear a raiz de um erro. E propriedades significativas adicionais, como código de status HTTP e uma descrição estendendo a classe Error, a tornarão mais informativa.

 class BaseError extends Error { public readonly name: string; public readonly httpCode: HttpStatusCode; public readonly isOperational: boolean; constructor(name: string, httpCode: HttpStatusCode, description: string, isOperational: boolean) { super(description); Object.setPrototypeOf(this, new.target.prototype); this.name = name; this.httpCode = httpCode; this.isOperational = isOperational; Error.captureStackTrace(this); } } //free to extend the BaseError class APIError extends BaseError { constructor(name, httpCode = HttpStatusCode.INTERNAL_SERVER, isOperational = true, description = 'internal server error') { super(name, httpCode, isOperational, description); } }

Eu implementei apenas alguns códigos de status HTTP para simplificar, mas você pode adicionar mais posteriormente.

 export enum HttpStatusCode { OK = 200, BAD_REQUEST = 400, NOT_FOUND = 404, INTERNAL_SERVER = 500, }

Não há necessidade de estender BaseError ou APIError, mas não há problema em estendê-lo para erros comuns de acordo com suas necessidades e preferências pessoais.

 class HTTP400Error extends BaseError { constructor(description = 'bad request') { super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description); } }

Então, como você usa? Basta jogar isso:

 ... const user = await User.getUserById(1); if (user === null) throw new APIError( 'NOT FOUND', HttpStatusCode.NOT_FOUND, true, 'detailed explanation' );

Tratamento de erros do Node.js centralizado

Agora, estamos prontos para construir o componente principal do nosso sistema de tratamento de erros Node.js: o componente centralizado de tratamento de erros.

Geralmente é uma boa ideia construir um componente de tratamento de erros centralizado para evitar possíveis duplicações de código ao lidar com erros. O componente de tratamento de erros é responsável por tornar os erros detectados compreensíveis, por exemplo, enviando notificações aos administradores do sistema (se necessário), transferindo eventos para um serviço de monitoramento como o Sentry.io e registrando-os.

Aqui está um fluxo de trabalho básico para lidar com erros:

Tratamento de erros no Node.js: fluxo de trabalho básico

Em algumas partes do código, os erros são capturados para serem transferidos para um middleware de tratamento de erros.

 ... try { userService.addNewUser(req.body).then((newUser: User) => { res.status(200).json(newUser); }).catch((error: Error) => { next(error) }); } catch (error) { next(error); } ...

O middleware de tratamento de erros é um bom lugar para distinguir entre os tipos de erro e enviá-los para o componente centralizado de tratamento de erros. Conhecer o básico sobre como lidar com erros no middleware Express.js certamente ajudaria.

 app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => { if (!errorHandler.isTrustedError(err)) { next(err); } await errorHandler.handleError(err); });

Até agora, pode-se imaginar como deve ser o componente centralizado porque já usamos algumas de suas funções. Lembre-se de que depende totalmente de você como implementá-lo, mas pode se parecer com o seguinte:

 class ErrorHandler { public async handleError(err: Error): Promise<void> { await logger.error( 'Error message from the centralized error-handling component', err, ); await sendMailToAdminIfCritical(); await sendEventsToSentry(); } public isTrustedError(error: Error) { if (error instanceof BaseError) { return error.isOperational; } return false; } } export const errorHandler = new ErrorHandler();

Às vezes, a saída do “console.log” padrão torna difícil acompanhar os erros. Em vez disso, pode ser muito melhor imprimir erros de forma formatada para que os desenvolvedores possam entender rapidamente os problemas e garantir que eles sejam corrigidos.

No geral, isso economizará tempo dos desenvolvedores, facilitando o acompanhamento de erros e o tratamento deles, aumentando sua visibilidade. É uma boa decisão empregar um registrador personalizável como winston ou morgan.

Aqui está um registrador winston personalizado:

 const customLevels = { levels: { trace: 5, debug: 4, info: 3, warn: 2, error: 1, fatal: 0, }, colors: { trace: 'white', debug: 'green', info: 'green', warn: 'yellow', error: 'red', fatal: 'red', }, }; const formatter = winston.format.combine( winston.format.colorize(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.splat(), winston.format.printf((info) => { const { timestamp, level, message, ...meta } = info; return `${timestamp} [${level}]: ${message} ${ Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '' }`; }), ); class Logger { private logger: winston.Logger; constructor() { const prodTransport = new winston.transports.File({ filename: 'logs/error.log', level: 'error', }); const transport = new winston.transports.Console({ format: formatter, }); this.logger = winston.createLogger({ level: isDevEnvironment() ? 'trace' : 'error', levels: customLevels.levels, transports: [isDevEnvironment() ? transport : prodTransport], }); winston.addColors(customLevels.colors); } trace(msg: any, meta?: any) { this.logger.log('trace', msg, meta); } debug(msg: any, meta?: any) { this.logger.debug(msg, meta); } info(msg: any, meta?: any) { this.logger.info(msg, meta); } warn(msg: any, meta?: any) { this.logger.warn(msg, meta); } error(msg: any, meta?: any) { this.logger.error(msg, meta); } fatal(msg: any, meta?: any) { this.logger.log('fatal', msg, meta); } } export const logger = new Logger();

O que ele basicamente fornece é o registro em vários níveis diferentes de forma formatada, com cores claras e o registro em diferentes mídias de saída de acordo com o ambiente de tempo de execução. O bom disso é que você pode assistir e consultar logs usando as APIs internas do winston. Além disso, você pode usar uma ferramenta de análise de log para analisar os arquivos de log formatados para obter informações mais úteis sobre o aplicativo. É incrível, não é?

Até este ponto, discutimos principalmente como lidar com erros operacionais. E os erros do programador? A melhor maneira de lidar com esses erros é travar imediatamente e reiniciar normalmente com um reiniciador automático como o PM2 - o motivo é que os erros do programador são inesperados, pois são bugs reais que podem fazer com que o aplicativo acabe em um estado errado e se comporte de uma forma inesperada.

 process.on('uncaughtException', (error: Error) => { errorHandler.handleError(error); if (!errorHandler.isTrustedError(error)) { process.exit(1); } });

Por último, mas não menos importante, vou mencionar como lidar com rejeições e exceções de promessas não tratadas.

Você pode passar muito tempo lidando com promessas ao trabalhar em aplicativos Node.js/Express. Não é difícil ver mensagens de aviso sobre rejeições de promessas não tratadas quando você esquece de lidar com rejeições.

As mensagens de aviso não fazem muito, exceto registrar, mas é uma boa prática usar um fallback decente e assinar process.on('unhandledRejection', callback) .

O fluxo de tratamento de erros típico pode ter a seguinte aparência:

 // somewhere in the code ... User.getUserById(1).then((firstUser) => { if (firstUser.isSleeping === false) throw new Error('He is not sleeping!'); }); ... // get the unhandled rejection and throw it to another fallback handler we already have. process.on('unhandledRejection', (reason: Error, promise: Promise<any>) => { throw reason; }); process.on('uncaughtException', (error: Error) => { errorHandler.handleError(error); if (!errorHandler.isTrustedError(error)) { process.exit(1); } });

Empacotando

Quando tudo estiver dito e feito, você deve perceber que o tratamento de erros não é um extra opcional, mas sim uma parte essencial de um aplicativo, tanto no estágio de desenvolvimento quanto na produção.

A estratégia de lidar com erros em um único componente no Node.js garantirá que os desenvolvedores economizem tempo valioso e escrevam códigos limpos e fáceis de manter, evitando duplicação de código e contexto de erro ausente.

Espero que você tenha gostado de ler este artigo e achado o fluxo de trabalho e a implementação de tratamento de erros discutidos úteis para criar uma base de código robusta em Node.js.


Leitura adicional no Blog da Toptal Engineering:

  • Como usar rotas Express.js para tratamento de erros baseado em promessa