Skip to content

Tratamento de Erros

rodrigosdk edited this page Nov 11, 2026 · 2 revisions

Tratamento de erros: falhar bem é uma funcionalidade

Para quem é este artigo: para quem consome a API e precisa interpretar os erros, e para quem estuda como projetar o tratamento de erros de um backend.

Pré-requisitos: saber o que é uma exceção (throw/catch). A Referência da API explica os códigos HTTP.

O que você vai aprender: por que erros merecem um contrato próprio, o padrão ProblemDetails, como o código lança erros sem saber de HTTP, como um único lugar os traduz, o que acontece com erros inesperados e como as mensagens são traduzidas para inglês.


O problema: cada erro de um jeito

Imagine uma API em que cada erro chega em um formato:

{ "message": "CPF inválido" }
{ "error": { "code": 42, "text": "Sinistro não encontrado" } }
System.NullReferenceException: Object reference not set to an instance of an object.
   at PolicyDesk.Application.Claims.Approve...

O front-end precisaria de um if para cada formato. Pior: o terceiro exemplo vaza detalhes internos (nomes de classes, caminhos de arquivos), que ajudam um atacante a entender o sistema.

Um bom tratamento de erros tem três objetivos:

  1. Um formato único, para o cliente tratar todos os erros com o mesmo código.
  2. Mensagens úteis para o usuário, sem detalhes técnicos.
  3. Detalhes técnicos para a equipe, nos logs, fáceis de encontrar.

O contrato: ProblemDetails

O PolicyDesk usa o ProblemDetails, um formato padronizado pela IETF na RFC 9457 para descrever erros de APIs HTTP. Ser um padrão é importante: bibliotecas de muitas linguagens já sabem lê-lo.

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
X-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "Bad Request",
  "status": 400,
  "instance": "/api/apolices",
  "errors": [
    "A descrição é obrigatória.",
    "O valor da cobertura deve ser maior que zero."
  ],
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
Campo De onde vem O que significa
type RFC 9457 Um link que explica o tipo de erro (aqui, a seção da RFC do status HTTP)
title RFC 9457 O nome do status
status RFC 9457 O código HTTP, repetido no corpo para quem perde o cabeçalho
instance RFC 9457 O caminho da requisição que falhou
errors Extensão do PolicyDesk Sempre uma lista de textos, prontos para mostrar ao usuário
traceId Extensão do PolicyDesk O identificador da requisição, igual ao cabeçalho X-Trace-Id

A RFC permite acrescentar campos próprios (extensões). O PolicyDesk acrescenta dois, e faz uma promessa ao cliente: errors está presente em toda resposta de erro, sempre como lista. Isso vale até para erros gerados pelo próprio framework, como rota inexistente (404), método não permitido (405) e formato não suportado (415). O front-end pode fazer simplesmente:

if (!response.ok) {
  const problem = await response.json();
  mostrarErros(problem.errors);   // sempre uma lista
}

Lançando erros sem saber de HTTP

As exceções do projeto

O domínio e os casos de uso não sabem que existe HTTP. Eles descrevem o que aconteceu, lançando exceções com significado de negócio. O projeto src/Shared/PolicyDesk.Exception define a família:

classDiagram
    direction TB
    class Exception
    class PolicyDeskException {
        <<abstract>>
    }
    class ErrorOnValidationException {
        +GetErrors() List~string~
    }
    class DomainRuleException
    class NotFoundException
    class ConflictException

    Exception <|-- PolicyDeskException
    PolicyDeskException <|-- ErrorOnValidationException
    PolicyDeskException <|-- DomainRuleException
    PolicyDeskException <|-- NotFoundException
    PolicyDeskException <|-- ConflictException
Loading
Exceção Significa Quem lança Vira
ErrorOnValidationException Dados de entrada inválidos; pode carregar vários erros Casos de uso, UnitOfWork 400
DomainRuleException Uma regra de negócio foi violada, ou uma transição de estado é proibida Entidades e validadores 400
NotFoundException O recurso não existe Casos de uso 404
ConflictException O registro mudou entre a leitura e a gravação UnitOfWork 409

