Repository navigation
Tratamento de Erros
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.
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:
- Um formato único, para o cliente tratar todos os erros com o mesmo código.
- Mensagens úteis para o usuário, sem detalhes técnicos.
- Detalhes técnicos para a equipe, nos logs, fáceis de encontrar.
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
}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
| 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.
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.
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]
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
BadHttpRequestExceptioncobre problemas de leitura da requisição, como um corpo maior que 64 KB. - O
defaultcaptura tudo o que não foi previsto. É a rede de segurança.
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."
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.
{
"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.
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.
{
"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.
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_FOUDNnão compila. - Os testes comparam com a mesma constante, sem duplicar o texto.
- Tradução sem mudar código, como você verá abaixo.
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.
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:
-
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()];
-
Existem dois arquivos com as mesmas chaves:
Arquivo Idioma ResourcesMessageException.resxPortuguês (o recurso "neutro", embutido na DLL principal) ResourcesMessageException.en.resxInglês (compilado em uma DLL satélite, en/PolicyDesk.Exception.resources.dll) -
Quando o código lê
ResourcesMessageException.INVALID_CPF, oResourceManagerdo .NET escolhe o arquivo pela cultura da requisição. Nenhumifno 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; oResourceManagerdevolve o texto em português. Ao criar uma mensagem, adicione-a aos dois arquivos. OsLocalizationTestscobrem 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.
| 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.
- Envie
POST /api/seguradoscom o corpo{}e conte quantos erros vêm na lista. - Envie
{ "valor": "abc" }para abrir um sinistro e veja o erro de JSON no mesmo formato. - Chame
GET /api/rota-que-nao-existee confira que até o 404 do framework temerrorsetraceId. - Repita os passos com
Accept-Language: en. - Copie um
traceIde procure-o no Aspire Dashboard.
- Todo erro segue o ProblemDetails (RFC 9457), com
errors(sempre uma lista) etraceId. - O código lança exceções com significado de negócio, sem saber de HTTP; o
ExceptionFiltere 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 peloAccept-Language.
- 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.
PolicyDesk · Distribuído sob a licença MIT · Repositório
Comece aqui
Como o sistema é feito
A API
Engenharia
Prática
Consulta