Tratamento de erros em APIs com ProblemDetails e RFC 9457 no .NET 10

Problem Details é o formato que a RFC 9457 define para descrever erros em APIs HTTP, e o ASP.NET Core no .NET 10 oferece suporte nativo a ele. Mesmo assim, o tratamento de erros costuma ser a parte mais negligenciada de uma API.

Enquanto os fluxos de sucesso recebem atenção, contratos e testes, os fluxos de falha acabam resolvidos de forma improvisada, endpoint por endpoint. Neste artigo, você vai combinar a especificação, os recursos nativos do .NET 10, versão LTS atual da plataforma, e uma separação clara entre erros esperados e inesperados.

O problema das APIs que improvisam erros

Considere um endpoint de cancelamento de pedidos escrito sem nenhuma preocupação com padronização. Ele devolve uma string simples quando o pedido não existe e um objeto anônimo quando o pedido já foi cancelado.

Em falhas inesperadas, o mesmo endpoint devolve a mensagem da exceção e o stack trace.

catch (Exception ex)
{
    // Anti-pattern: expõe detalhes internos ao cliente
    return Results.Json(new { message = ex.Message, stack = ex.StackTrace }, statusCode: 500);
}

Esse código concentra cinco problemas distintos, e nenhum deles existiria com Problem Details. O primeiro é a existência de três formatos diferentes de erro.

O segundo é o vazamento de informação. A mensagem de uma exceção de banco de dados pode conter nomes de tabelas, trechos de SQL, endereços de servidores e até partes da connection string.

O stack trace, por sua vez, revela namespaces e caminhos de arquivos. A CWE-209 cataloga exatamente essa falha como geração de mensagem de erro com informação sensível.

O terceiro problema é o uso incorreto de status HTTP. Afinal, um pedido já cancelado não representa um erro de sintaxe da requisição, e sim um conflito com o estado atual do recurso.

O quarto é o acoplamento ao texto. Se o cliente decide o que fazer comparando a mensagem "Já cancelado", qualquer ajuste de acentuação quebra a integração.

O quinto é a dispersão de blocos try/catch, o que torna o comportamento em produção imprevisível.

O formato Problem Details da RFC 9457

A RFC 9457, publicada pela IETF em julho de 2023, define o formato Problem Details for HTTP APIs. Ela torna obsoleta a RFC 7807, de 2016, mantendo o mesmo formato e esclarecendo pontos que geravam dúvidas.

A proposta é simples. Quando uma API precisa descrever um erro, ela responde com um documento de campos conhecidos, usando o media type application/problem+json.

{
  "type": "https://api.example.com/problems/insufficient-stock",
  "title": "Insufficient stock",
  "status": 422,
  "detail": "Product 'MUG' has 2 units available and 5 were requested.",
  "instance": "/orders"
}

O membro type é uma URI que identifica o tipo do problema e funciona como identificador principal. Quando ausente, ele assume o valor about:blank.

O membro title resume o tipo de forma legível e não deve variar entre ocorrências, exceto por tradução. Já o membro status é consultivo e precisa coincidir com o status real da resposta.

O membro detail explica a ocorrência específica e se destina a pessoas, não a programas. Por fim, o membro instance identifica aquela ocorrência, podendo ser o caminho da requisição ou um identificador próprio.

Em resumo, type e title descrevem a classe do erro, enquanto detail e instance descrevem a ocorrência.

Extensões do Problem Details

A especificação permite adicionar membros próprios, chamados de extensões. Clientes que não reconhecem uma extensão devem simplesmente ignorá-la.

Por meio das extensões, uma API entrega dados estruturados ao consumidor, como o SKU envolvido, a quantidade solicitada e a quantidade disponível. A RFC recomenda que esses nomes comecem com letra, usem apenas letras, dígitos e sublinhado, e tenham pelo menos três caracteres.

O que mudou em relação à RFC 7807

As mudanças da nova versão esclarecem o texto, sem alterar o formato. A principal novidade é um registro na IANA para tipos de problema comuns, o que permite que APIs diferentes reutilizem o mesmo type.

