IExceptionHandler no .NET 10: tratamento global de exceções
O IExceptionHandler é a interface do ASP.NET Core para tratar exceções imprevistas em um único ponto da aplicação. Com ela, qualquer falha inesperada vira uma resposta padronizada, sem stack trace e sem dados internos.
Neste artigo, você vai entender como o IExceptionHandler funciona no .NET 10, versão LTS atual da plataforma. Além disso, vai ver como combiná-lo com ProblemDetails, com a RFC 9457 e com uma separação clara entre erros esperados e falhas imprevistas.
O conteúdo aprofunda um dos pilares do artigo Tratamento de erros em APIs com ProblemDetails e RFC 9457 no .NET 10. Por isso, o foco aqui está no handler global e nas decisões que o cercam.
O que é o IExceptionHandler
O IExceptionHandler surgiu no .NET 8 como a forma recomendada de tratar exceções de maneira centralizada. Antes dele, era comum escrever um middleware próprio com um grande bloco try/catch.
A interface possui um único método, chamado TryHandleAsync. Ele recebe o HttpContext, a exceção e um CancellationToken, e devolve um ValueTask<bool>.
public interface IExceptionHandler
{
ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken);
}
O retorno indica se o handler assumiu a responsabilidade pela resposta. Quando ele devolve true, o pipeline considera a exceção tratada, e quando devolve false, o próximo handler da cadeia entra em ação.
Na prática, o container de injeção de dependência mantém cada implementação como singleton. Portanto, dependências com ciclo de vida scoped, como um DbContext, não devem entrar pelo construtor.
Quando o handler precisar de um serviço scoped, a saída passa pelo httpContext.RequestServices. Assim, cada requisição obtém a própria instância, sem capturar um objeto que deveria morrer ao fim da chamada.
Por que tratar exceções em um único ponto
Considere uma API que trata falhas endpoint por endpoint. Cada desenvolvedor escolhe um formato, e o cliente precisa lidar com strings soltas, objetos anônimos e mensagens de exceção cruas.
Esse cenário parece inofensivo durante o desenvolvimento. No entanto, em produção ele gera integrações frágeis e, pior, expõe detalhes internos da aplicação.
O custo do try/catch espalhado
Blocos try/catch espalhados criam um problema de cobertura. Basta um endpoint novo sem o bloco para que o comportamento de erro se torne imprevisível.
Além disso, esses blocos misturam regra de negócio com política de resposta HTTP. Com isso, qualquer ajuste de formato exige alterações em dezenas de arquivos.
// Exemplo do que NÃO fazer
catch (Exception ex)
{
return Results.Json(
new { message = ex.Message, stack = ex.StackTrace },
statusCode: 500);
}
O trecho acima resume o problema. Ele devolve ao cliente a mensagem original e o stack trace, ou seja, tudo o que um atacante gostaria de ver.
Vazamento de informação e a CWE-209
A mensagem de uma exceção de banco pode conter nomes de tabelas, trechos de SQL e até partes da connection string. Já o stack trace revela namespaces, caminhos de arquivos e versões de bibliotecas.
A CWE-209 cataloga exatamente essa falha como mensagem de erro com informação sensível. Por esse motivo, o tratamento global não é apenas uma questão de organização, mas também de segurança.
Como o IExceptionHandler funciona no pipeline
O IExceptionHandler não atua sozinho. Ele depende do middleware de tratamento de exceções, que captura qualquer falha lançada pelos componentes seguintes do pipeline.
Quando uma exceção chega a esse middleware, ele percorre os handlers na ordem de registro. Assim que um deles devolve true, a busca termina e a resposta segue para o cliente.
A relação com o UseExceptionHandler
O registro acontece em duas etapas: primeiro no container de serviços e, em seguida, no pipeline da aplicação.
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
Segundo a documentação oficial do ASP.NET Core, chamar UseExceptionHandler sem argumentos exige um fallback configurado. Caso contrário, a aplicação lança uma InvalidOperationException logo na inicialização.
A mesma documentação lembra que registrar o handler não basta. Sem a chamada a UseExceptionHandler, nenhuma implementação de IExceptionHandler chega a executar.
O papel do AddProblemDetails
O AddProblemDetails cumpre justamente esse papel de fallback. Ele registra o IProblemDetailsService, que escreve respostas no formato padronizado quando nenhum handler assume a exceção.
Por outro lado, o UseStatusCodePages cobre respostas de erro que saem sem corpo. Ele transforma uma rota inexistente em um 404 estruturado, em vez de uma resposta vazia.
ProblemDetails e a RFC 9457 como formato de resposta
Um handler global precisa de um formato de saída estável. A RFC 9457 define esse formato para APIs HTTP e substitui a antiga RFC 7807, mantendo a mesma estrutura.
O documento de erro usa o media type application/problem+json e traz campos conhecidos. Os principais são type, title, status, detail e instance.
{
"type": "https://api.example.com/problems/internal-error",
"title": "Internal error",
"status": 500,
"detail": "An unexpected error occurred. Please provide the traceId to support.",
"instance": "/orders",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
Uma forma simples de memorizar a divisão é pensar que type e title descrevem a classe do erro. Enquanto isso, detail e instance descrevem a ocorrência específica.
A especificação também permite extensões, como o traceId acima. Clientes que não reconhecem uma extensão simplesmente a ignoram, o que torna a evolução do contrato segura.
Implementando um handler global com IExceptionHandler
Com a base configurada, o próximo passo é escrever o handler global. Ele registra a falha no log, define o status 500 e delega a escrita da resposta ao IProblemDetailsService.
public sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
// O log explícito é obrigatório no .NET 10
logger.LogError(exception, "Unhandled error at {Method} {Path}",
httpContext.Request.Method, httpContext.Request.Path);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Type = "https://api.example.com/problems/internal-error",
Title = "Internal error",
Detail = "An unexpected error occurred. If the problem persists, provide the traceId to support."
}
});
}
}
Repare que a classe usa o construtor primário do C#, recurso que deixa o código mais enxuto. Além disso, ela devolve o próprio resultado do TryWriteAsync, o que mantém o fallback funcionando caso a escrita falhe.
O que o handler nunca deve expor
A segurança desse IExceptionHandler depende de uma regra simples. O Detail é sempre fixo, e a mensagem da exceção pertence exclusivamente ao log.
A exceção segue para o ProblemDetailsContext, mas o writer padrão não a serializa no corpo da resposta. Dessa forma, o time mantém o diagnóstico completo sem entregar nada sensível ao cliente.
O traceId fecha esse ciclo. Na ponta do cliente, chega o mesmo identificador que aparece no log e no tracing distribuído, então o suporte localiza a falha exata a partir de um único valor.
Quando o TryWriteAsync devolve false
O TryWriteAsync nem sempre consegue escrever a resposta. Segundo a documentação, o writer padrão aceita apenas os media types application/json, application/problem+json e curingas como */* no cabeçalho Accept.
Se o cliente pedir application/xml ou text/html, por exemplo, o serviço devolve false. Nesse caso, o IExceptionHandler repassa esse valor ao middleware, que segue para o próximo handler ou para o fallback configurado.
Por essa razão, não vale a pena forçar true como retorno fixo. Devolver o resultado real da escrita mantém o pipeline honesto e evita respostas vazias com status incorreto.
A mudança de diagnósticos do IExceptionHandler no .NET 10
O .NET 10 trouxe uma mudança de comportamento que afeta diretamente o handler global. Quando TryHandleAsync devolve true, o middleware deixa de emitir diagnósticos para aquela exceção.
Nas versões 8 e 9, o middleware registrava o log UnhandledException e adicionava a tag error.type à métrica http.server.request.duration. Por isso, muitas equipes viam a mesma exceção duas vezes no log.
A partir do .NET 10, essa emissão fica suprimida por padrão. Em outras palavras, se o seu IExceptionHandler não registrar a falha explicitamente, ela simplesmente desaparece da telemetria.
A nota de breaking change da Microsoft descreve a alternativa para quem prefere o comportamento anterior. Basta configurar o SuppressDiagnosticsCallback nas opções do middleware.
app.UseExceptionHandler(new ExceptionHandlerOptions
{
// false restaura o comportamento do .NET 8 e 9
SuppressDiagnosticsCallback = context => false
});
O callback também aceita uma condição, como suprimir apenas um tipo específico de exceção. Na maioria dos casos, porém, o log explícito dentro do handler continua a escolha mais clara.
Assim, a mensagem, a categoria e os campos estruturados ficam sob controle do próprio time. Para aprofundar a configuração de logs, vale conferir o artigo sobre logging no ASP.NET.
Encadeando vários IExceptionHandler
O ASP.NET Core aceita múltiplos handlers, e o middleware os consulta na ordem de registro até que um deles devolva true. Consequentemente, os handlers específicos precisam vir antes do global.
builder.Services.AddExceptionHandler<BadRequestExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
Se a ordem estiver invertida, o handler global captura tudo primeiro. Nesse caso, os handlers específicos nunca executam, e o problema passa despercebido até a produção.
Tratando JSON malformado
Um caso típico de handler específico é o corpo JSON inválido, que gera uma BadHttpRequestException. Esse erro vem do cliente, então o status correto é 400, e não 500.
public sealed class BadRequestExceptionHandler(
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
// Ignora qualquer exceção que não seja de requisição inválida
if (exception is not BadHttpRequestException)
return false;
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid request",
Detail = "The request body is not in a valid format."
}
});
}
}
Mesmo aqui, a recomendação é não expor a mensagem original. Ela costuma citar nomes de parâmetros e tipos C#, o que revela a estrutura interna da API.
O retorno true sem resposta
A documentação alerta para um detalhe sutil. Um handler que devolve true sem definir o status e escrever o corpo produz uma resposta 404, acompanhada de um aviso no log.
Portanto, todo IExceptionHandler que assume uma exceção deve escrever a resposta completa. Se o 404 for intencional, a propriedade AllowStatusCode404Response libera esse comportamento.
Erros esperados não são exceções
O handler global resolve falhas imprevistas, como um banco indisponível ou uma NullReferenceException. Contudo, ele não deve virar o mecanismo principal para regras de negócio.
Um pedido não encontrado ou um estoque insuficiente fazem parte do domínio. Por isso, esses casos devem voltar como valores de retorno, por exemplo com um tipo Result<T>, e não como exceções.
Lançar exceções para controlar fluxo traz dois custos. Primeiro, a assinatura do método promete um valor que nem sempre existe, e segundo, cada exceção tem custo relevante de desempenho.
O artigo sobre exceptions vs Result Pattern no .NET aprofunda essa comparação. Aqui, o importante é o efeito no desenho do handler.
Nesse modelo, cada erro de domínio carrega um código estável, como order-already-cancelled, e uma categoria semântica. Em seguida, um único arquivo traduz essa categoria para o status HTTP correto.
public static int ToStatusCode(this ErrorType type) => type switch
{
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.BusinessRule => StatusCodes.Status422UnprocessableEntity,
_ => StatusCodes.Status500InternalServerError
};
Dessa forma, o IExceptionHandler fica reservado para o que realmente foge do controle. Enquanto isso, os endpoints devolvem erros de domínio pelo mesmo IProblemDetailsService, com o mesmo formato.
Migrando código legado com IExceptionHandler
Bases de código antigas costumam lançar exceções de domínio em vários pontos. Nesse cenário, reescrever tudo de uma vez raramente faz sentido.
Uma alternativa gradual é criar uma DomainException que carrega o erro de domínio. Então, um handler específico captura essa exceção e reaproveita o mesmo mapeamento de status.
public sealed class DomainExceptionHandler(
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not DomainException domain)
return false;
// Reaproveita o mesmo mapeamento usado pelo Result<T>
var status = domain.Error.Type.ToStatusCode();
httpContext.Response.StatusCode = status;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = new ProblemDetails
{
Status = status,
Type = $"https://api.example.com/problems/{domain.Error.Code}",
Title = domain.Error.Title,
Detail = domain.Error.Detail
}
});
}
}
Com isso, o cliente recebe exatamente o mesmo formato, venha o erro de um Result<T> ou de uma exceção legada. O contrato externo permanece estável enquanto o código interno evolui aos poucos.
Personalizando a resposta com CustomizeProblemDetails
Como todas as respostas passam pelo IProblemDetailsService, existe um ponto único para aplicar as regras do contrato. A propriedade CustomizeProblemDetails cumpre esse papel.
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
var problem = context.ProblemDetails;
problem.Instance ??= context.HttpContext.Request.Path;
problem.Extensions.TryAdd("traceId",
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier);
};
});
A customização vale tanto para o handler global quanto para os erros de domínio e de validação. Assim, qualquer resposta sai com instance e traceId, sem repetir código em cada IExceptionHandler.
No .NET 10, as respostas da validação nativa das Minimal APIs também passaram a usar o IProblemDetailsService. Em outras palavras, a mesma regra global cobre os erros de campo que o AddValidation produz, tema do artigo sobre validação em Minimal APIs no .NET 10.
Testando o IExceptionHandler em Production
Um contrato sem teste permanece apenas como intenção. Por isso, vale escrever testes de integração com WebApplicationFactory<Program> para validar o comportamento do handler.
O primeiro cuidado é forçar o ambiente Production com UseEnvironment. Afinal, a factory usa Development por padrão, e nesse ambiente a página de exceção do desenvolvedor pode exibir detalhes completos.
O segundo cuidado envolve um teste de regressão de segurança. Um repositório falso lança uma exceção com uma senha fictícia, e o teste verifica que nem a senha nem o nome da exceção aparecem no corpo.
[Fact]
public async Task InternalError_DoesNotExposeDetails()
{
var client = _factory
.WithWebHostBuilder(b => b.UseEnvironment("Production"))
.CreateClient();
var response = await client.GetAsync("/orders/failure");
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.DoesNotContain("SecretPassword123", body);
Assert.DoesNotContain("InvalidOperationException", body);
}
Se alguém, no futuro, colocar exception.Message no Detail do IExceptionHandler, esse teste falha imediatamente. Dessa maneira, a proteção deixa de depender da memória do time.
Erros comuns ao usar o IExceptionHandler
Alguns deslizes aparecem com frequência em revisões de código. O mais grave consiste em usar exception.Message no Detail, o que reintroduz o vazamento que o handler deveria impedir.
Registrar o handler global antes dos específicos representa outro erro recorrente. Também acontece com frequência de a equipe esquecer o log explícito no .NET 10 e perder a exceção na telemetria.
Há ainda quem devolva true sem escrever a resposta, o que resulta em um 404 confuso. Por fim, injetar serviços scoped no construtor de um handler singleton causa falhas difíceis de diagnosticar.
Para evitar esses problemas, a combinação de revisão e testes de integração costuma bastar. Além disso, vale estudar padrões de resiliência, como o Retry Pattern, para lidar com falhas transitórias antes que virem exceções.
Conclusão
O IExceptionHandler oferece ao ASP.NET Core um ponto único e seguro para tratar exceções inesperadas. Combinado ao AddProblemDetails e à RFC 9457, ele entrega respostas padronizadas sem expor detalhes internos.
No .NET 10, o handler ganha ainda mais responsabilidade, já que o middleware deixa de registrar diagnósticos das exceções tratadas. Portanto, o log explícito passa a fazer parte obrigatória da implementação.
Separar erros esperados de falhas imprevistas, encadear handlers na ordem correta e testar em Production completam a estratégia. Assim, a API ganha um contrato de erro estável, seguro e fácil de diagnosticar.
FAQ
O que é o IExceptionHandler?
Trata-se da interface do ASP.NET Core para tratar exceções de forma centralizada. Ela surgiu no .NET 8 e continua como a abordagem recomendada no .NET 10.
Preciso chamar AddProblemDetails para usar o IExceptionHandler?
Não obrigatoriamente, mas ele oferece a forma mais simples de satisfazer o fallback que o UseExceptionHandler sem argumentos exige. Além disso, ele padroniza as respostas no formato da RFC 9457.
Por que minhas exceções sumiram do log no .NET 10?
Porque o middleware não emite mais diagnósticos quando o IExceptionHandler devolve true. Registre a exceção dentro do handler ou configure SuppressDiagnosticsCallback.
Posso registrar mais de um IExceptionHandler?
Sim. O middleware consulta os handlers na ordem de registro até que um deles devolva true, então os específicos vêm antes do global.
Devo usar exceções para erros de negócio?
Não. Erros esperados funcionam melhor como valores de retorno, e o IExceptionHandler fica reservado para falhas imprevistas.
Por que testar em Production?
Porque em Development a página de exceção do desenvolvedor pode expor detalhes. Testar em Production valida o comportamento que o cliente real observa.