Melhor tratamento de erros no NodeJS com classes de erro

Publicados: 2022-03-10
Resumo rápido ↬ Este artigo é para desenvolvedores JavaScript e NodeJS que desejam melhorar o tratamento de erros em seus aplicativos. Kelvin Omereshone explica o padrão de classe de error e como usá-lo para uma maneira melhor e mais eficiente de lidar com erros em seus aplicativos.

O tratamento de erros é uma daquelas partes do desenvolvimento de software que não recebe a atenção que realmente merece. No entanto, construir aplicativos robustos requer lidar com os erros corretamente.

Você pode sobreviver no NodeJS sem manipular corretamente os erros, mas devido à natureza assíncrona do NodeJS, o manuseio ou erros impróprios podem causar problemas em breve - especialmente ao depurar aplicativos.

Antes de prosseguirmos, gostaria de apontar os tipos de erros que discutiremos sobre como utilizar as classes de erro.

Erros operacionais

Esses são erros descobertos durante o tempo de execução de um programa. Erros operacionais não são bugs e podem ocorrer de tempos em tempos principalmente por causa de um ou uma combinação de vários fatores externos, como um servidor de banco de dados expirando ou um usuário decidindo fazer uma tentativa de injeção de SQL inserindo consultas SQL em um campo de entrada.

Abaixo estão mais exemplos de erros operacionais:

  • Falha ao conectar a um servidor de banco de dados;
  • Entradas inválidas do usuário (servidor responde com código de resposta 400 );
  • Solicitar tempo limite;
  • Recurso não encontrado (servidor responde com código de resposta 404);
  • O servidor retorna com uma resposta 500 .

Também é digno de nota discutir brevemente a contrapartida dos Erros Operacionais.

Erros do programador

Estes são bugs no programa que podem ser resolvidos alterando o código. Esses tipos de erros não podem ser tratados porque ocorrem como resultado da quebra do código. Exemplo desses erros são:

  • Tentando ler uma propriedade em um objeto que não está definido.
 const user = { firstName: 'Kelvin', lastName: 'Omereshone', } console.log(user.fullName) // throws 'undefined' because the property fullName is not defined
  • Invocar ou chamar uma função assíncrona sem um retorno de chamada.
  • Passando uma string onde um número era esperado.

Este artigo é sobre o tratamento de erros operacionais no NodeJS. O tratamento de erros no NodeJS é significativamente diferente do tratamento de erros em outras linguagens. Isso se deve à natureza assíncrona do JavaScript e à abertura do JavaScript com erros. Deixe-me explicar:

Em JavaScript, instâncias da classe de error não são a única coisa que você pode lançar. Você pode literalmente lançar qualquer tipo de dados, essa abertura não é permitida por outras linguagens.

Por exemplo, um desenvolvedor JavaScript pode decidir lançar um número em vez de uma instância de objeto de erro, assim:

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

Você pode não ver o problema em lançar outros tipos de dados, mas isso resultará em uma depuração mais difícil porque você não obterá um rastreamento de pilha e outras propriedades que o objeto Error expõe que são necessárias para depuração.

Vejamos alguns padrões incorretos no tratamento de erros, antes de dar uma olhada no padrão da classe Error e como ele é uma maneira muito melhor de tratamento de erros no NodeJS.

Mais depois do salto! Continue lendo abaixo ↓

Padrão de tratamento de erros incorreto nº 1: uso incorreto de retornos de chamada

Cenário do mundo real : seu código depende de uma API externa que exige um retorno de chamada para obter o resultado que você espera que ele retorne.

Vamos pegar o trecho de código abaixo:

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

Até o NodeJS 8 e superior, o código acima era legítimo e os desenvolvedores simplesmente disparavam e esqueciam os comandos. Isso significa que os desenvolvedores não precisavam fornecer um retorno de chamada para essas chamadas de função e, portanto, poderiam deixar de fora o tratamento de erros. O que acontece quando o writeFolder não foi criado? A chamada para writeFile não será feita e não saberíamos nada sobre isso. Isso também pode resultar em condição de corrida porque o primeiro comando pode não ter terminado quando o segundo comando foi iniciado novamente, você não saberia.

Vamos começar a resolver este problema resolvendo a condição de corrida. Faríamos isso dando um retorno de chamada ao primeiro comando mkdir para garantir que o diretório realmente exista antes de escrever nele com o segundo comando. Assim, nosso código ficaria parecido com o abaixo:

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