A RFC 9457 também deixa claro que a URI do type não precisa ser acessível, embora o ideal seja apontar para uma documentação. Além disso, ela orienta como representar múltiplos problemas em uma mesma resposta, seja com um tipo que represente o conjunto, seja devolvendo apenas o problema mais relevante.

Problem Details nativo no ASP.NET Core

O ASP.NET Core implementa o formato Problem Details sem dependências externas. Três chamadas no Program.cs já mudam completamente o comportamento da aplicação.

builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();

O método AddProblemDetails registra o IProblemDetailsService, que escreve as respostas no formato padronizado. Ele preenche type e title com base no status HTTP e adiciona um traceId à resposta.

O middleware de UseExceptionHandler captura qualquer exceção não tratada e a transforma em uma resposta 500. Segundo a documentação oficial de tratamento de erros em APIs, chamar esse método sem argumentos exige um fallback configurado, e o AddProblemDetails cumpre esse papel.

Já o UseStatusCodePages gera um corpo Problem Details para respostas de erro que saíram vazias. Por isso, uma rota inexistente passa a devolver um 404 estruturado.

Com apenas essa configuração, uma exceção cuja mensagem contém uma senha resulta em um Problem Details 500 genérico, sem vestígio da mensagem original.

Um cuidado importante envolve o ambiente de desenvolvimento. Nele, a página de exceção do desenvolvedor exibe a exceção completa, então você precisa validar o comportamento de erro sempre no ambiente Production.

Erros esperados e erros inesperados

A distinção mais importante de toda a estratégia está na natureza do erro. Um pedido não encontrado, um estoque insuficiente ou um pedido já cancelado são erros esperados, pois fazem parte das regras de negócio.

Um banco de dados indisponível, uma NullReferenceException ou um timeout de rede são erros inesperados. Nesses casos, o cliente normalmente só pode tentar novamente mais tarde.

A regra prática fica assim: erros esperados viram valores de retorno, e erros inesperados viram exceções. O artigo sobre exceptions vs Result Pattern no .NET aprofunda essa comparação.

Usar exceções para controlar o fluxo de negócio custa clareza, pois Task<Order> GetAsync(Guid id) promete um pedido que nem sempre existe. Além disso, lançar exceções tem custo de desempenho relevante.

Modelando erros de domínio

Um record simples resolve a representação de um erro de domínio. Ele carrega um código estável, um título fixo, um detalhe da ocorrência, uma categoria semântica e metadados opcionais.

public enum ErrorType { Validation, NotFound, Conflict, BusinessRule, Unauthorized, Forbidden }

public sealed record Error(
    string Code,
    string Title,
    string Detail,
    ErrorType Type,
    IReadOnlyDictionary<string, object?>? Metadata = null);

O Code é um identificador legível por máquina, escrito em kebab case, como order-already-cancelled. Ele compõe o final da URI do type e também aparece como extensão.

O Type define a categoria do erro e, indiretamente, o status HTTP do Problem Details. Repare que o domínio não conhece HTTP.

Os Metadata permitem que o cliente consuma dados estruturados, como um campo numérico de estoque, sem interpretar texto.

O tipo Result

Para transportar sucesso ou falha, use um tipo Result<T> com conversões implícitas. Dessa forma, o código de domínio pode simplesmente retornar a entidade ou o erro.

public sealed class Result<T>
{
    public T? Value { get; }
    public Error? Error { get; }

    // Informa ao compilador que Error não é nulo quando IsSuccess é false
    [MemberNotNullWhen(false, nameof(Error))]
    public bool IsSuccess => Error is null;

    private Result(T value) => Value = value;
    private Result(Error error) => Error = error;

    public static implicit operator Result<T>(T value) => new(value);
    public static implicit operator Result<T>(Error error) => new(error);
}
public Result<Order> Cancel()
{
    if (Status == OrderStatus.Cancelled)
        return OrderErrors.AlreadyCancelled;

    Status = OrderStatus.Cancelled;
    return this;
}

