- Por que a maioria das APIs trata erros do jeito errado
- Classes de erro customizadas
- Middleware centralizado de erros
- Erros assíncronos no Express 4 (e como não perdê-los)
- Erros operacionais vs. erros de programação
- Logging seguro: o que registrar sem vazar dados sensíveis
- Normalizando erros de bibliotecas de terceiros
- FAQ
- Próximos passos
Por que a maioria das APIs trata erros do jeito errado
Abra o DevTools em qualquer projeto Node.js que cresceu rápido demais e há uma boa chance de você encontrar algo assim na primeira resposta de erro que inspecionar:
{
"message": "Cannot read properties of undefined (reading 'id')",
"stack": "TypeError: Cannot read properties of undefined...\n at /app/src/controllers/user.js:42:18\n at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)"
}
Isso não é tratamento de erro. É uma falha de segurança com formatação JSON.
Você acabou de expor a estrutura de diretórios do servidor, o nome dos seus controllers, e a linha exata onde algo quebrou — tudo isso para qualquer um que esteja monitorando o tráfego ou inspecionando a resposta. Um stack trace completo em produção é o equivalente a deixar o plano da planta da sua casa colado na porta.
O problema não é a ausência de try/catch. Projetos assim normalmente têm try/catch em todo lugar. O problema é a ausência de uma estratégia de erros: sem hierarquia, sem middleware centralizado, sem distinção entre erros esperados e bugs reais. Cada desenvolvedor no time tratando erro do seu jeito, jogando res.status(500).json({ error: err.message }) e seguindo em frente.
Este artigo resolve isso do zero. Você vai sair com uma estrutura completa, testável e pronta para integrar com Sentry ou Datadog.
Classes de erro customizadas
O ponto de partida é criar uma hierarquia de erros que carregue contexto — em vez de jogar um Error genérico e deixar o middleware adivinhar o que fazer com ele.
export class AppError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean;
constructor(message: string, statusCode: number, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
A propriedade isOperational é o que separa os dois mundos: erros que você antecipou (true) e bugs que você não previu (false). Isso determina, lá na frente, se o processo deve continuar rodando ou se deve encerrar. Veja as subclasses mais usadas:
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} não encontrado`, 404);
}
}
export class ValidationError extends AppError {
public readonly fields: Record<string, string>;
constructor(fields: Record<string, string>) {
super("Dados inválidos na requisição", 422);
this.fields = fields;
}
}
export class UnauthorizedError extends AppError {
constructor(message = "Não autorizado") {
super(message, 401);
}
}
export class ConflictError extends AppError {
constructor(resource: string) {
super(`${resource} já existe`, 409);
}
}
export class RateLimitError extends AppError {
constructor() {
super("Muitas requisições. Tente novamente em instantes.", 429);
}
}
💡 Por que
Error.captureStackTrace? Sem essa chamada, o stack trace aponta para dentro da sua classe de erro — não para onde o erro foi lançado. Em debugging, isso faz diferença entre encontrar o problema em 30 segundos ou 30 minutos.
Com essa hierarquia, seus controllers ficam expressivos:
throw new NotFoundError("Usuário");
throw new ValidationError({ email: "E-mail inválido", password: "Mínimo 8 caracteres" });
throw new UnauthorizedError("Token expirado");
Cada lançamento diz exatamente o que aconteceu, qual status HTTP retornar e quais metadados carregar — sem que o middleware precise adivinhar nada.
Middleware centralizado de erros
O Express reconhece um middleware de erro pelo número de parâmetros: (err, req, res, next). Coloque um único desses no final do app.ts e todos os erros — operacionais ou não — chegam em um só lugar.
import { Request, Response, NextFunction } from "express";
import { AppError, ValidationError } from "../errors/AppError";
import { logger } from "../lib/logger";
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction
) {
if (err instanceof AppError) {
const body: Record<string, unknown> = {
status: "error",
message: err.message,
};
if (err instanceof ValidationError) {
body.fields = err.fields;
}
return res.status(err.statusCode).json(body);
}
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
requestId: req.headers["x-request-id"],
});
return res.status(500).json({
status: "error",
message: "Erro interno no servidor",
});
}
Repare na separação: erros AppError recebem resposta limpa e formatada; qualquer outra coisa — um TypeError, um erro de banco não tratado — cai no bloco de baixo, loga tudo que for útil para debugging, e retorna apenas uma mensagem genérica para o cliente.
No app.ts, o middleware de erro é sempre o último:
import express from "express";
import { userRoutes } from "./routes/user";
import { errorHandler } from "./middlewares/errorHandler";
const app = express();
app.use(express.json());
app.use("/users", userRoutes);
app.use(errorHandler);
export { app };
Com isso no lugar, seus controllers ficam enxutos. A única responsabilidade deles é lançar o erro certo:
export async function getUser(req: Request, res: Response, next: NextFunction) {
try {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError("Usuário");
res.json(user);
} catch (err) {
next(err);
}
}
Erros assíncronos no Express 4 (e como não perdê-los)
O Express 4 não captura rejeições de Promise automaticamente. Se um handler async lançar um erro sem try/catch, o processo recebe um UnhandledPromiseRejection — e dependendo da versão do Node, isso pode derrubar o servidor inteiro sem log nenhum.
app.get("/users/:id", async (req, res) => {
const user = await userService.findById(req.params.id);
res.json(user);
});
Se findById rejeitar, esse erro some. Nenhuma resposta, nenhum log, nenhum next(err). O cliente fica pendurado até o timeout.
A solução é um wrapper que envolve qualquer handler assíncrono e faz o roteamento para o next automaticamente:
import { Request, Response, NextFunction, RequestHandler } from "express";
export function asyncHandler(fn: RequestHandler): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
Uso:
app.get(
"/users/:id",
asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError("Usuário");
res.json(user);
})
);
Agora qualquer rejeição vai diretamente para o errorHandler, sem boilerplate de try/catch em cada rota.
⚠️ Express 5: A versão 5 (lançada como estável em 2024) captura rejeições de Promise nativamente — o
asyncHandlernão é necessário. Se você está iniciando um projeto novo, vale considerar a migração. Para projetos existentes em Express 4, o wrapper é indispensável.
Erros operacionais vs. erros de programação
Essa distinção é o que separa um sistema que sobrevive a falhas de um que fica em estado indefinido depois da primeira exceção não tratada.
Erros operacionais são situações que você previu e para as quais a aplicação tem uma resposta adequada: banco de dados temporariamente fora, recurso não encontrado, validação falha, rate limit atingido, serviço externo com timeout. A API deve tratar, responder corretamente e continuar rodando.
Erros de programação são bugs: undefined is not a function, propriedade acessada em null, tipo errado passado para uma função. Esses erros indicam que o código está em um estado que você não previu. Continuar executando pode corromper dados ou gerar comportamento completamente imprevisível.
Para capturar esses casos no nível do processo:
process.on("uncaughtException", (err: Error) => {
logger.fatal({
message: err.message,
stack: err.stack,
type: "uncaughtException",
});
process.exit(1);
});
process.on("unhandledRejection", (reason: unknown) => {
throw reason;
});
⚠️ Não tente se recuperar de
uncaughtException. O estado interno do processo pode estar corrompido de formas que não são visíveis. Encerre, logue, e deixe o orquestrador (PM2, Kubernetes, systemd) reiniciar o processo em um estado limpo.
A regra prática: se o erro tem isOperational = true, responda e siga. Se não tem, logue tudo e encerre.
Logging seguro: o que registrar sem vazar dados sensíveis
Um log mal configurado pode ser tão problemático quanto um stack trace exposto para o cliente. A diferença é que o log fica nos seus próprios sistemas — mas se esses sistemas forem comprometidos, ou se o log for indexado em alguma ferramenta de observabilidade sem controle de acesso adequado, você terá exposto tokens, senhas e dados pessoais.
O que nunca deve entrar no log:
logger.error({ body: req.body, error: err.message });
req.body pode conter senhas em texto plano, tokens de autenticação, números de cartão. Esse é um vetor real de vazamento.
O que logar:
logger.error({
requestId: req.headers["x-request-id"] ?? crypto.randomUUID(),
method: req.method,
path: req.path,
statusCode: err instanceof AppError ? err.statusCode : 500,
message: err.message,
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
});
Para novos projetos, prefira pino em vez de winston. É significativamente mais rápido (benchmarks mostram 3-5x em requisições de alta frequência), serializa JSON nativamente e tem suporte a redação de campos sensíveis via redact:
npm install pino pino-pretty
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL || "info",
redact: ["body.password", "body.token", "headers.authorization"],
transport:
process.env.NODE_ENV === "development"
? { target: "pino-pretty" }
: undefined,
});
A opção redact substitui automaticamente os campos listados por [Redacted] antes de gravar — mesmo que alguém esqueça de filtrar o body manualmente.
Normalizando erros de bibliotecas de terceiros
ORMs, clientes HTTP e SDKs de serviços externos lançam seus próprios tipos de erro — que não seguem sua hierarquia. Se esses erros chegam ao middleware sem tratamento, caem no bloco genérico e retornam 500, mesmo quando são erros perfeitamente previsíveis.
A solução é normalizar na camada de serviço, antes de qualquer erro chegar ao controller:
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NotFoundError, ConflictError } from "../errors/AppError";
export async function findUserById(id: string) {
try {
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return user;
} catch (err) {
if (err instanceof PrismaClientKnownRequestError) {
if (err.code === "P2025") throw new NotFoundError("Usuário");
if (err.code === "P2002") throw new ConflictError("E-mail");
}
throw err;
}
}
O mesmo padrão se aplica para Axios (AxiosError), erros de JWT (JsonWebTokenError, TokenExpiredError) e qualquer outra biblioteca. O controller nunca vê o erro da biblioteca — só vê os seus próprios tipos.
FAQ
Devo usar try/catch em todo controller ou confiar só no asyncHandler?
Use o asyncHandler como padrão para eliminar boilerplate. Adicione try/catch interno apenas quando precisar de lógica de fallback dentro do próprio handler — por exemplo, tentar uma fonte de dados alternativa antes de lançar o erro para o middleware.
O que retornar quando um erro de validação tem múltiplos campos inválidos?
Retorne todos de uma vez. O cliente não deve precisar de múltiplas requisições para descobrir que e-mail e senha estão errados ao mesmo tempo. A estrutura fields: { email: "...", password: "..." } no corpo da resposta é suficiente e não requer nenhum esquema adicional.
Como testar o middleware de erro?
Teste unitário direto: chame errorHandler(err, req, res, next) com mocks das dependências. Para integração, use supertest e valide status code e corpo para cada tipo de AppError. Cubra especialmente o caminho de erro inesperado (não-AppError) para garantir que o 500 genérico está funcionando.
Devo logar todos os erros operacionais com nível error?
Não. Um NotFoundError em alta frequência como warn ou nem logado. Reserve error para situações que exigem atenção imediata — banco fora, falha em serviço de pagamento, timeout em serviço crítico. Isso evita que alertas baseados em nível error virem ruído.
Como lidar com erros de autenticação JWT?
Normalize no middleware de autenticação, antes de chegar ao controller. TokenExpiredError vira UnauthorizedError("Token expirado"), JsonWebTokenError vira UnauthorizedError("Token inválido"). O controller nunca recebe um erro do jsonwebtoken diretamente.
Próximos passos
Se você implementar só três coisas deste artigo, que sejam:
AppErrorcomisOperational— saiba diferenciar erros esperados de bugs reais.- Middleware centralizado — um único lugar para formatar, logar e responder.
asyncHandlerem todos os handlers assíncronos — pare de perder erros silenciosamente no Express 4.
A partir daqui, o caminho natural é adicionar um requestId por requisição com crypto.randomUUID(), propagar esse ID nos logs e nas respostas de erro, e integrar com Sentry ou Datadog para correlacionar erros com traces. Isso transforma o seu sistema de logging em observabilidade de verdade — mas isso já é assunto para outro artigo.