Tratamento global de erro no ASP.NET Core: uma resposta só, um id no log
Configure UseExceptionHandler, ProblemDetails e IExceptionHandler para que toda exceção não tratada vire um 500 limpo, com um id que leva direto à linha certa do log.
O aplicativo da loja chama POST /pedidos e recebe um 500 com corpo vazio. O suporte abre o log e encontra 340 exceções na última hora, sem saber qual é a desse cliente. Em outro endpoint, um try/catch caseiro devolve 200 com {"erro": "deu ruim"}, e o app mostra "pedido confirmado". A resposta curta: no ASP.NET Core, registre um tratamento global com UseExceptionHandler no início do pipeline, responda no formato ProblemDetails com o traceId da requisição e grave a exceção completa no log com esse mesmo id. Validação esperada não passa por ali: ela devolve 400 antes de estourar.
Este guia monta isso para a API de pedidos, em .NET 8 ou mais novo, e explica por que o tratamento precisa ficar na borda e o que acontece quando a resposta já começou a ser enviada.
Por que o erro precisa ser tratado num lugar só
No ASP.NET Core, cada requisição atravessa uma fila de middlewares, na ordem em que foram registrados. Cada um chama o próximo e pode agir antes e depois dele. Uma exceção lançada num controller sobe por essa fila, de volta, até alguém capturá-la. Se ninguém capturar, o servidor devolve um 500 sem corpo. Em desenvolvimento, a página de exceção do desenvolvedor mostra a pilha completa, com caminhos de arquivo.
Por isso o tratamento global tem de ser um dos primeiros middlewares registrados: ele envolve todo o resto e enxerga qualquer exceção que escape. Tratar erro em cada endpoint dá respostas diferentes para o mesmo problema, esquece endpoints novos e, quase sempre, esconde a falha devolvendo 200.
Quando a resposta já começou
O status HTTP e os cabeçalhos são enviados antes do corpo. Se o endpoint já escreveu parte da resposta (um download, um JSON grande em streaming) e só então estourou, não há como trocar o 200 por 500. A propriedade Response.HasStarted indica isso. O middleware de exceção do ASP.NET Core confere essa propriedade e, se a resposta já começou, apenas registra e deixa a conexão ser abortada. Para endpoints de streaming, valide tudo o que puder antes de escrever o primeiro byte.
Como configurar o tratamento global
- Registre o serviço de ProblemDetails com
AddProblemDetails(). Ele padroniza o corpo de erro no formato da RFC 9457 (antiga 7807). - Crie uma classe que implementa
IExceptionHandlerpara registrar a exceção com o id da requisição e escrever a resposta. - Registre a classe com
AddExceptionHandler<T>(). - Chame
UseExceptionHandler()logo no início do pipeline, antes de roteamento, autenticação e endpoints. - Adicione
UseStatusCodePages()para que 404 e 405 sem corpo também saiam como ProblemDetails. - Trate validação como 400, com o campo e a mensagem, sem lançar exceção.
O Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<ManipuladorGlobal>();
builder.Services.AddControllers();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
O manipulador
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public sealed class ManipuladorGlobal : IExceptionHandler
{
private readonly ILogger<ManipuladorGlobal> _logger;
public ManipuladorGlobal(ILogger<ManipuladorGlobal> logger) => _logger = logger;
public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception excecao, CancellationToken ct)
{
if (excecao is OperationCanceledException && http.RequestAborted.IsCancellationRequested)
{
http.Response.StatusCode = StatusCodes.Status499ClientClosedRequest;
return true;
}
var id = http.TraceIdentifier;
_logger.LogError(excecao, "Falha não tratada em {Metodo} {Caminho} (id {Id})",
http.Request.Method, http.Request.Path, id);
http.Response.StatusCode = StatusCodes.Status500InternalServerError;
await http.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Erro inesperado",
Detail = "Não foi possível concluir o pedido. Informe o código ao suporte.",
Extensions = { ["traceId"] = id }
}, ct);
return true;
}
}
O primeiro if separa o cliente que desistiu (fechou o app no meio do pedido) de um erro de verdade. Sem ele, cada cancelamento vira um erro no log e esconde as falhas reais. O resto grava a exceção completa, com pilha, só no log, e devolve ao cliente uma frase genérica e o id.
O que o cliente recebe
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
{
"title": "Erro inesperado",
"status": 500,
"detail": "Não foi possível concluir o pedido. Informe o código ao suporte.",
"traceId": "0HN7Q2K3J8V1R:00000004"
}
O app mostra a mensagem e o código. O suporte busca 0HN7Q2K3J8V1R:00000004 no log e cai direto na exceção. Esse é o ganho que o 500 vazio não dá. Se o seu log é estruturado, o campo Id vira filtro; cuide para não levar senha ou token junto, como em log sem senha.
Entendendo o middleware por baixo
Antes do .NET 8, era comum escrever o próprio middleware. Ver a forma mínima ajuda a entender o que o recurso pronto faz:
app.Use(async (context, next) =>
{
try
{
await next(context);
}
catch (Exception ex) when (!context.Response.HasStarted)
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "Falha não tratada (id {Id})", context.TraceIdentifier);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { title = "Erro inesperado", traceId = context.TraceIdentifier });
}
});
O filtro when (!context.Response.HasStarted) é o detalhe importante: se a resposta já foi enviada, a exceção segue adiante em vez de tentar escrever um segundo status. O UseExceptionHandler faz isso e mais: limpa cabeçalhos que já estavam na resposta, integra com ProblemDetails e permite vários manipuladores encadeados. Em projeto novo, prefira o recurso pronto.
Validação não é exceção
Pedido sem endereço, quantidade negativa, CPF inválido: isso é previsto e tem resposta própria, 400 com o campo e a mensagem. Em controllers com [ApiController], o ASP.NET Core já devolve 400 com ValidationProblemDetails quando o modelo é inválido. Regra de negócio que falha, como estoque insuficiente, também tem status próprio, normalmente 409 ou 422, devolvido pelo endpoint. Guarde o 500 para o que você não previu. A tabela de quando usar cada um está em códigos de status HTTP, e o formato da mensagem de campo em a mensagem de erro do campo é a mesma na tela e na API.
Desenvolvimento e produção
Em desenvolvimento, a página de exceção com a pilha ajuda a corrigir rápido; nos templates atuais ela já é ligada automaticamente nesse ambiente. Em produção, ninguém fora da equipe deve ver pilha, nome de classe ou caminho de arquivo. Confira a variável ASPNETCORE_ENVIRONMENT do servidor: um container que sobe com Development por engano expõe tudo. O lado de quem recebe o erro, a tela, está em erro 500 para a pessoa é uma frase e um código.
Erros comuns
- Registrar
UseExceptionHandlerdepois de outros middlewares. Exceções lançadas antes dele, na autenticação, por exemplo, escapam do tratamento. - Capturar tudo e devolver 200 com "erro" no corpo. Cliente, monitoramento e balanceador acham que deu certo.
- Mandar a mensagem da exceção ao cliente. Vaza nome de tabela, caminho e detalhes da infraestrutura.
- Logar a exceção sem o id da requisição. O suporte não consegue ligar a reclamação à linha do log.
- Tratar validação como exceção. O log enche de falsos erros e o cliente recebe 500 onde deveria receber 400.
- Tentar mudar o status depois que a resposta começou. Gera uma segunda exceção e uma resposta truncada.
Checklist
UseExceptionHandler()é o primeiro middleware do pipeline.AddProblemDetails()está registrado e os erros saem nesse formato.- A resposta de erro inclui um
traceIde o log grava o mesmo valor. - Nenhuma resposta de produção contém pilha ou mensagem interna de exceção.
- Cancelamento pelo cliente não é registrado como erro.
- Validação devolve 400 com o campo, sem passar pelo tratamento global.
- O ambiente de produção não está como
Development.
Crie uma rota de teste que lança uma exceção, publique em homologação e chame com curl -i. A resposta trouxe só a frase genérica e um traceId, e quando você busca esse id no log, a pilha completa está lá?
Referência: https://andrewlock.net/creating-a-custom-error-handler-middleware-function/