O atributo MemberNotNullWhen informa ao compilador que, em caso de falha, a propriedade Error não é nula. Com isso, os endpoints dispensam o operador de supressão de nulidade.

O catálogo de erros

Em vez de instanciar erros de forma dispersa, centralize todos em classes estáticas como OrderErrors. Esse catálogo é a semente do contrato estável, pois reúne em um único lugar todos os erros de um módulo.

public static class OrderErrors
{
    public static readonly Error AlreadyCancelled = new(
        "order-already-cancelled",
        "Order already cancelled",
        "The order has already been cancelled and cannot change.",
        ErrorType.Conflict);
}

O serviço de aplicação consome esse catálogo e devolve Result<Order>, sem mencionar HTTP, o que permite reutilizá-lo em workers.

Mapeando erros de domínio para Problem Details

A tradução entre o domínio e o HTTP deve existir em um único arquivo. Assim, se a política de status mudar, a alteração ocupa uma linha, revisada em pull request.

public static int ToStatusCode(this ErrorType type) => type switch
{
    ErrorType.Validation => StatusCodes.Status400BadRequest,
    ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
    ErrorType.Forbidden => StatusCodes.Status403Forbidden,
    ErrorType.NotFound => StatusCodes.Status404NotFound,
    ErrorType.Conflict => StatusCodes.Status409Conflict,
    ErrorType.BusinessRule => StatusCodes.Status422UnprocessableEntity,
    _ => StatusCodes.Status500InternalServerError
};

A escolha entre 400 e 422 costuma gerar debate. A abordagem adotada aqui reserva o 400 para dados de entrada inválidos e o 422 para violações de regra de negócio, o que dá ao cliente uma pista útil sobre a natureza do problema.

Muitas equipes usam apenas o 400, o que também é válido se for consistente e documentado.

O método ToProblem converte um Error em um ProblemHttpResult por meio de TypedResults.Problem. Como o IProblemDetailsService escreve esse resultado, as customizações globais também valem para os erros de domínio.

public static ProblemHttpResult ToProblem(this Error error)
{
    var extensions = new Dictionary<string, object?>(error.Metadata ?? new Dictionary<string, object?>());

    return TypedResults.Problem(
        detail: error.Detail,
        statusCode: error.Type.ToStatusCode(),
        title: error.Title,
        type: ErrorHttpMapping.TypeBase + error.Code,
        extensions: extensions);
}

Com isso, todos os endpoints assumem a mesma forma: chamam o serviço e devolvem ToProblem() em caso de falha.

app.MapPost("/orders/{id:guid}/cancel",
    async Task<Results<Ok<OrderResponse>, ProblemHttpResult>> (Guid id, OrderService service) =>
    {
        var result = await service.CancelAsync(id);

        return result.IsSuccess
            ? TypedResults.Ok(OrderResponse.From(result.Value!))
            : result.Error.ToProblem();
    });

A assinatura com Results<Ok<OrderResponse>, ProblemHttpResult> torna explícitos os tipos de resposta possíveis. Para revisar esse estilo, veja o artigo sobre Minimal APIs no ASP.NET.

O handler global de exceções com Problem Details

Tudo o que é inesperado deve cair em um handler global. Desde o .NET 8, a forma recomendada é implementar a interface IExceptionHandler, tema que o artigo sobre IExceptionHandler no .NET 10 detalha.

public sealed class GlobalExceptionHandler(
    IProblemDetailsService problemDetailsService,
    ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        // A mensagem da exceção vai apenas para o log
        logger.LogError(exception, "Unhandled error on {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 = ErrorHttpMapping.TypeBase + "internal-error",
                Title = "Internal error",
                Detail = "An unexpected error occurred. If the problem persists, send the traceId to support."
            }
        });
    }
}

A segurança desse handler depende de uma regra simples. O Detail é sempre fixo, e a mensagem da exceção pertence exclusivamente ao log.