Por que separar assim? Porque cada camada fala da sua responsabilidade. A entidade Claim diz "não posso ser aprovada agora"; ela não sabe, e não precisa saber, que isso vira um 400. Se amanhã o mesmo caso de uso for exposto por outra tecnologia (gRPC, uma fila de mensagens, uma linha de comando), as exceções continuam as mesmas, e só o "tradutor" muda.

Vários erros de uma vez

Quem preenche um formulário com três campos errados prefere saber dos três de uma vez, e não corrigir um, enviar, descobrir o segundo, corrigir, enviar... Por isso os casos de uso acumulam os erros antes de lançar:

var errors = new List<string>();

if (string.IsNullOrWhiteSpace(request.Descricao))
    errors.Add(ResourcesMessageException.DESCRIPTION_REQUIRED);

if (request.Valor <= 0)
    errors.Add(ResourcesMessageException.CLAIM_AMOUNT_MUST_BE_POSITIVE);

if (errors.Count > 0)
    throw new ErrorOnValidationException(errors);

Esse padrão se chama Notification: em vez de parar no primeiro problema, você coleta todos e notifica de uma vez. A exceção carrega a lista:

public class ErrorOnValidationException(List<string> errors) : PolicyDeskException(string.Join("; ", errors))
{
    public ErrorOnValidationException(string error) : this([error]) { }

    public List<string> GetErrors() => errors;
}

Um detalhe útil: a mensagem da exceção (Message) junta os erros com "; ". Ferramentas que leem qualquer exceção como System.Exception (logs, monitoramento) mostram todos os erros, enquanto o tradutor usa GetErrors() para montar a lista da resposta.

As entidades, ao contrário, param no primeiro erro (DomainRuleException). Elas são a última linha de defesa, e não precisam ser amigáveis: se a validação da aplicação funcionou, a entidade nunca recebe dados inválidos.


Traduzindo exceções em respostas

Uma exceção pode surgir em vários lugares, e cada um tem um tratador. Todos convergem para o mesmo formato:

flowchart TD
    A[Exceção ou erro] --> B{Onde aconteceu?}
    B -->|Dentro de um controller| C[ExceptionFilter]
    B -->|JSON malformado ou tipo errado| E[InvalidModelStateResponseFactory]
    B -->|Em um middleware, fora dos controllers| D[UseExceptionHandler]
    B -->|Resposta sem corpo: 404 de rota, 405...| F[UseStatusCodePages]

    C --> G[ProblemDetailsFactory]
    D --> G
    E --> G
    F --> G
    G --> H[CustomizeProblemDetails<br/>+ instance, traceId, errors]
    H --> I[application/problem+json]
Loading

1. O ExceptionFilter: o tradutor principal

Um filtro de exceção do ASP.NET é chamado sempre que um controller lança uma exceção. O do PolicyDesk é um grande switch sobre o tipo da exceção:

switch (context.Exception)
{
    case ErrorOnValidationException errorOnValidationException:
        SetErrorResult(context, StatusCodes.Status400BadRequest, errorOnValidationException.GetErrors());
        break;
    case DomainRuleException domainRuleException:
        SetErrorResult(context, StatusCodes.Status400BadRequest, [domainRuleException.Message]);
        break;
    case NotFoundException notFoundException:
        SetErrorResult(context, StatusCodes.Status404NotFound, [notFoundException.Message]);
        break;
    case ConflictException conflictException:
        SetErrorResult(context, StatusCodes.Status409Conflict, [conflictException.Message]);
        break;
    case BadHttpRequestException badHttpRequestException:
        SetErrorResult(context, badHttpRequestException.StatusCode, [ResourcesMessageException.INVALID_REQUEST]);
        break;
    case OperationCanceledException when context.HttpContext.RequestAborted.IsCancellationRequested:
        logger.RequestCancelled(context.HttpContext.Request.Method, context.HttpContext.Request.Path.Value);
        context.Result = new StatusCodeResult(StatusClientClosedRequest);
        break;
    default:
        logger.UnhandledException(context.Exception, context.HttpContext.Request.Method, context.HttpContext.Request.Path.Value);
        SetErrorResult(context, StatusCodes.Status500InternalServerError, [ResourcesMessageException.UNKNOWN_ERROR]);
        break;
}