Embora tenhamos resolvido a condição de corrida, ainda não terminamos. Nosso código ainda é problemático porque, embora tenhamos usado um callback para o primeiro comando, não temos como saber se a pasta writeFolder foi criada ou não. Se a pasta não foi criada, a segunda chamada falhará novamente, mas ainda assim, ignoramos o erro novamente. Resolvemos isso por…

Tratamento de erros com retornos de chamada

Para lidar com o erro corretamente com retornos de chamada, você deve sempre usar a abordagem de primeiro erro. O que isso significa é que você deve primeiro verificar se há um erro retornado da função antes de prosseguir para usar os dados (se houver) retornados. Vamos ver a maneira errada de fazer isso:

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

O padrão acima está errado porque às vezes a API que você está chamando pode não retornar nenhum valor ou pode retornar um valor falso como um valor de retorno válido. Isso faria com que você acabasse em um caso de erro, mesmo que aparentemente você tivesse uma chamada bem-sucedida da função ou API.

O padrão acima também é ruim porque seu uso consumiria seu erro (seus erros não serão chamados mesmo que possam ter acontecido). Você também não terá ideia do que está acontecendo em seu código como resultado desse tipo de padrão de tratamento de erros. Então, o caminho certo para o código acima seria:

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

Padrão de tratamento de erros incorreto nº 2: uso incorreto de promessas

Cenário do mundo real : Então você descobriu Promises e você acha que eles são muito melhores do que callbacks por causa do inferno de callback e você decidiu prometer alguma API externa da qual sua base de código dependia. Ou você está consumindo uma promessa de uma API externa ou de uma API do navegador, como a função fetch().

Atualmente, não usamos callbacks em nossas bases de código NodeJS, usamos promessas. Então, vamos reimplementar nosso código de exemplo com uma promessa:

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

Vamos colocar o código acima sob um microscópio — podemos ver que estamos ramificando a promessa fs.mkdir em outra cadeia de promessas (a chamada para fs.writeFile) sem mesmo manipular essa chamada de promessa. Você pode pensar que uma maneira melhor de fazer isso seria:

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

Mas o acima não escalaria. Isso ocorre porque se tivermos mais cadeias de promessas para chamar, acabaríamos com algo semelhante ao inferno de retorno de chamada que as promessas foram feitas para resolver. Isso significa que nosso código continuará recuando para a direita. Teríamos um inferno prometido em nossas mãos.

Prometendo uma API baseada em retorno de chamada

Na maioria das vezes, você gostaria de prometer uma API baseada em retorno de chamada por conta própria para lidar melhor com erros nessa API. No entanto, isso não é realmente fácil de fazer. Vamos dar um exemplo abaixo para explicar o porquê.

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

Do exposto, se arg não for true e não tivermos um erro da chamada para a função doATask , essa promessa será interrompida, o que é um vazamento de memória em seu aplicativo.

Erros de sincronização engolidos em promessas

Usar o construtor Promise tem várias dificuldades, uma dessas dificuldades é; assim que for resolvido ou rejeitado, não poderá obter outro estado. Isso ocorre porque uma promessa só pode obter um único estado - está pendente ou é resolvida/rejeitada. Isso significa que podemos ter zonas mortas em nossas promessas. Vamos ver isso no 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 }); }); }

Pelo exposto, vemos que assim que a promessa é resolvida, a próxima linha é uma zona morta e nunca será alcançada. Isso significa que qualquer manipulação de erro síncrona a seguir em suas promessas será engolida e nunca será lançada.

Exemplos do mundo real

Os exemplos acima ajudam a explicar padrões de tratamento de erros ruins, vamos dar uma olhada no tipo de problema que você pode ver na vida real.

Exemplo do mundo real nº 1 — Transformando erro em string

Cenário : Você decidiu que o erro retornado de uma API não é realmente bom o suficiente para você, então decidiu adicionar sua própria mensagem a ele.

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

Vamos ver o que está errado com o código acima. Pelo exposto, vemos que o desenvolvedor está tentando melhorar o erro gerado pela API databaseGet concatenando o erro retornado com a string “Template not found”. Essa abordagem tem muitas desvantagens porque quando a concatenação foi feita, o desenvolvedor implicitamente executa toString no objeto de erro retornado. Dessa forma, ele perde qualquer informação extra retornada pelo erro (diga adeus ao rastreamento de pilha). Então, o que o desenvolvedor tem agora é apenas uma string que não é útil na depuração.

Uma maneira melhor é manter o erro como está ou envolvê-lo em outro erro que você criou e anexou o erro gerado da chamada databaseGet como uma propriedade a ele.