O writer padrão não serializa a exceção no corpo do Problem Details. Além disso, o traceId que o cliente recebe é o mesmo do log, então o suporte localiza a falha exata.

A mudança de diagnósticos no .NET 10

Uma mudança relevante no .NET 10 torna o log explícito indispensável. A partir dessa versão, quando TryHandleAsync retorna true, o middleware deixa de emitir diagnósticos como logs e métricas para a exceção tratada.

Nas versões 8 e 9, esses diagnósticos apareciam sempre. Quem preferir o comportamento anterior pode configurar a propriedade SuppressDiagnosticsCallback em ExceptionHandlerOptions.

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    // Retornar false mantém logs e métricas das exceções tratadas
    SuppressDiagnosticsCallback = context => false
});

Handlers em cadeia

Você pode registrar múltiplos handlers, e o middleware os executa na ordem de registro até que um retorne true. Por isso, registre os handlers específicos antes do global.

builder.Services.AddExceptionHandler<BadRequestExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

Um caso típico é o JSON malformado, que gera BadHttpRequestException. Mesmo nesse cenário, não exponha a mensagem original no Problem Details.

A documentação também alerta para um detalhe sutil. Um handler que retorna true sem definir o status e escrever o corpo produz uma resposta 404, acompanhada de um aviso no log.

Migração de código legado

Bases legadas costumam lançar exceções de domínio espalhadas, e você não precisa reescrever tudo de uma vez.

Uma DomainException que carrega um Error, combinada com um handler que reaproveita o mesmo mapeamento, entrega o mesmo Problem Details. Assim, o contrato externo permanece estável enquanto o código interno evolui gradualmente para o Result<T>.

Validação nativa e Problem Details no .NET 10

O .NET 10 trouxe validação nativa para Minimal APIs, baseada em Data Annotations. Basta registrar o serviço correspondente.

builder.Services.AddValidation();

Um source generator descobre os tipos usados nos handlers e adiciona um filtro de validação a cada endpoint. Quando a validação falha, o endpoint nem executa, e o cliente recebe um 400.

Essa resposta é um HttpValidationProblemDetails, ou seja, um corpo Problem Details com a extensão errors. Esse é justamente o cenário de múltiplos problemas descrito pela RFC 9457.

A Microsoft documenta um ponto de atenção em aplicações com múltiplos assemblies. O source generator só descobre tipos no assembly que chama AddValidation, então endpoints definidos em outro projeto podem passar sem validação.

Também no .NET 10, as respostas de validação passaram a usar o IProblemDetailsService. Portanto, a mesma customização global vale para os erros de validação, como mostra o artigo sobre validação em Minimal APIs no .NET 10.

Um contrato estável com Problem Details

O formato Problem Details resolve metade do problema. A outra metade exige garantias para o código do cliente funcionar por anos.

Como todas as respostas passam pelo IProblemDetailsService, existe um ponto único para aplicar as regras do contrato. A propriedade CustomizeProblemDetails é esse ponto.

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);
    };
});

Nessa mesma customização, os problemas de validação recebem um type e um title próprios, e todas as respostas recebem a extensão code. Dessa forma, qualquer erro sai com instance, traceId e code.

As regras do contrato

Algumas regras devem constar na documentação da API e valer com rigor. Primeiro, todo erro usa o media type application/problem+json, sem exceção.

O type identifica o problema e nunca muda de significado. Se o significado mudar, crie um novo tipo.

O code espelha o type em formato curto. Já o title é fixo por tipo e não carrega dados da ocorrência.

O detail se destina a pessoas e pode mudar a qualquer momento. Por isso, dados que o cliente precisa consumir ficam sempre em extensões estruturadas.

Todo Problem Details carrega um traceId. Além disso, a evolução do contrato é apenas aditiva, pois renomear ou remover uma extensão quebra a compatibilidade.

Erros da família 5xx nunca exibem detalhes internos. E o mapeamento de status permanece concentrado em um único lugar.

O catálogo como documentação pública

