Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

Tratamento de erro centralizado no Express, num handler só

Como capturar o erro de todas as rotas num único middleware de quatro argumentos, padronizar a resposta JSON e não vazar stack trace em produção.

Rodolfo Mori11 min de leitura

Um handler de erro no Express é um app.use com quatro argumentos, declarado depois de todas as rotas. Quando uma rota chama next(erro) — ou, no Express 5, simplesmente lança —, a requisição pula todo o resto e cai nele. É ali, num lugar só, que você escolhe o status, o corpo do JSON e o que vai para o log.

Os exemplos deste artigo são a API de uma clínica veterinária: pets, tutores e agenda de consultas. Tudo foi executado no Node 24.16.0, com Express 4.21.2 e Express 5.1.0 instalados lado a lado, porque a diferença entre as duas versões é o ponto que mais confunde quem chega agora.

Uma central de ocorrências para a API inteira

Imagine um prédio em que cada sala inventa um procedimento quando falta luz: uma liga para a portaria, outra abre a janela, outra simplesmente fica no escuro. Um handler centralizado é a central de ocorrências: as salas continuam detectando o problema, mas encaminham a mesma ficha para um único lugar decidir resposta, registro e prioridade.

No Express, essa ficha é um objeto de erro e o encaminhamento acontece com next(erro) — ou com um throw assíncrono no Express 5. O nome técnico é middleware de tratamento de erro, reconhecido pelos quatro parâmetros. Ele não corrige o defeito e não elimina validação na rota; só garante uma saída consistente. Ao ler os exemplos, faça uma pergunta por erro: “quem percebeu?” e “quem decidiu o status?”. Se a mesma função faz as duas coisas em toda rota, você encontrou a repetição que esta lição vai remover.

O try/catch em toda rota é o sintoma, não a solução

Este é o código que quase toda API começa tendo. Cada rota se defende sozinha:

js
app.get('/pets/:id', (req, res) => {
  try {
    const pet = pets.find((p) => p.id === Number(req.params.id));
    if (!pet) {
      return res.status(404).json({ erro: 'Pet nao encontrado' });
    }
    res.json(pet);
  } catch (e) {
    res.status(500).json({ erro: e.message });
  }
});

app.post('/consultas', (req, res) => {
  try {
    const { petId, data } = req.body;
    if (!petId) {
      return res.status(400).json({ mensagem: 'petId e obrigatorio', ok: false });
    }
    res.status(201).json({ id: 77, petId, data });
  } catch (e) {
    res.status(500).send('Erro interno');
  }
});

Funciona. O problema aparece quando o front tenta ler as duas respostas:

bash
curl -s http://localhost:3901/pets/99
curl -s -X POST http://localhost:3901/consultas -H 'Content-Type: application/json' -d '{}'
{"erro":"Pet nao encontrado"} {"mensagem":"petId e obrigatorio","ok":false}

Duas rotas do mesmo servidor, dois formatos de erro diferentes. Uma tem erro, a outra tem mensagem e um ok que ninguém pediu. A terceira rota vai inventar o terceiro formato. E o catch genérico devolve e.message cru para o cliente, que é como um nome de coluna do banco acaba aparecendo na tela do usuário.

O try/catch não é o vilão — ele continua sendo a ferramenta certa para tratar erro dentro de uma função. O que está errado é cada rota decidir sozinha como um erro vira resposta HTTP.

O handler tem quatro argumentos, e vem por último

A rota volta a ser só a regra de negócio. Quando algo dá errado, ela chama next(erro) e sai de cena:

js
app.get('/pets/:id', (req, res, next) => {
  const pet = pets.find((p) => p.id === Number(req.params.id));
  if (!pet) {
    const erro = new Error('Pet nao encontrado');
    erro.status = 404;
    return next(erro);
  }
  res.json(pet);
});

app.get('/pets/:id/vacinas', (req, res) => {
  // erro NÃO provocado de propósito: o pet não existe e ninguém checou
  const pet = pets.find((p) => p.id === Number(req.params.id));
  res.json(pet.vacinas.map((v) => v.nome));
});

// quatro argumentos, depois de todas as rotas
app.use((erro, req, res, next) => {
  console.error(`[erro] ${req.method} ${req.originalUrl} -> ${erro.message}`);
  res.status(erro.status ?? 500).json({ erro: erro.message });
});

A segunda rota não tem try/catch nenhum e mesmo assim está protegida: o Express embrulha a chamada de cada handler síncrono num try/catch interno e manda o que for lançado para a fila de erro.