Repare em três coisas:

  • Um único lugar conhece a tabela "exceção → status HTTP". Adicionar um tipo novo de erro é acrescentar um case.
  • O BadHttpRequestException cobre problemas de leitura da requisição, como um corpo maior que 64 KB.
  • O default captura tudo o que não foi previsto. É a rede de segurança.

2. JSON malformado

Se o cliente envia um JSON inválido, ou um texto onde se esperava um número, o erro acontece antes do controller, na leitura do corpo. Por padrão, o ASP.NET responde com outro formato, em que errors é um dicionário:

{ "errors": { "$.valor": ["The JSON value could not be converted to System.Decimal."] } }

Isso quebraria a promessa de "errors é sempre uma lista". O PolicyDesk substitui a fábrica dessa resposta (InvalidModelStateResponseFactory) para usar o mesmo formato de todos os outros erros:

{ "errors": ["The JSON value could not be converted to System.Decimal. Path: $.valor ..."] }

Se não houver mensagem utilizável, usa uma genérica: "A requisição é inválida. Verifique o formato e os campos enviados."

3. As redes de segurança

UseExceptionHandler captura exceções que acontecem fora dos controllers (em um middleware, por exemplo). UseStatusCodePages dá corpo a respostas que o framework geraria vazias, como o 404 de uma rota inexistente. As duas usam o mesmo serviço de ProblemDetails, então passam pela mesma personalização:

services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
{
    var problem = context.ProblemDetails;

    problem.Instance ??= context.HttpContext.Request.Path;
    problem.Extensions[TraceIdKey] = CorrelationId.Get(context.HttpContext);

    if (problem is not HttpValidationProblemDetails && !problem.Extensions.ContainsKey(ErrorsKey))
        problem.Extensions[ErrorsKey] = new List<string> { problem.Detail ?? problem.Title ?? ResourcesMessageException.UNKNOWN_ERROR };
});

É esse trecho que garante o traceId e a lista errors em toda resposta de erro, venha de onde vier.

Esse conjunto de técnicas tem nome: Exception Translation. Cada camada lança erros no seu próprio vocabulário, e a borda do sistema os traduz para o vocabulário de quem está do outro lado.


Os casos especiais

409: alguém mexeu no dado ao mesmo tempo

{
  "status": 409,
  "errors": ["O registro foi alterado por outra operação. Consulte novamente e repita a solicitação."]
}

O cliente deve buscar o recurso de novo, mostrar o estado atual ao usuário e deixá-lo decidir. Repetir a ação automaticamente pode não fazer sentido: se outro analista negou o sinistro, aprová-lo agora vai falhar com 400. Para entender de onde vem o 409, leia Persistência › Concorrência otimista.

499: o cliente desistiu

Quando o cliente fecha a conexão (fechou a aba, deu timeout), o ASP.NET sinaliza o cancelamento por um CancellationToken, que é repassado até a consulta ao banco. A consulta é interrompida, e o filtro registra o código 499.

O 499 não faz parte do padrão HTTP (vem do servidor web Nginx), e a resposta nunca chega ao cliente, que já foi embora. Ele existe para os logs e métricas: separa "o cliente desistiu" de "o servidor falhou". Sem ele, cancelamentos apareceriam como erros 500 e disparariam alarmes falsos.

500: o inesperado