Exemplo do mundo real nº 2: ignorando completamente o erro

Cenário : Talvez quando um usuário está se inscrevendo em seu aplicativo, se ocorrer um erro, você deseja apenas capturar o erro e mostrar uma mensagem personalizada, mas ignorou completamente o erro que foi detectado sem sequer registrá-lo para fins de depuração.

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

Pelo exposto, podemos ver que o erro é completamente ignorado e o código está enviando 500 para o usuário se a chamada para o banco de dados falhar. Mas, na realidade, a causa da falha do banco de dados pode ser dados malformados enviados pelo usuário, que é um erro com o código de status 400.

No caso acima, estaríamos acabando em um horror de depuração porque você, como desenvolvedor, não saberia o que deu errado. O usuário não poderá fornecer um relatório decente porque o erro interno do servidor 500 sempre é lançado. Você acabaria perdendo horas para encontrar o problema que equivaleria ao desperdício de tempo e dinheiro do seu empregador.

Exemplo do mundo real nº 3: não aceitar o erro gerado por uma API

Cenário : um erro foi gerado de uma API que você estava usando, mas você não aceita esse erro, em vez disso, você organiza e transforma o erro de maneira que o torna inútil para fins de depuração.

Tome o seguinte exemplo de código abaixo:

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

Muita coisa está acontecendo no código acima que levaria ao horror de depuração. Vamos dar uma olhada:

  • Envolvendo blocos try/catch : Você pode ver acima que estamos envolvendo o bloco try/catch , o que é uma péssima ideia. Normalmente tentamos reduzir o uso de blocos try/catch para minimizar a superfície onde teríamos que lidar com nosso erro (pense nisso como tratamento de erro DRY);
  • Também estamos manipulando a mensagem de erro na tentativa de melhorar o que também não é uma boa ideia;
  • Estamos verificando se o erro é uma instância do tipo Klass e neste caso, estamos configurando uma propriedade booleana do erro isKlass para truev(mas se essa verificação passar então o erro é do tipo Klass );
  • Também estamos revertendo o banco de dados muito cedo porque, pela estrutura do código, há uma grande tendência de que talvez nem tenhamos atingido o banco de dados quando o erro foi gerado.

Abaixo está uma maneira melhor de escrever o código acima:

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

Vamos analisar o que estamos fazendo certo no trecho acima:

  • Estamos usando um bloco try/catch e somente no bloco catch estamos usando outro bloco try/catch que serve como guarda caso algo aconteça com essa função de rollback e estejamos registrando isso;
  • Por fim, estamos lançando nosso erro original recebido, o que significa que não perdemos a mensagem incluída nesse erro.

Teste

Na maioria das vezes, queremos testar nosso código (manualmente ou automaticamente). Mas na maioria das vezes estamos apenas testando as coisas positivas. Para um teste robusto, você também deve testar erros e casos extremos. Essa negligência é responsável por bugs encontrarem seu caminho para a produção, o que custaria mais tempo extra de depuração.

Dica : Certifique-se sempre de testar não apenas as coisas positivas (obtendo um código de status de 200 de um endpoint), mas também todos os casos de erro e todos os casos de borda também.

Exemplo do mundo real nº 4: rejeições não tratadas

Se você já usou promessas antes, provavelmente já se unhandled rejections .

Aqui está uma cartilha rápida sobre rejeições não tratadas. Rejeições não tratadas são rejeições de promessas que não foram tratadas. Isso significa que a promessa foi rejeitada, mas seu código continuará em execução.

Vejamos um exemplo comum do mundo real que leva a rejeições não tratadas.

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

O código acima, à primeira vista, pode não parecer propenso a erros. Mas olhando mais de perto, começamos a ver um defeito. Deixe-me explicar: O que acontece quando a é rejeitado? Isso significa que o await b nunca é alcançado e isso significa que é uma rejeição sem tratamento. Uma solução possível é usar Promise.all em ambas as promessas. Então o código ficaria assim:

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

Aqui está outro cenário do mundo real que levaria a um erro de rejeição de promessa não tratado:

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

Se você executar o trecho de código acima, receberá uma rejeição de promessa não tratada, e aqui está o motivo: Embora não seja óbvio, estamos retornando uma promessa (foobar) antes de tratá-la com o try/catch . O que devemos fazer é aguardar a promessa que estamos tratando com o try/catch para que o código leia:

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

Encerrando as coisas negativas

Agora que você viu padrões errados de tratamento de erros e possíveis correções, vamos agora mergulhar no padrão de classe Error e como ele resolve o problema de tratamento errado de erros no NodeJS.