bash
curl -s http://localhost:3102/pets/99
curl -s http://localhost:3102/pets/99/vacinas
{"erro":"Pet nao encontrado"} {"erro":"Cannot read properties of undefined (reading 'vacinas')"}

E, no terminal do servidor, as duas passagens pelo mesmo lugar:

clinica ouvindo na 3102 [erro] GET /pets/99 -> Pet nao encontrado [erro] GET /pets/99/vacinas -> Cannot read properties of undefined (reading 'vacinas')

Repare que o 404 previsto e o TypeError acidental chegaram no mesmo ponto. Isso é bom e é perigoso ao mesmo tempo: a mensagem do bug foi parar na resposta. Daqui a pouco a gente separa os dois.

Três argumentos em vez de quatro: o erro que todo mundo comete

O Express identifica um handler de erro pela aridade da função, ou seja, pelo número de parâmetros declarados. Com três, ele vira middleware comum e nunca recebe o erro:

js
// FALTOU O QUARTO ARGUMENTO
app.use((erro, req, res) => {
  res.status(500).json({ erro: erro.message });
});

O sintoma não é uma exceção: é a resposta ficar em HTML, porque quem respondeu foi o handler padrão do próprio Express.

HTTP/1.1 500 Internal Server Error Content-Security-Policy: default-src 'none' X-Content-Type-Options: nosniff Content-Type: text/html; charset=utf-8 Content-Length: 1116

