Pular para o conteúdo

Trilha de Kotlin · parte 41 de 59

Tratamento de erros com ProblemDetail

Aula da fase 6 da trilha de Kotlin: Como mapear exceções de domínio para status HTTP sem vazar detalhes internos?

intermediárioPublicada em · 16 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Imagine dois formulários de reclamação de um órgão público. Um deles é uma folha em branco, em que cada atendente escreve o que lembra. O outro tem campos fixos: qual é o problema, qual o código do problema, o que aconteceu desta vez. Quem lê o segundo sabe onde procurar a informação, e quem lê o primeiro precisa adivinhar. Em uma API HTTP, o erro é a parte que mais sofre com a falta de padrão. Cada serviço devolve o erro de um jeito: às vezes como texto, às vezes como um objeto com campos que mudam de nome, às vezes como a página de erro padrão do servidor. Quem consome a API precisa de um tratamento diferente para cada caso. Esta aula mostra como o Spring transforma exceções de domínio em respostas no formato da RFC 9457, por meio da classe ProblemDetail, e registra o que um servidor de verdade respondeu.

A RFC 9457 é uma especificação da IETF que define um formato padrão para erros em APIs HTTP. O tipo de conteúdo é application/problem+json, e o corpo tem campos fixos como type, title, status e detail. A classe ProblemDetail do Spring é a representação desse formato em Java e Kotlin. Uma exceção de domínio é um erro que a própria regra de negócio conhece, como “pedido não encontrado”. Ela se distingue de uma falha de infraestrutura, como uma conexão caída com o banco, e essa diferença determina o status HTTP que cada uma recebe.

o mapa da aula

A aula começa pelo problema que as respostas improvisadas causam, porque sem entender o custo do formato livre a RFC parece burocracia. Em seguida apresenta os campos da RFC 9457 com as citações da especificação. Depois mostra o @ExceptionHandler que converte uma exceção de domínio, e registra a resposta que o servidor devolveu para ela, com e sem a propriedade spring.mvc.problemdetails.enabled. Na sequência vêm as bordas: exceções sem handler, validação, cancelamento de coroutines e a representação no springdoc. A última parte trata de erros comuns e de práticas de design.

Versões desta aula: Spring Framework 7.0.9, gerenciado pelo BOM do Spring Boot 4.1.1, com Kotlin 2.3.21 e Jackson 3.1.5. As respostas HTTP foram obtidas de um servidor local iniciado com java -jar, em localhost, em 20/10/2026.

1. por que um formato padrão importa

Quando cada endpoint escreve o erro do seu jeito, o cliente da API precisa de código específico para cada um. Um app móvel que chama dez endpoints precisa de dez leituras de erro, ou de uma leitura frágil que quebra quando alguém muda o nome de um campo. Outro problema comum é o vazamento: a resposta de erro traz o nome da classe Java, a mensagem de uma exceção de banco ou a pilha de chamadas inteira. Isso revela detalhes internos que nenhum cliente deveria conhecer.

Um formato padrão resolve os dois problemas. O servidor adota um formato único e documentado, e o cliente aprende a lê-lo uma vez. A parte que varia entre um erro e outro vai para campos previstos pela especificação, em vez de ir para texto livre. A RFC 9457 faz isso com um conjunto pequeno de campos e a possibilidade de acrescentar campos próprios.

2. os campos da RFC 9457

A RFC define o tipo de conteúdo application/problem+json e um objeto JSON com alguns membros. O membro type é “a JSON string containing a URI reference … that identifies the problem type”, e, quando ele não aparece, seu valor é presumido como about:blank. O membro title é “a short, human-readable summary of the problem type”. O membro status é “a JSON number indicating the HTTP status code … generated by the origin server for this occurrence of the problem”. O membro detail é “a human-readable explanation specific to this occurrence of the problem”. O membro instance é “a JSON string containing a URI reference that identifies the specific occurrence of the problem”. A especificação também permite que cada tipo de problema acrescente membros próprios.

A diferença entre title e detail é a que mais confunde. O title descreve o tipo do problema e tende a ser o mesmo em todas as ocorrências desse tipo. O detail descreve esta ocorrência, com os valores concretos que falharam. Um pedido de número 42 que não existe tem title igual ao de qualquer outro pedido inexistente, e o detail diz “Pedido 42 não foi encontrado”. A figura abaixo mostra essa diferença com duas ocorrências do mesmo tipo.