{
  "status": 500,
  "errors": ["Ocorreu um erro inesperado. Tente novamente mais tarde."],
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

O cliente recebe uma mensagem genérica. A exceção completa (tipo, mensagem e stack trace) vai só para o log, com o código de evento 9001. Quando um usuário reporta o erro, ele informa o traceId, e a equipe encontra o log exato e o caminho completo da requisição. Veja Observabilidade.

Por que não mostrar o erro real ao cliente? Porque ele pode conter informações internas, como nomes de tabelas, trechos de SQL e caminhos de arquivos. Isso ajuda um atacante e não ajuda o usuário.


As mensagens

Um arquivo para todas

Nenhuma mensagem de erro está escrita "solta" no código. Todas ficam em um arquivo de recursos, o ResourcesMessageException.resx, e são acessadas por propriedades geradas automaticamente:

throw new NotFoundException(ResourcesMessageException.CLAIM_NOT_FOUND);

Vantagens:

  • Um só lugar para revisar o texto de todas as mensagens.
  • Erro de digitação vira erro de compilação: ResourcesMessageException.CLAIM_NOT_FOUDN não compila.
  • Os testes comparam com a mesma constante, sem duplicar o texto.
  • Tradução sem mudar código, como você verá abaixo.

Mensagens com valores

Algumas mensagens têm "lacunas", preenchidas na hora:

<data name="POLICYHOLDER_NAME_TOO_LONG">
    <value>O nome deve ter no máximo {0} caracteres.</value>
</data>
public static string PolicyholderNameTooLong =>
    string.Format(CultureInfo.CurrentCulture, ResourcesMessageException.POLICYHOLDER_NAME_TOO_LONG, PolicyholderName);

O {0} vira 150. O limite vem da constante FieldLimits.PolicyholderName, então a mensagem nunca fica desatualizada.

Português e inglês: localização

Localização é adaptar o sistema ao idioma e às convenções do usuário. As mensagens existem em português (o padrão) e em inglês, e o cliente escolhe pelo cabeçalho Accept-Language:

Accept-Language Idioma da resposta
ausente Português
pt-BR Português
en, en-US Inglês
fr (não suportado) Português
POST /api/segurados
Accept-Language: en

{ "errors": ["Invalid CPF."], ... }

Como funciona:

  1. O middleware de localização lê o cabeçalho e define a cultura da requisição (CultureInfo):

    options.DefaultRequestCulture = new RequestCulture("pt-BR");
    options.SupportedCultures = [new CultureInfo("pt-BR"), new CultureInfo("en")];
    options.RequestCultureProviders = [new AcceptLanguageHeaderRequestCultureProvider()];
  2. Existem dois arquivos com as mesmas chaves:

    Arquivo Idioma
    ResourcesMessageException.resx Português (o recurso "neutro", embutido na DLL principal)
    ResourcesMessageException.en.resx Inglês (compilado em uma DLL satélite, en/PolicyDesk.Exception.resources.dll)
  3. Quando o código lê ResourcesMessageException.INVALID_CPF, o ResourceManager do .NET escolhe o arquivo pela cultura da requisição. Nenhum if no código.

O projeto declara que o recurso neutro está em português:

<!-- PolicyDesk.Exception.csproj -->
<PropertyGroup>
    <NeutralLanguage>pt-BR</NeutralLanguage>
</PropertyGroup>

Assim, em requisições pt-BR, o .NET usa direto o texto embutido, sem procurar um arquivo satélite que não existe. (O analisador de código CA1824 exige essa declaração.)

As mensagens com lacunas são formatadas na cultura da requisição, então aparecem certas nos dois idiomas: "O tamanho da página deve estar entre 1 e 100." / "The page size must be between 1 and 100."

⚠️ Armadilha: se uma chave faltar no arquivo em inglês, nada quebra; o ResourceManager devolve o texto em português. Ao criar uma mensagem, adicione-a aos dois arquivos. Os LocalizationTests cobrem o idioma da resposta, o retorno ao português para idiomas não suportados e a formatação em inglês, mas não conferem se toda chave tem tradução.


Tabela de referência das mensagens

Chave Mensagem HTTP
POLICYHOLDER_NOT_FOUND Segurado não encontrado. 404
POLICY_NOT_FOUND Apólice não encontrada. 404
CLAIM_NOT_FOUND Sinistro não encontrado. 404
INVALID_CPF CPF inválido. 400
INVALID_EMAIL E-mail inválido. 400
INVALID_PHONE Telefone inválido. 400
POLICYHOLDER_ACTIVE_CPF_EXISTS Já existe um segurado ativo com este CPF. 400
POLICYHOLDER_ACTIVE_EMAIL_EXISTS Já existe um segurado ativo com este e-mail. 400
POLICYHOLDER_HAS_ACTIVE_POLICY Não é possível remover um segurado com apólice ativa. 400
POLICYHOLDER_INACTIVE O segurado está inativo. 400
POLICY_ALREADY_CANCELLED A apólice já está cancelada. 400
POLICY_END_DATE_MUST_BE_AFTER_START_DATE A data de fim deve ser posterior à data de início. 400
POLICY_CANCELLED_CANNOT_REGISTER_CLAIM Não é possível registrar sinistro em uma apólice cancelada. 400
CLAIM_DATE_OUTSIDE_POLICY_PERIOD A data do sinistro está fora da vigência da apólice. 400
CLAIM_AMOUNT_EXCEEDS_COVERAGE O valor do sinistro excede o saldo disponível da cobertura da apólice. 400
CLAIM_AMOUNT_INVALID_PRECISION O valor do sinistro deve ter no máximo {0} dígitos inteiros e {1} casas decimais. 400
CLAIM_OCCURRENCE_DATE_IN_FUTURE A data de ocorrência não pode estar no futuro. 400
CLAIM_MUST_BE_PENDING_FOR_ANALYSIS Somente sinistros pendentes podem entrar em análise. 400
CLAIM_MUST_BE_IN_ANALYSIS_FOR_APPROVAL Somente sinistros em análise podem ser aprovados. 400
CLAIM_MUST_BE_IN_ANALYSIS_FOR_DENIAL Somente sinistros em análise podem ser negados. 400
CLAIM_MUST_BE_APPROVED_FOR_PAYMENT Somente sinistros aprovados podem ser pagos. 400
CLAIM_DENIAL_REASON_REQUIRED O motivo da negação é obrigatório. 400
INVALID_POLICY_STATUS Status de apólice inválido. 400
INVALID_PAGE A página deve ser maior ou igual a 1. 400
INVALID_PAGE_SIZE O tamanho da página deve estar entre 1 e {0}. 400
CONCURRENCY_CONFLICT O registro foi alterado por outra operação. Consulte novamente e repita a solicitação. 409
INVALID_REQUEST A requisição é inválida. Verifique o formato e os campos enviados. 400
UNKNOWN_ERROR Ocorreu um erro inesperado. Tente novamente mais tarde. 500

A lista completa está no próprio .resx. Duas mensagens nunca chegam ao cliente: MISSING_CONNECTION_STRING e INVALID_BUSINESS_TIME_ZONE aparecem no log quando a aplicação se recusa a subir por falta de configuração.


Experimente

  1. Envie POST /api/segurados com o corpo {} e conte quantos erros vêm na lista.
  2. Envie { "valor": "abc" } para abrir um sinistro e veja o erro de JSON no mesmo formato.
  3. Chame GET /api/rota-que-nao-existe e confira que até o 404 do framework tem errors e traceId.
  4. Repita os passos com Accept-Language: en.
  5. Copie um traceId e procure-o no Aspire Dashboard.

Resumo

  • Todo erro segue o ProblemDetails (RFC 9457), com errors (sempre uma lista) e traceId.
  • O código lança exceções com significado de negócio, sem saber de HTTP; o ExceptionFilter e as redes de segurança traduzem tudo na borda.
  • Erros de validação são acumulados (padrão Notification).
  • Erros inesperados viram uma mensagem genérica; os detalhes ficam no log, encontráveis pelo traceId.
  • As mensagens ficam em arquivos .resx, em português e inglês, e o idioma é escolhido pelo Accept-Language.

Para aprofundar

  • RFC 9457: Problem Details for HTTP APIs.
  • Documentação do ASP.NET Core: "Handle errors in ASP.NET Core APIs" e "Globalization and localization".
  • "Replacing Throwing Exceptions with Notification in Validations", artigo de Martin Fowler sobre o padrão Notification.
  • Próximo artigo sugerido: Observabilidade.

Clone this wiki locally