<!DOCTYPE html> <html lang=“en”> <head> <meta charset=“utf-8”> <title>Error</title> </head> <body> <pre>TypeError: Cannot read properties of undefined (reading &#39;vacinas&#39;)<br> &nbsp; &nbsp;at file:///private/tmp/clinica/e5/03-tres-args.js:7:16<br> &nbsp; &nbsp;at Layer.handleRequest (/private/tmp/clinica/e5/node_modules/router/lib/layer.js:152:17)<br> …

Sempre que a sua API devolver Content-Type: text/html num erro, o diagnóstico é esse: o erro não encontrou nenhum handler seu e caiu no do Express. Conte os argumentos. E note o que vai junto: o caminho absoluto do arquivo no servidor e a árvore inteira de node_modules.

Erro dentro de await: o que muda do Express 4 para o 5

Aqui está a diferença que quebra projeto real. A rota abaixo é idêntica nas duas versões — o mesmo arquivo, copiado para duas pastas com Express diferentes:

js
async function buscarNoBanco(id) {
  await new Promise((r) => setTimeout(r, 10));
  throw new Error('conexao com o banco recusada');
}

app.get('/consultas/:id', async (req, res) => {
  const consulta = await buscarNoBanco(req.params.id);
  res.json(consulta);
});

app.use((erro, req, res, next) => {
  console.error(`[erro] ${req.method} ${req.originalUrl} -> ${erro.message}`);
  res.status(500).json({ erro: 'Falha ao buscar a consulta' });
});

No Express 4.21.2, o handler nunca é chamado. A promise rejeitada não tem dono, e no Node moderno isso derruba o processo inteiro:

bash
curl -i --max-time 4 http://localhost:3204/consultas/12
curl: (52) Empty reply from server

E o terminal do servidor:

clinica ouvindo na 3204 file:///private/tmp/clinica/e4/04-async.js:7 throw new Error('conexao com o banco recusada'); ^

Error: conexao com o banco recusada at buscarNoBanco (file:///private/tmp/clinica/e4/04-async.js:7:9) at async file:///private/tmp/clinica/e4/04-async.js:11:20

Node.js v24.16.0

O cliente recebeu resposta vazia porque não sobrou servidor para responder. Em Node antigo o estrago era outro e mais silencioso: a requisição ficava pendurada até o timeout do cliente e o processo seguia vivo. Do Node 15 em diante, promise rejeitada sem catch vira exceção não tratada e mata o processo — a mesma falha, agora barulhenta.

No Express 5.1.0, mesmo arquivo, mesmo Node:

HTTP/1.1 500 Internal Server Error X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 37

{“erro”:“Falha ao buscar a consulta”}

clinica ouvindo na 3206 [erro] GET /consultas/12 -> conexao com o banco recusada

O servidor continuou de pé e a segunda requisição também respondeu 500. Essa é a mudança mais importante do Express 5 para quem escreve API: a rota async que rejeita passou a cair no handler de erro sozinha.

a rota faz Express 4.21.2 Express 5.1.0
throw síncrono vai para o handler vai para o handler
next(erro) vai para o handler vai para o handler
throw depois de await promise rejeitada sem dono; processo cai vai para o handler
Promise.reject() retornada promise rejeitada sem dono; processo cai vai para o handler

Se você está preso no Express 4, a correção é embrulhar cada handler async numa função que liga o .catch no next:

js
const rota = (handler) => (req, res, next) =>
  Promise.resolve(handler(req, res, next)).catch(next);

app.get(
  '/consultas/:id',
  rota(async (req, res) => {
    const consulta = await buscarNoBanco(req.params.id);
    res.json(consulta);
  }),
);
clinica ouvindo na 3106 [erro] GET /consultas/12 -> conexao com o banco recusada

Mesmo comportamento do Express 5, em cinco linhas. O preço é lembrar de escrever rota(...) em todo handler assíncrono — esquecer em um só devolve o crash silencioso de novo. Se async e await ainda são novidade para você, vale revisar como o await propaga a rejeição antes de seguir.

Uma classe de erro com status, código e mensagem pública

Pendurar erro.status na mão funciona, mas não diz nada sobre a intenção. O que o handler precisa saber é outra coisa: este erro foi previsto por mim, e portanto a mensagem pode ir para o cliente, ou é um bug, e a mensagem precisa ficar no log?

js
export class ErroDaApi extends Error {
  constructor({ status, codigo, mensagem }) {
    super(mensagem);
    this.name = 'ErroDaApi';
    this.status = status;
    this.codigo = codigo;
  }
}

app.get('/pets/:id', (req, res, next) => {
  const pet = pets.find((p) => p.id === Number(req.params.id));
  if (!pet) {
    return next(
      new ErroDaApi({
        status: 404,
        codigo: 'PET_NAO_ENCONTRADO',
        mensagem: `Nao existe pet com id ${req.params.id}`,
      }),
    );
  }
  res.json(pet);
});

app.post('/consultas', (req, res, next) => {
  const { petId, horario } = req.body;
  if (agenda.has(horario)) {
    return next(
      new ErroDaApi({
        status: 409,
        codigo: 'HORARIO_OCUPADO',
        mensagem: `O horario ${horario} ja esta reservado`,
      }),
    );
  }
  agenda.add(horario);
  res.status(201).json({ id: 77, petId, horario });
});

O codigo é o campo que o front realmente usa. status é HTTP e é grosso demais: 409 pode ser horário ocupado, pet duplicado ou consulta já cancelada. HORARIO_OCUPADO é específico e estável — dá para o front abrir o seletor de horários sem ler texto. Se você tem dúvida sobre qual número usar em cada caso, a lição de métodos HTTP e status code tem a tabela completa.

A resposta padronizada, e o bug que não pode vazar

Agora o handler distingue os dois mundos:

js
app.use((erro, req, res, next) => {
  const previsto = erro instanceof ErroDaApi;
  const status = previsto ? erro.status : 500;

  if (!previsto) {
    console.error(`[500] ${req.method} ${req.originalUrl}`, erro);
  }

  const corpo = {
    erro: {
      codigo: previsto ? erro.codigo : 'ERRO_INTERNO',
      mensagem: previsto ? erro.message : 'Erro interno na clinica',
      rota: `${req.method} ${req.originalUrl}`,
    },
  };

  if (process.env.NODE_ENV !== 'production') {
    corpo.erro.stack = erro.stack.split('\n').slice(0, 3);
  }

  res.status(status).json(corpo);
});

Com NODE_ENV=production, quatro requisições diferentes devolvem o mesmo envelope:

bash
curl -s -w " [status %{http_code}]\n" http://localhost:3107/pets/42
curl -s -w " [status %{http_code}]\n" -X POST http://localhost:3107/consultas \
  -H 'Content-Type: application/json' -d '{"petId":1,"horario":"2026-09-02T09:00"}'
curl -s -w " [status %{http_code}]\n" http://localhost:3107/gatos
curl -s -w " [status %{http_code}]\n" http://localhost:3107/relatorios/faturamento
{"erro":{"codigo":"PET_NAO_ENCONTRADO","mensagem":"Nao existe pet com id 42","rota":"GET /pets/42"}} [status 404] {"erro":{"codigo":"HORARIO_OCUPADO","mensagem":"O horario 2026-09-02T09:00 ja esta reservado","rota":"POST /consultas"}} [status 409] {"erro":{"codigo":"ROTA_INEXISTENTE","mensagem":"A rota GET /gatos nao existe nesta API","rota":"GET /gatos"}} [status 404] {"erro":{"codigo":"ERRO_INTERNO","mensagem":"Erro interno na clinica","rota":"GET /relatorios/faturamento"}} [status 500]

A quarta rota tem um bug de verdade: ela chama .query() em cima de null. O cliente recebeu “Erro interno na clinica” e nada mais. O detalhe ficou onde deveria — no log do servidor:

[500] GET /relatorios/faturamento TypeError: Cannot read properties of null (reading 'query') at file:///private/tmp/clinica/e5/06-api.js:49:20 at Layer.handleRequest (/private/tmp/clinica/e5/node_modules/router/lib/layer.js:152:17)

404 não é erro: é o handler que vem logo antes

A terceira resposta acima, ROTA_INEXISTENTE, não veio de rota nenhuma. Ela vem de um app.use sem caminho, registrado depois das rotas e antes do handler de erro:

js
app.use((req, res, next) => {
  next(
    new ErroDaApi({
      status: 404,
      codigo: 'ROTA_INEXISTENTE',
      mensagem: `A rota ${req.method} ${req.originalUrl} nao existe nesta API`,
    }),
  );
});

A lógica é simples: se a requisição chegou até aqui, nenhuma rota bateu. Esse middleware transforma “não achei” num ErroDaApi e entrega para o mesmo handler, para que a resposta de rota inexistente tenha o mesmo formato de todas as outras.

Erro de terceiro também cai aqui, com status próprio

Um detalhe que só aparece em produção: o express.json() também lança. Mande um corpo com JSON quebrado para a API acima:

bash
curl -s -w " [status %{http_code}]\n" -X POST http://localhost:3107/consultas \
  -H 'Content-Type: application/json' -d '{"petId":1,'
{"erro":{"codigo":"ERRO_INTERNO","mensagem":"Erro interno na clinica","rota":"POST /consultas"}} [status 500]

[500] POST /consultas SyntaxError: Expected double-quoted property name in JSON at position 11 (line 1 column 12) at JSON.parse (<anonymous>)

Culpa do cliente, mas a API assumiu como se fosse dela: 500 e log de erro interno. O SyntaxError do body-parser já vem com status: 400, e basta o handler respeitar isso:

js
const previsto = erro instanceof ErroDaApi;
const statusDeTerceiro =
  Number.isInteger(erro.status) && erro.status < 500 ? erro.status : null;
const status = previsto ? erro.status : (statusDeTerceiro ?? 500);

if (!previsto && !statusDeTerceiro) {
  console.error(`[500] ${req.method} ${req.originalUrl}`, erro);
}

const corpo = {
  erro: {
    codigo: previsto ? erro.codigo : statusDeTerceiro ? 'CORPO_INVALIDO' : 'ERRO_INTERNO',
    mensagem: previsto || statusDeTerceiro ? erro.message : 'Erro interno na clinica',
    rota: `${req.method} ${req.originalUrl}`,
  },
};
{"erro":{"codigo":"CORPO_INVALIDO","mensagem":"Expected double-quoted property name in JSON at position 11 (line 1 column 12)","rota":"POST /consultas"}} [status 400] {"erro":{"codigo":"ERRO_INTERNO","mensagem":"Erro interno na clinica","rota":"GET /relatorios/faturamento"}} [status 500]

Agora o JSON quebrado dá 400 e não polui o log, enquanto o bug de verdade continua 500. O critério status < 500 é de propósito: erro de terceiro que se declara 5xx é problema seu, não do cliente. Para o resto da validação de corpo, o caminho é validar a entrada com um schema antes de a rota rodar.

Stack em desenvolvimento, mensagem seca em produção

A única diferença entre os dois ambientes é aquele if do NODE_ENV. Com NODE_ENV=development, a mesma rota quebrada devolve as três primeiras linhas do stack dentro do JSON:

bash
NODE_ENV=development node 06-api.js
curl -s http://localhost:3107/relatorios/faturamento | json_pp
{ "erro" : { "codigo" : "ERRO_INTERNO", "mensagem" : "Erro interno na clinica", "rota" : "GET /relatorios/faturamento", "stack" : [ "TypeError: Cannot read properties of null (reading 'query')", " at file:///private/tmp/clinica/e5/06-api.js:49:20", " at Layer.handleRequest (/private/tmp/clinica/e5/node_modules/router/lib/layer.js:152:17)" ] } }

Em produção o campo stack some. Não é frescura de segurança: o stack entrega o caminho absoluto do arquivo no servidor, a estrutura de pastas do projeto e as versões das bibliotecas — três informações que ajudam quem está procurando brecha. O que o cliente precisa é do codigo; o que você precisa está no log.

O que o handler não pega

O handler de erro só enxerga o que acontece durante o ciclo de uma requisição. Fora dele, ele não existe. A rota abaixo responde e só depois quebra:

js
app.get('/lembretes/:id', (req, res) => {
  res.json({ agendado: true });
  setTimeout(() => {
    throw new Error('falha ao enviar o lembrete de vacina');
  }, 50);
});
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8

{“agendado”:true}

Cinquenta milissegundos depois, o terminal do servidor:

clinica ouvindo na 3108 file:///private/tmp/clinica/e5/07-fora.js:8 throw new Error('falha ao enviar o lembrete de vacina'); ^

Error: falha ao enviar o lembrete de vacina at Timeout._onTimeout (file:///private/tmp/clinica/e5/07-fora.js:8:11) at listOnTimeout (node:internal/timers:605:17) at process.processTimers (node:internal/timers:541:7)

Node.js v24.16.0

O cliente recebeu 200 e foi embora feliz, e o handler nem foi consultado — quando o setTimeout disparou, a requisição já tinha terminado e o Express não tinha mais nada a ver com aquela pilha. Para esse caso existem os eventos do processo:

js
const servidor = app.listen(3109, () => console.log('clinica ouvindo na 3109'));

process.on('uncaughtException', (erro) => {
  console.error('[fatal] uncaughtException:', erro.message);
  servidor.close(() => process.exit(1));
  setTimeout(() => process.exit(1), 5000).unref();
});

process.on('unhandledRejection', (motivo) => {
  console.error('[fatal] unhandledRejection:', motivo);
  servidor.close(() => process.exit(1));
  setTimeout(() => process.exit(1), 5000).unref();
});

Rodando e batendo na mesma rota, com o shell mostrando o código de saída:

bash
node 08-fatal.js; echo "[node saiu com codigo $?]"
clinica ouvindo na 3109 [fatal] uncaughtException: falha ao enviar o lembrete de vacina [node saiu com codigo 1]

Repare no que esse código não faz: ele não continua rodando. Depois de um uncaughtException, o processo está em estado desconhecido — pode haver conexão de banco pela metade e variável corrompida. O papel desses dois listeners é registrar o motivo, parar de aceitar requisição nova, deixar as em andamento terminarem e sair com código 1, para o orquestrador subir um processo limpo. servidor.close() sozinho pode nunca chamar o callback se houver conexão presa; por isso o setTimeout com unref() como prazo final.

O que fazer agora

Abra a sua API e faça três coisas, nesta ordem: confira se o app.use de erro tem os quatro parâmetros, rode npm ls express para saber em qual versão você está, e apague o primeiro try/catch de rota que encontrar, deixando o erro subir. Depois disso, a próxima peça é validar a entrada antes de a rota rodar — com o handler pronto, a validação vira só mais um ErroDaApi de 400. Para ver onde esta lição entra no percurso completo, o guia de Node.js mostra a ordem inteira, e a trilha de Node lista as lições na sequência.

  • express
  • erro
  • middleware
  • api
  • node

Perguntas frequentes

Posso ter mais de um handler de erro no mesmo app?
Pode. Eles formam uma fila igual à dos middlewares comuns: o primeiro que receber o erro decide se responde ou passa adiante com next(erro). É útil para ter um handler que só registra em log e outro, no fim, que monta a resposta.
Validação que falha devolve 400 ou 422?
Use 400 quando o corpo está malformado ou falta campo obrigatório, e 422 quando o corpo é válido mas a regra de negócio recusa. Na prática, a maioria das APIs brasileiras padroniza tudo em 400 e diferencia pelo código interno do erro. O que não vale é misturar os dois sem critério.
express-async-errors ainda faz sentido?
Só em projeto preso no Express 4. Esse pacote faz por monkey patch o que o Express 5 passou a fazer nativamente. Em projeto novo, use o Express 5 e não instale nada.
Como devolvo o mesmo erro em várias linguagens?
Devolva um código estável (PET_NAO_ENCONTRADO) e deixe o texto para o front. A mensagem em português é para o desenvolvedor ler no log; quem escreve o que o usuário vê é a interface, que já sabe o idioma dele.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, Express 4.21.2 e Express 5.1.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. Express — Error Handling — expressjs.com
  2. Express 5 — Migrating from 4.x: rejected promises — expressjs.com
  3. MDN — HTTP response status codes — developer.mozilla.org

Continue por aqui