O catálogo centralizado permite publicar uma tabela com cada code, seu status, título, extensões e a ação sugerida ao cliente. Esse material tem grande valor para equipes de front-end e para parceiros de integração.

Documentando Problem Details no OpenAPI

Os métodos ProducesProblem e ProducesValidationProblem declaram, no documento OpenAPI, quais status de erro cada operação pode devolver. Eles também informam que o corpo segue o schema de Problem Details.

app.MapPost("/orders", CreateOrderAsync)
    .ProducesValidationProblem()
    .ProducesProblem(StatusCodes.Status409Conflict)
    .ProducesProblem(StatusCodes.Status422UnprocessableEntity);

Com essa declaração, geradores de clientes produzem tipos de erro corretos. Vale também listar, na descrição da operação, os códigos de erro possíveis.

Testes de integração para Problem Details

Um contrato sem testes permanece apenas como intenção. Os testes de integração com WebApplicationFactory<Program> validam o formato dos erros de domínio, a estrutura da validação e a ausência de vazamento.

Dois cuidados tornam esses testes realmente eficazes. O primeiro é forçar o ambiente Production com UseEnvironment, já que a factory utiliza Development por padrão.

var factory = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder => builder.UseEnvironment("Production"));

O segundo é um teste de regressão de segurança. Nele, 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 Problem Details.

Se alguém, no futuro, colocar exception.Message no Detail, esse teste falhará imediatamente.

Erros frequentes na implementação

Alguns deslizes aparecem com frequência. O mais grave é usar exception.Message no handler global, o que reintroduz o vazamento.

Registrar o handler global antes dos específicos impede que estes executem. Além disso, incluir dados da ocorrência no title viola a regra de título fixo por tipo.

Permitir que o cliente interprete o detail do Problem Details cria um acoplamento frágil. Da mesma forma, escolher o status em cada endpoint gera inconsistência, e renomear um code já publicado quebra clientes de forma silenciosa.

Conclusão

A RFC 9457 oferece um formato consolidado para descrever erros em APIs HTTP, e o ASP.NET Core no .NET 10 implementa Problem Details de maneira nativa. O ganho real, porém, surge quando essa base técnica se combina com decisões de arquitetura consistentes.

Separar erros esperados, tratados como valores, de erros inesperados, tratados como exceções, torna o código mais claro e mais eficiente. Centralizar o mapeamento de status e o catálogo de erros, por sua vez, elimina inconsistências entre endpoints.

O handler global com Problem Details protege a aplicação contra vazamentos, enquanto o traceId preserva o diagnóstico. Por fim, as regras de contrato, a documentação no OpenAPI e os testes em ambiente de produção transformam o Problem Details em uma garantia duradoura para quem consome a API.

FAQ

Qual a diferença entre a RFC 7807 e a RFC 9457?

O formato é o mesmo. A RFC 9457 torna a 7807 obsoleta e esclarece pontos como o registro de tipos na IANA, o uso de about:blank e a representação de múltiplos problemas.

O campo type precisa apontar para uma página existente?

Não. A URI identifica o tipo, mas o ideal é que seja absoluta e, quando possível, aponte para uma documentação útil.

Devo usar 400 ou 422 para regras de negócio?

Ambos são aceitáveis. O essencial é adotar uma política consistente, centralizá-la em um único ponto e documentá-la para os consumidores.

Por que não usar exceções para erros de negócio?

Exceções tornam as assinaturas enganosas e têm custo de desempenho. Um tipo Result<T> deixa explícita a possibilidade de falha.

O que muda no .NET 10 em relação ao log de exceções tratadas?

Quando um IExceptionHandler retorna true, o middleware deixa de emitir logs e métricas por padrão. Por isso, registre a exceção explicitamente no handler ou configure SuppressDiagnosticsCallback.

Por que os testes devem rodar 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.

A validação nativa funciona com endpoints em outro projeto?

Somente se o assembly que define os endpoints chamar AddValidation, já que o source generator só analisa esse assembly.