Tratamento de erros HTTP na prática: status codes, RFC 7807 e como o Spring resolve isso pra você
Quando uma API retorna 200 OK com {“success”: false} no corpo, alguém perdeu tempo. Quando o front recebe 500 Internal Server Error sem mais nenhuma pista, alguém vai abrir um ticket. E quando ninguém na equipe consegue explicar, de cabeça, a diferença entre 401 e 403, é hora de revisar o básico.
Tratamento de erros parece um assunto pequeno — até virar a causa raiz de uma noite mal dormida. Este post junta o que precisa estar firme na cabeça de quem escreve APIs HTTP: as famílias de status codes, os pares que mais confundem, o padrão RFC 7807 (e sua atualização, a 9457), e como o Spring Boot moderno resolve tudo isso com poucas linhas.
Por que tratamento de erros importa tanto
Erros não são “casos de canto” da API. Eles definem quatro coisas críticas:
- Contrato com o cliente. Status code e corpo dizem ao consumidor exatamente como reagir. Errar isso é introduzir bug no outro lado.
- DX e tempo de debug. Mensagens claras economizam horas. Erros genéricos viram tickets eternos.
- Segurança. O que você expõe num erro pode virar vetor de ataque — stack trace, SQL, e-mail, ID interno.
- Observabilidade. Sem erros padronizados, alertas e dashboards viram adivinhação.
A boa notícia: as cinco famílias de status codes do HTTP já fazem 80% do trabalho. Basta usá-las corretamente.
As cinco famílias de status codes
Um truque mental para nunca errar a faixa: o primeiro dígito responde “de quem é a culpa?”.
- 1xx — Informational. Processando, continue. Raríssimo escrever na mão.
- 2xx — Success. Deu certo. Inclui 201 Created (com header Location), 202 Accepted para processamento assíncrono, 204 No Content para DELETE e PUT sem retorno.
- 3xx — Redirection. Procure em outro lugar. Os mais úteis: 303 See Other (PRG — Post/Redirect/Get), 307 e 308 que garantem preservar o método HTTP no redirect (coisa que 301 e 302 não fazem de forma consistente).
- 4xx — Client Error. Você (cliente) errou.
- 5xx — Server Error. Nós (servidor) erramos. Minimize.
Se você está em dúvida entre 4xx e 5xx, pergunte: o cliente consegue corrigir esse erro? Sim → 4xx. Não → 5xx.
A confusão eterna: 401 vs 403
Esse par troca posição na cabeça de praticamente todo dev pelo menos uma vez na carreira. O nome não ajuda: 401 se chama “Unauthorized”, mas é sobre autenticação, não autorização. A diferença é simples quando você verbaliza:
401 Unauthorized: “Quem é você?” A requisição não traz credencial, ou a credencial é inválida. Token ausente, JWT expirado, assinatura quebrada, basic auth com senha errada — tudo isso é 401. A RFC pede que a resposta inclua o header WWW-Authenticate indicando o esquema esperado.
403 Forbidden: “Eu sei quem você é, mas você não pode.” A identidade foi resolvida, mas falta autorização. Usuário comum tentando rota de admin, acesso a recurso de outro tenant, API key correta cujo plano não libera aquela feature. Reenviar credencial não resolve um 403.
Casos práticos para fixar:
|
Cenário |
Status |
|
Requisição sem header Authorization |
401 |
|
JWT expirado |
401 |
|
JWT com assinatura inválida |
401 |
|
Usuário logado tentando acessar /admin |
403 |
|
Usuário lendo pedido de outro cliente |
403 ou 404 |
|
API key correta, plano não permite a feature |
403 |
Dica de privacidade: quando seu objetivo é esconder a existência de um recurso (listagens privadas, multi-tenant), prefira 404 em vez de 403. O 403 confirma que o recurso existe — bom prato para enumeration attacks.
Outros pares que merecem decorar
Alguns status codes próximos confundem tanto quanto 401/403:
- 400 vs 422. 400 Bad Request é para sintaxe quebrada — JSON inválido, campo faltando estruturalmente. 422 Unprocessable Entity é para semântica — a sintaxe está OK, mas uma regra de negócio falhou (e-mail já cadastrado, idade < 18, CPF inválido).
- 404 vs 410. 404 significa “não sei se já existiu”. 410 Gone é “existiu, foi removido de propósito” — útil para LGPD, contas deletadas, links que não vão voltar.
- 409 vs 412. 409 Conflict é para conflito de estado (duplicidade, versão divergente). 412 Precondition Failed é para condicionais (If-Match, If-Unmodified-Since) que não bateram.
- 429 vs 503. 429 Too Many Requests é este cliente abusando. 503 Service Unavailable é o servidor inteiro indisponível. Ambos devem incluir o header Retry-After.
O problema: cada API inventa seu próprio formato de erro
Mesmo com os status codes resolvidos, surge o segundo problema — o corpo da resposta. Sem padrão, cada API inventa o seu:
// API A
{ "error": "USR_404", "msg": "not found" }
// API B
{ "errorCode": 1004, "errorMessage": "User unknown", "success": false }
// API C (em XML, pra piorar)
<error><code>NOT_FOUND</code></error>
Resultado: cada cliente escreve um parser próprio, logs não casam entre serviços, monitoramento vira gambiarra. Foi exatamente esse caos que a IETF resolveu padronizar.
RFC 7807: Problem Details for HTTP APIs
Publicada em 2016 e atualizada pela RFC 9457 em 2023, a RFC 7807 define um formato JSON (e XML) padrão para descrever erros em APIs HTTP. A motivação, nas palavras da própria especificação, é “evitar a proliferação de formatos de erro de uso único”.
O Content-Type é application/problem+json e os campos previsíveis são seis:
|
Campo |
O que é |
Quando usar |
|
type |
URI identificando o tipo do problema (idealmente dereferenciável, apontando para uma doc) |
Sempre — é seu contrato |
|
title |
Resumo curto e legível, invariante entre ocorrências do mesmo type |
Sempre |
|
status |
Código HTTP, repetido aqui para facilitar log/leitura |
Sempre |
|
detail |
Explicação específica desta ocorrência. Pode variar |
Quando útil |
|
instance |
URI referenciando esta ocorrência específica |
Quando útil |
|
Extensões |
Campos custom (traceId, errors, balance…) |
À vontade |
Um exemplo completo:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "You do not have enough credit",
"status": 422,
"detail": "Your balance is 30, but the cost is 50",
"instance": "/account/12345/transactions/abc",
"balance": 30,
"traceId": "7a9f1c..."
}
Os campos custom (balance, traceId) convivem normalmente com os padrão. O type é seu contrato com o cliente — uma vez publicado, versione com cuidado.
RFC 9457: o que mudou
A 9457 obsoleta a 7807, mas é praticamente compatível. As novidades importantes:
- Padronização do campo errors. Antes cada um inventava o seu para validação em lote. Agora há orientação clara.
- i18n explícito. O title é em inglês; o detail pode usar Accept-Language.
- Segurança reforçada. Reforça o óbvio que muita gente ignora: não vaze stack trace, paths internos, SQL ou PII no detail.
- type como URN. Aceitável quando você não quer hospedar uma página de documentação para cada erro.
Validação em lote: o array errors
Aqui está o caso que aparece em todo formulário real. Um cadastro com vários campos inválidos não merece cinco round-trips — o ideal é uma única resposta 422 listando tudo.
Imagine um POST para /pessoas com vários campos inválidos: nome com 1 caractere, sobrenome ultrapassando 200, CPF com 5 dígitos, telefone com 3. A resposta deve descrever todos os erros de uma vez:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.exemplo.com/errors/validation",
"title": "Dados inválidos",
"status": 422,
"errors": [
{ "field": "nome", "detail": "mínimo de 2 caracteres" },
{ "field": "sobrenome", "detail": "máximo de 200 caracteres" },
{ "field": "cpf", "detail": "deve conter 11 dígitos" },
{ "field": "telefone", "detail": "deve conter 11 dígitos" }
],
"traceId": "7a9f1c..."
}
O front itera o array, pinta cada campo de vermelho e mostra a mensagem ao lado. Sem ping-pong, sem usuário irritado tentando descobrir o que mais quebra ao corrigir o primeiro erro.
Dica de internacionalização: traduza apenas o detail conforme Accept-Language e mantenha o field (e o type) estáveis — esses são contrato.
Como o Spring trata erros (em três níveis)
O Spring evoluiu bastante nesse assunto. Hoje há três caminhos, que se complementam:
Nível 1: ResponseStatusException
Para casos simples, lance direto no controller:
@GetMapping("/users/{id}")
public User findById(@PathVariable Long id) {
return repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "User " + id + " not found"));
}
Rápido e direto. Bom para one-offs. Ruim quando o mapeamento de exceptions começa a crescer.
Nível 2: @RestControllerAdvice com @ExceptionHandler
A partir do momento em que há mais de duas ou três exceptions de domínio, centralize:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handle(UserNotFoundException ex) {
ErrorResponse body = new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handle(ConstraintViolationException ex) {
return ResponseEntity.unprocessableEntity().body(/* ... */);
}
}
DRY, testável, fácil de evoluir. Toda exception passa por um único ponto de tradução para resposta HTTP.
Nível 3: ProblemDetail — RFC 7807 nativo (Spring 6 / Boot 3)
Aqui mora a maior mudança recente. Desde Spring Framework 6 e Spring Boot 3, há suporte nativo à RFC 7807 — não precisa mais escrever a classe de erro à mão:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handle(UserNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://api.example.com/errors/user-not-found"));
pd.setTitle("User not found");
pd.setProperty("traceId", MDC.get("traceId")); // campo custom
return pd;
}
}
O Spring serializa como application/problem+json automaticamente. Resultado para o cliente:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/user-not-found",
"title": "User not found",
"status": 404,
"detail": "User 42 not found",
"instance":"/users/42",
"traceId": "7a9f1c..."
}
Em uma equipe que já está em Boot 3, não há motivo para inventar formato próprio. Use ProblemDetail.
Anti-padrões: o que não fazer
Os erros mais comuns que vejo em code reviews:
- Devolver 200 com {“success”: false}. Mata todo o uso de status codes — quebra retry automático, monitoração, cache, e ferramentas que dependem do código.
- Vazar stack trace no detail. Clássico vetor de info disclosure. Logue no servidor, mostre apenas um traceId ao cliente.
- Mensagens genéricas (“erro interno”). Sem type nem código, o cliente não tem como reagir programaticamente.
- Status arbitrário (599, 444…) Quebra proxies, ferramentas e clientes.
- Mudar o significado do type. É contrato. Mudou? É breaking change — versione a URL.
- Esquecer Retry-After em 429 e 503. Sem isso, o cliente faz spin retry e piora a carga.
Boas práticas: o que fazer
- Use application/problem+json — o cliente sabe parsear sem doc extra.
- Inclua traceId ou correlationId em toda resposta de erro — permite suporte fechar o ciclo em um clique.
- Documente os type URIs — cada URI deve apontar para uma página explicando o erro e como resolver.
- Mensagens acionáveis no detail — “e-mail must be valid” é infinitamente melhor que “validation failed“.
- Trate validação em lote com errors[].
- Status code e corpo precisam contar a mesma história.
Segurança: erros são vetor de ataque
Três comportamentos a evitar:
- Não vaze diferenças entre “usuário não existe” e “senha errada” no login. Devolva 401 genérico — ajuda contra enumeration.
- Use 404 em vez de 403 para esconder recursos privados quando faz sentido. O 403 confirma a existência.
- Sanitize tudo que vai no detail. Nunca interpole input bruto. Reflected XSS via problem+json é coisa real.
Observabilidade: o bônus que ninguém promove
Padronizar o corpo é o que torna possível medir e alertar com qualidade. Métricas por type (errors_by_type{type=”validation”}) viram gráfico legível imediato. Correlação com tracing fica trivial. Alertas só em 5xx (não acorde ninguém por 4xx — é o cliente que errou). Dashboards por status code contam a história: pico de 429 é abuso, pico de 422 provavelmente é o front quebrado, pico de 500 é fogo na cozinha.
Bônus: curiosidades para você ser o nerd da equipe
- 418 I’m a teapot. RFC 2324, de 1º de abril de 1998. Tentaram remover; teve protesto. Continua. O Google ainda responde 418 em google.com/teapot.
- 451 Unavailable for Legal Reasons. Homenagem ao livro Fahrenheit 451. Use quando bloquear conteúdo por ordem judicial.
- 402 Payment Required. Reservado em 1997, nunca padronizado. Stripe e outras gateways usam livremente.
- 499 Client Closed Request. Não está na RFC, mas o nginx usa quando o cliente fecha a conexão antes da resposta.
Checklist antes de mergear sua API
- [ ] Status code escolhido representa a causa real (cliente vs servidor)
- [ ] Corpo segue application/problem+json com type, title, status, detail
- [ ] type aponta para documentação dereferenciável (ou URN estável)
- [ ] traceId / correlationId presente em toda resposta de erro
- [ ] Erros de validação usam o array errors[]
- [ ] Nada de stack trace, SQL, PII ou paths internos vaza no corpo
- [ ] 401 vs 403 revisados — autenticação vs autorização
- [ ] 429 e 503 incluem header Retry-After
- [ ] Testes cobrem caminhos de erro, não só happy path
- [ ] Documentação OpenAPI lista os erros possíveis por endpoint
Fechando
Tratamento de erros bem feito é uma daquelas coisas que ninguém elogia, mas todo mundo nota quando falta. Status code certo + corpo padronizado = API que economiza tempo da sua equipe, do seu cliente e do seu plantão.
Se sua stack é Spring Boot 3, comece pelo ProblemDetail ainda hoje. Se está em outra linguagem, a RFC 7807/9457 funciona igual — Python tem problems, .NET tem ProblemDetails, Node tem várias libs. O padrão é o ponto.
E se você lê este post no celular durante um plantão tentando entender um 503 sem Retry-After, sinto muito — e boa sorte.
Referências
- RFC 9110 — HTTP Semantics
- RFC 7807 — Problem Details for HTTP APIs
- RFC 9457 — Problem Details for HTTP APIs (update)
- Spring Framework — ProblemDetail
Bruno Henrique S. Contente — Bacharel em Ciência da Computação e pós-graduado em Engenharia de Software (especialização). Desenvolvedor na Rem Soft, escreve sobre boas práticas, design de APIs e código limpo.