Mesmo tipo de problema, duas ocorrências. type (pedido 42) → type (pedido 7) (mesmo); title (pedido 42) → title (pedido 7) (igual)…
figura 1 · Mesmo tipo de problema, duas ocorrências

As linhas da figura são os membros de duas respostas, uma para o pedido 42 e outra para o pedido 7. As setas verdes ligam os campos que se repetem, type e title. As setas rosas tracejadas ligam os campos que mudam, detail e pedidoId. O status aparece em cada resposta com o mesmo valor, porque o tipo de erro é o mesmo. A leitura correta de um erro começa pelo type e pelo status, que dizem o que aconteceu, e depois pelo detail, que diz onde.

3. ProblemDetail no Spring

A documentação do Spring Framework descreve a classe assim: “ProblemDetail: representation for an RFC 9457 problem detail; a simple container for both standard fields defined in the spec, and for non-standard ones.” A interface ErrorResponse expõe o status HTTP, os cabeçalhos e o corpo em formato RFC 9457. Segundo a mesma página, “All Spring MVC exceptions implement this”, ou seja, as exceções que o próprio framework lança já sabem como se transformar em resposta. A documentação também diz que “The status property of ProblemDetail determines the HTTP status” e que “The instance property of ProblemDetail is set from the current URL path, if not already set”.

A forma mais comum de uso é um método anotado com @ExceptionHandler que devolve um ProblemDetail. O código abaixo é o que foi compilado e executado no servidor de verificação, sem alteração:

package aula.l52

import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import java.net.URI

class PedidoNaoEncontrado(val pedidoId: Long) : RuntimeException("pedido $pedidoId não existe")

@RestControllerAdvice
class ErrosDaApi {
    @ExceptionHandler(PedidoNaoEncontrado::class)
    fun pedidoNaoEncontrado(e: PedidoNaoEncontrado): ProblemDetail {
        val pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Pedido ${e.pedidoId} não foi encontrado")
        pd.title = "Pedido não encontrado"
        pd.type = URI.create("https://api.exemplo.com/erros/pedido-nao-encontrado")
        pd.setProperty("pedidoId", e.pedidoId)
        return pd
    }
}

Um endpoint de teste lança PedidoNaoEncontrado(42). Com a propriedade spring.mvc.problemdetails.enabled desligada, que é o padrão, a resposta foi:

HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"Pedido 42 não foi encontrado","instance":"/teste/nao-encontrado/42","status":404,"title":"Pedido não encontrado","type":"https://api.exemplo.com/erros/pedido-nao-encontrado","pedidoId":42}

O campo instance veio preenchido com o caminho da URL, como a documentação descreve. O pedidoId aparece no mesmo nível dos membros padrão, como a RFC prevê para membros de extensão. Essa é uma resposta observada, e não uma leitura da documentação.

A figura abaixo mostra o caminho que a exceção percorre no Spring. A decisão central é se existe um handler para o tipo lançado. Quando existe, o ProblemDetail é serializado. Quando não existe, a exceção segue para o tratamento padrão do Boot, e a seção 5 mostra o que isso produz.

Caminho de uma exceção até a resposta. Regra de negócio → Há handler para o tipo? (lança); Falha inesperada → Há handler para o tipo? (lança)…
figura 2 · Caminho de uma exceção até a resposta

O losango pergunta se há um @ExceptionHandler para o tipo lançado. Os dois caminhos de entrada mostram o contraste da aula: a regra de negócio lança PedidoNaoEncontrado, e a falha inesperada lança IllegalStateException com um detalhe interno. A seta verde leva a primeira a uma resposta padronizada, e a seta rosa tracejada leva a segunda ao tratamento padrão, que devolve 500 sem a mensagem. Por isso vale ter um handler para cada exceção de domínio que pode chegar ao cliente.

Pergunta de entrevistaQue formato de erro HTTP você usaria numa API?

Usaria o formato da RFC 9457, que define type, title, status, detail e instance, com campos extras quando precisar. O Spring tem a classe ProblemDetail. Com um tratador central, todas as exceções saem no mesmo formato, e o cliente trata os erros de forma uniforme. Não exponho stack trace nem detalhes internos no detail. Erros de validação ganham uma lista de campos com a mensagem de cada um.

4. mapear exceções de domínio