Classes de erro

Nesse padrão, iniciaríamos nosso aplicativo com uma classe ApplicationError dessa forma, sabemos que todos os erros em nossos aplicativos que lançamos explicitamente herdarão dela. Então, começaríamos com as seguintes classes de erro:

  • ApplicationError
    Este é o ancestral de todas as outras classes de erro, ou seja, todas as outras classes de erro herdam dele.
  • DatabaseError
    Qualquer erro relacionado às operações do banco de dados será herdado desta classe.
  • UserFacingError
    Qualquer erro produzido como resultado de um usuário interagindo com o aplicativo seria herdado dessa classe.

Aqui está como nosso arquivo de classe de error ficaria:

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

Essa abordagem nos permite distinguir os erros lançados pelo nosso aplicativo. Então, agora, se quisermos lidar com um erro de solicitação incorreta (entrada de usuário inválida) ou um erro não encontrado (recurso não encontrado), podemos herdar da classe base que é UserFacingError (como no código abaixo).

 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 }

Um dos benefícios da abordagem de classe de error é que, se lançarmos um desses erros, por exemplo, um NotFoundError , todo desenvolvedor que estiver lendo esta base de código será capaz de entender o que está acontecendo neste momento (se eles lerem o código ).

Você também poderá passar várias propriedades específicas para cada classe de erro durante a instanciação desse erro.

Outro benefício importante é que você pode ter propriedades que sempre fazem parte de uma classe de erro, por exemplo, se você receber um erro UserFacing, saberá que um statusCode sempre faz parte dessa classe de erro, agora você pode usá-lo diretamente no código mais tarde.

Dicas sobre como utilizar classes de erro

  • Faça seu próprio módulo (possivelmente um privado) para cada classe de erro, dessa forma você pode simplesmente importá-lo em seu aplicativo e usá-lo em qualquer lugar.
  • Lance apenas erros com os quais você se importa (erros que são instâncias de suas classes de erro). Dessa forma, você sabe que suas classes de erro são sua única fonte de verdade e contém todas as informações necessárias para depurar seu aplicativo.
  • Ter um módulo de erro abstrato é bastante útil porque agora sabemos que todas as informações necessárias sobre erros que nossos aplicativos podem lançar estão em um só lugar.
  • Lidar com erros em camadas. Se você lida com erros em todos os lugares, você tem uma abordagem inconsistente para lidar com erros que é difícil de acompanhar. Por camadas, quero dizer como banco de dados, camadas express/fastify/HTTP e assim por diante.

Vamos ver como as classes de erro aparecem no código. Aqui está um exemplo em expresso:

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

A partir do exposto, estamos aproveitando que o Express expõe um manipulador de erros global que permite lidar com todos os seus erros em um só lugar. Você pode ver a chamada para next() nos lugares em que estamos lidando com erros. Essa chamada passaria os erros para o manipulador definido na seção app.use . Como o express não suporta async/await, estamos usando blocos try/catch .

Então, a partir do código acima, para lidar com nossos erros, precisamos apenas verificar se o erro que foi lançado é uma instância UserFacingError e automaticamente sabemos que haveria um statusCode no objeto de erro e enviamos isso para o usuário (você pode querer ter também um código de erro específico que você pode passar para o cliente) e é isso.

Você também notaria que neste padrão (padrão de classe de error ) todos os outros erros que você não lançou explicitamente é um erro 500 porque é algo inesperado que significa que você não lançou explicitamente esse erro em seu aplicativo. Dessa forma, podemos distinguir os tipos de erro que ocorrem em nossas aplicações.

Conclusão

O tratamento adequado de erros em seu aplicativo pode fazer você dormir melhor à noite e economizar tempo de depuração. Aqui estão alguns pontos-chave para tirar deste artigo:

  • Use classes de erro configuradas especificamente para seu aplicativo;
  • Implemente manipuladores de erros abstratos;
  • Sempre use async/await;
  • Tornar os erros expressivos;
  • O usuário promete se necessário;
  • Retornar status e códigos de erro adequados;
  • Faça uso de ganchos de promessa.

The Smashing Cat explorando novos insights, nos Smashing Workshops, é claro.

Bits úteis de front-end e UX, entregues uma vez por semana.

Com ferramentas para ajudá-lo a fazer seu trabalho melhor. Assine e receba o PDF das listas de verificação de design de interface inteligente da Vitaly por e-mail.

No front-end e UX. Confiado por 190.000 pessoas.