Programação Por Equipe DevBras

Capa do artigo: Tratamento global de erro no ASP.NET Core: uma resposta só, um id no log

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

  1. Registre o serviço de ProblemDetails com AddProblemDetails(). Ele padroniza o corpo de erro no formato da RFC 9457 (antiga 7807).
  2. Crie uma classe que implementa IExceptionHandler para registrar a exceção com o id da requisição e escrever a resposta.
  3. Registre a classe com AddExceptionHandler<T>().
  4. Chame UseExceptionHandler() logo no início do pipeline, antes de roteamento, autenticação e endpoints.
  5. Adicione UseStatusCodePages() para que 404 e 405 sem corpo também saiam como ProblemDetails.
  6. 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 UseExceptionHandler depois 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 traceId e 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á?

Avalie este artigo

Referência: https://andrewlock.net/creating-a-custom-error-handler-middleware-function/