Uma exceção de domínio carrega a intenção da regra de negócio. PedidoNaoEncontrado diz que o identificador informado não existe, e isso não é um erro de servidor. Uma falha de conexão com o banco é um problema de infraestrutura, e o cliente não deve receber nenhum detalhe dela. A separação entre as duas é o que permite responder com o status certo sem vazar informação.

Considere três casos. Um pedido inexistente responde 404. A RFC 9110 descreve o 404 como a situação em que o servidor não encontra uma representação atual do recurso, e a seção 15.5.5 acrescenta que o código não informa se essa ausência é temporária ou permanente. Um pedido que tenta ultrapassar o limite de crédito do cliente é uma violação de regra. Para esse caso a RFC 9110 oferece dois códigos, e a escolha depende do que a equipe definiu no contrato. O 409 (seção 15.5.10) serve quando o estado atual do recurso impede a operação e o usuário pode resolver o conflito e reenviar. O 422 (seção 15.5.21) serve quando o corpo está sintaticamente correto, mas o servidor não consegue processar as instruções. Uma falha de banco não é decisão da regra de negócio, e não deve ganhar um handler com mensagem própria. Ela deve virar um erro interno com um title genérico, sem detail que revele a consulta SQL.

O handler do exemplo separa a informação da formatação. A exceção carrega o pedidoId, e o handler monta o ProblemDetail a partir dela. Quando a mensagem é montada na hora do throw, a informação estruturada se perde dentro de uma string, e o cliente não consegue ler o campo sem interpretar texto.

5. detalhes e bordas

A propriedade spring.mvc.problemdetails.enabled muda o comportamento para as exceções que o próprio Spring MVC lança. A documentação do Spring Boot diz que “This support can be enabled by setting spring.mvc.problemdetails.enabled to true.” A mesma página não descreve o que a propriedade monta por dentro, por isso a aula registra o efeito observado e não a implementação. Quando ela está ligada, a validação de corpo passa a responder com application/problem+json. Os resultados de quatro requisições, com a propriedade desligada e depois ligada, estão na tabela a seguir.

RequisiçãoSem a propriedadeCom problemdetails.enabled=true
GET /teste/param/0 com @Min(1) no parâmetro400, JSON padrão do Boot400, application/problem+json, detalhe Validation failure
GET /teste/retorno com @Min(1) no retorno500, JSON padrão do Boot500, application/problem+json, detalhe Validation failure
POST /teste/corpo com @Valid e corpo inválido400, JSON padrão do Boot400, application/problem+json, detalhe Invalid request content.
GET /teste/generico, exceção sem handler500, sem a mensagem da exceção500, sem a mensagem da exceção

A última linha mostra que a propriedade não altera exceções sem handler, e que o Boot já omite a mensagem interna nesse caso. O texto segredo interno do banco, lançado pela exceção de teste, não aparece em nenhuma das respostas. A resposta padrão contém timestamp, status, error e path. Para as exceções de validação, as duas formas precisam de tratamento, como explicado na aula Spring Boot com controllers suspend: MethodArgumentNotValidException devolve 400 e HandlerMethodValidationException devolve 400 para parâmetros e 500 para o retorno.

A documentação do Spring MVC diz que a renderização dessas exceções pode ser customizada por meio de MessageSource e de bundles de recursos por idioma. A aula não testou essa customização, e ela não é necessária para o formato básico.

Existe uma relação com cancelamento que costuma passar despercebida. Um @ExceptionHandler que captura Exception de forma genérica também captura CancellationException, que é a exceção que uma coroutine recebe quando é cancelada. Para verificar o mecanismo, um teste sem Spring rodou duas coroutines que esperam com delay, e a cancelaram depois de 50 ms. A primeira captura com catch (e: Exception) e a segunda relança a CancellationException. A saída foi esta:

-- A: catch (Exception) engole a cancelação --
A engoliu JobCancellationException na volta 0
A engoliu JobCancellationException na volta 1
A engoliu JobCancellationException na volta 2
A: o corpo terminou sem parar
A: isCancelled=true isCompleted=true
-- B: catch que relança CancellationException --
B: isCancelled=true isCompleted=true

No caso A, o código depois do catch continuou rodando e terminou o corpo da coroutine, mesmo cancelada. No caso B, o trecho depois do catch não rodou. O teste não usou um handler do Spring, então o que se afirma é o mecanismo das coroutines. Não foi verificado se um @ExceptionHandler genérico, em um controller suspend, produz exatamente esse efeito. O tratamento recomendado é capturar tipos específicos e relançar CancellationException quando houver um catch genérico.

A documentação gerada pelo springdoc também foi verificada com o servidor de teste. O /v3/api-docs respondeu 200, e a especificação não traz o schema ProblemDetail nem uma resposta 404 para o endpoint que lança PedidoNaoEncontrado. Os tipos de erro existem no código, mas não aparecem no contrato gerado. A aula Segurança com JWT e OpenAPI mostra como o springdoc trata os endpoints e o que mais aparece na especificação.

6. erros comuns e boas práticas

O erro mais frequente é responder com status 200 e um corpo de erro. O cliente que olha só o status acha que a operação deu certo, e caches e intermediários tratam a resposta como sucesso. O status precisa ser o código HTTP real da falha, e o ProblemDetail só descreve o erro.

O segundo erro é colocar a pilha de chamadas no detail. Além de expor a estrutura interna, o texto muda a cada execução e impede que o cliente reconheça o tipo do problema. A informação de diagnóstico deve ir para o log do servidor, com um identificador que também pode aparecer no corpo como membro extra, e o corpo fica com o que o cliente precisa.

O terceiro erro é usar o title como mensagem de ocorrência. Se o title carrega o nome do pedido, ele deixa de descrever o tipo e passa a descrever um caso. Como mostra a figura da seção 2, os dois campos existem para separar essas leituras, e misturá-los atrapalha quem usa o type para decidir o que fazer.

O quarto erro é tratar o type como texto livre. A RFC diz que o membro é uma referência de URI que identifica o tipo do problema. Uma string como “pedido nao encontrado” não é uma URI, e um cliente que queira documentar o tipo não tem para onde apontar. Um domínio próprio com um caminho estável, como https://api.exemplo.com/erros/pedido-nao-encontrado, dá um endereço para a documentação do erro, mesmo que a página ainda não exista.

A boa prática é ter um handler para cada exceção de domínio que pode chegar ao cliente, um tratamento genérico que não revele detalhes, e uma lista curta de tipos de problema documentada junto com a API. A aula Segurança com JWT e OpenAPI mostra como o springdoc gera a especificação, e a seção 5 desta aula mostra que os tipos de erro não aparecem nela sem trabalho extra.

7. exercícios

  1. Crie uma exceção SaldoInsuficiente com o saldo atual e o valor pedido, e um handler que devolva um ProblemDetail com um membro extra para cada valor. Suba o servidor, chame o endpoint e confira a resposta real.
  2. Escreva um handler para MethodArgumentNotValidException que transforme os erros de campo em um membro extra chamado campos. Antes, leia a página de validação do Spring MVC para confirmar como a exceção expõe os erros, porque esta aula não verificou essa API.
  3. Explique com suas palavras por que um title que muda a cada ocorrência prejudica quem agrupa erros pelo tipo.

8. glossário

RFC 9457: especificação da IETF que define o formato application/problem+json para erros em APIs HTTP.

ProblemDetail: classe do Spring que representa um problema no formato da RFC 9457.

ErrorResponse: interface do Spring que expõe o status, os cabeçalhos e o corpo de uma resposta de erro.

Exceção de domínio: erro que a regra de negócio conhece, como um pedido inexistente.

Membro extra: campo próprio de um tipo de problema, somado aos campos padrão da RFC.

Referência de URI: texto que identifica um recurso por um endereço, como https://api.exemplo.com/erros/pedido-nao-encontrado. O endereço não precisa apontar para uma página existente.

Vazamento de detalhes: exposição de informação interna, como nomes de classes, consultas ou pilhas de chamadas, na resposta de erro.

CancellationException: exceção que uma coroutine recebe quando é cancelada. Capturá-la sem relançá-la faz o código continuar rodando depois do catch.

fontes

Consultadas em 20/10/2026:

Verificações locais, feitas em 20/10/2026 com Temurin 21.0.8: o handler foi compilado com o Kotlin 2.3.21 e executado dentro do servidor local, em localhost, com e sem spring.mvc.problemdetails.enabled=true. O teste de cancelamento rodou como programa sem Spring, com kotlinx-coroutines-core 1.10.2.

Não verificado nesta aula: a implementação interna da propriedade spring.mvc.problemdetails.enabled, a customização por MessageSource, o efeito de um @ExceptionHandler genérico dentro de um controller suspend, e a forma como o springdoc poderia representar respostas de erro customizadas.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?