Todos os artigos

// Knowledge.log — 技術記事

RestClient no Spring Boot 4.1: o 4xx que vira exceção e some com o body

No RestClient 7.0.9, retrieve() no 422 lança UnprocessableContent. O JSON continua na exceção; exchange() e onStatus leem o body sem Testcontainers.

O cenário é comum. Um serviço chama outra API com RestClient, faz retrieve().body(...) e segue em frente. A API devolve 422 com um JSON de erro de campo, algo como {"field":"email","message":"must not be blank"}. A chamada lança uma exceção, o catch genérico registra "request failed" e o erro vai parar na mesma gaveta de timeout e conexão recusada. Às vezes ainda tem um retry configurado. Retry em 422 só serve para a API repetir, com toda a paciência, que o email está em branco.

O body não sumiu do HTTP. Ele sumiu do valor de retorno de retrieve().body(), porque no 4xx esse método não retorna nada: ele lança. O JSON continua dentro da exceção, só que ninguém olha lá.

A proposta é ver os três caminhos oficiais rodando contra um servidor local: retrieve() (que lança), exchange() (que entrega status e body sem handler padrão) e onStatus (que transforma o 422 num tipo de domínio). Sem Docker, sem Testcontainers, sem WireMock. Só o HttpServer do JDK e o RestClient.

Versões usadas

O experimento rodou em 03/10/2026 com Spring Boot 4.1.1, a GA mais nova da linha 4.1. No Maven Central, o latest/release do spring-boot-starter-parent aponta para 4.2.0-M2, que é milestone e ficou de fora. O BOM do Boot 4.1.1 traz o Spring Framework 7.0.9, e é nele que mora o RestClient, dentro do spring-web. Runtime: Temurin 25.0.4 e Maven 3.9.12. Segundo a página de requisitos do Spring Boot, o 4.1.1 exige Java 17 e é compatível até o Java 26, então o JDK 25 está coberto.

O POM tem o parent 4.1.1, java.version 25 e uma única dependência:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>
<groupId>academy.devdojo</groupId>
<artifactId>restclient-4xx</artifactId>
<version>1.0.0</version>
<properties>
  <java.version>25</java.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-restclient</artifactId>
  </dependency>
</dependencies>

O exec-maven-plugin 3.6.4 aponta para academy.devdojo.restclient4xx.RestClient4xxMain. O projeto não tem Tomcat nem SpringApplication. É um main que sobe o servidor, cria o cliente e chama a API.

O fixture: um HttpServer do JDK que responde 422

O HttpServer do JDK 25 vem no módulo jdk.httpserver e não precisa de dependência Maven. Ele escuta em 127.0.0.1 numa porta efêmera. POST /orders responde 422 com o JSON de campo, e GET /health responde 200 com {"ok":true}, para servir de controle:

byte[] errorJson = "{\"field\":\"email\",\"message\":\"must not be blank\"}"
        .getBytes(StandardCharsets.UTF_8);

HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/orders", exchange -> {
    try {
        exchange.getRequestBody().readAllBytes();
        if (!"POST".equals(exchange.getRequestMethod())) {
            exchange.sendResponseHeaders(405, -1);
            return;
        }
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        exchange.sendResponseHeaders(422, errorJson.length);
        exchange.getResponseBody().write(errorJson);
    } finally {
        exchange.close();
    }
});
// /health: mesmo formato, 200 com {"ok":true}
server.start();

O cliente é o mais simples possível:

String base = "http://127.0.0.1:" + server.getAddress().getPort();
RestClient client = RestClient.create(base);

RestClient.create(baseUrl) não passa pela autoconfiguração do Boot. A documentação do Boot sobre RestClient diz que, nesse caso, nenhum RestClientCustomizer é aplicado. Para este experimento, isso é uma vantagem: o que você vê é o comportamento padrão do Framework, sem nada configurado por cima.

Os comandos executados foram:

mvn -q -B -e package
mvn -B -e exec:java

Exit 0, BUILD SUCCESS. A saída completa:

HEALTH body={"ok":true}
RETRIEVE class=UnprocessableContent status=422 message=422 Unprocessable Content: "{"field":"email","message":"must not be blank"}" body={"field":"email","message":"must not be blank"}
EXCHANGE status=422 body={"field":"email","message":"must not be blank"}
ONSTATUS type=FieldValidationException field=email message=must not be blank

As seções abaixo explicam cada linha.

retrieve(): a exceção que já carrega o JSON

A documentação de clientes REST do Spring Framework é direta:

> "By default, RestClient throws a subclass of RestClientException when retrieving a response with a 4xx or 5xx status code. This behavior can be overridden using onStatus."

O javadoc do ResponseSpec diz o mesmo sobre body(Class): lança RestClientResponseException por padrão quando o status é 4xx ou 5xx. O código:

try {
    client.post()
            .uri("/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .body("{}")
            .retrieve()
            .body(String.class);
    System.out.println("RETRIEVE unexpected-success");
} catch (RestClientResponseException ex) {
    System.out.println("RETRIEVE class=" + ex.getClass().getSimpleName()
            + " status=" + ex.getStatusCode().value()
            + " message=" + ex.getMessage()
            + " body=" + ex.getResponseBodyAsString());
}

A linha RETRIEVE mostra três coisas.

A primeira é a classe. O 4xx vira HttpClientErrorException, e o 422 especificamente vira o tipo aninhado UnprocessableContent. Isso importa para quem captura o tipo exato: HttpClientErrorException.UnprocessableEntity está @Deprecated desde o 7.0 em favor de UnprocessableContent. Capturar RestClientResponseException evita depender desse nome.

A segunda é o body. getResponseBodyAsString() devolve o JSON exatamente como o servidor mandou. A classe também tem getResponseBodyAs(Class), que converte o conteúdo de erro para um tipo.

A terceira é o getMessage(). Nesta execução ele já trazia o JSON inteiro: 422 Unprocessable Content: "{"field":"email",...}". Ou seja, até o log mais preguiçoso, que só imprime a mensagem da exceção, teria mostrado o campo com problema. O "request failed" consegue ignorar até isso.

O que retrieve() não faz é retornar o DTO de erro. retrieve().body(FieldError.class) num 422 nunca retorna o FieldError, porque o handler padrão lança antes de qualquer conversão do caminho feliz. Quem escreve esse código esperando um objeto de erro no retorno vai continuar esperando.

exchange(): status e body sem handler padrão

exchange() entrega a resposta crua. A mesma página da documentação explica que os status handlers não são aplicados ali, "because the exchange function already provides access to the full response".

client.post()
        .uri("/orders")
        .contentType(MediaType.APPLICATION_JSON)
        .body("{}")
        .exchange((request, response) -> {
            String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
            System.out.println("EXCHANGE status=" + response.getStatusCode().value()
                    + " body=" + body);
            return null;
        });

Não tem try/catch, e nenhuma exceção foi lançada. A linha EXCHANGE status=422 body={...} mostra o status e o JSON lidos direto da resposta.

Junto com o controle, vem a responsabilidade. Sem handler padrão, nada lança por você em 4xx nem em 5xx. Quem usa exchange() precisa decidir o que fazer com cada faixa de status. O return null do fixture serve só para imprimir. Num cliente real, a função devolve um resultado que represente sucesso ou erro de campo, ou lança o seu próprio tipo.

onStatus: o 422 vira um tipo de domínio

onStatus mantém o retrieve() para o caminho feliz e intercepta os status que você escolher. No Framework 7.0.9, a assinatura é onStatus(Predicate<HttpStatusCode>, ErrorHandler). Também existe onStatus(ResponseErrorHandler), mas não existe sobrecarga que receba um HttpStatusCode direto. O status entra por predicado.

O tipo de domínio é pequeno:

record FieldError(String field, String message) {}

static final class FieldValidationException extends RuntimeException {
    private final FieldError error;

    FieldValidationException(FieldError error) {
        super(error.field() + ": " + error.message());
        this.error = error;
    }

    FieldError error() {
        return error;
    }
}

A chamada:

try {
    client.post()
            .uri("/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .body("{}")
            .retrieve()
            .onStatus(status -> status.value() == 422, (request, response) -> {
                String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
                if (!body.contains("\"field\":\"email\"")) {
                    throw new IllegalStateException("unexpected 422 body: " + body);
                }
                throw new FieldValidationException(new FieldError("email", "must not be blank"));
            })
            .body(String.class);
    System.out.println("ONSTATUS unexpected-success");
} catch (FieldValidationException ex) {
    System.out.println("ONSTATUS type=" + ex.getClass().getSimpleName()
            + " field=" + ex.error().field()
            + " message=" + ex.error().message());
}

Resultado: ONSTATUS type=FieldValidationException field=email message=must not be blank. Quem chama o cliente não vê RestClientResponseException. Vê um erro de validação com campo e mensagem, que dá para converter em resposta para o usuário, métrica ou decisão de negócio.

Um detalhe sobre o fixture: o handler lê o body, confirma que é o JSON esperado e monta o FieldError com os valores conhecidos. Fiz assim para o exemplo não depender de configurar um mapper. Num cliente real, o handler desserializa response.getBody() com o Jackson que o starter já traz e repassa o objeto para a exceção.

Os três caminhos lado a lado

CaminhoO que acontece no 422Onde está o JSONQuando usar
retrieve().body(...)Lança UnprocessableContent (subtipo de RestClientResponseException)getResponseBodyAsString() / getResponseBodyAs(Class) na exceção4xx inesperado, que deve falhar alto, com status e body no log
retrieve().onStatus(...)Seu handler roda e lança o tipo de domínioresponse.getBody() dentro do handlerAPI com contrato de erro conhecido, como 422 com erro de campo
exchange(...)Nada é lançado; status e body chegam crusresponse.getBody() na funçãoQuando sucesso e erro precisam ser tratados juntos e você quer controle total

A armadilha: "request failed" e mais nada

O defeito raramente é o RestClient. O defeito é este catch:

catch (RestClientException e) {
    log.warn("request failed");
}

Ele junta tudo numa coisa só: o 422 que diz exatamente qual campo está errado, o 503 de uma dependência fora do ar e o timeout de rede. E tudo segue o mesmo caminho, seja retry, fallback ou silêncio. A informação que resolveria o problema chegou no body, e o código jogou fora.

A correção tem duas formas, dependendo de onde você quer a decisão:

  • No ponto de chamada: capture RestClientResponseException antes de qualquer RestClientException genérico e registre getStatusCode().value() junto com getResponseBodyAsString(), as mesmas chamadas que o fixture imprime. Erro de transporte, que não tem resposta HTTP, fica num catch separado.
  • No cliente: traduza o 422 em onStatus para um tipo de domínio. Assim, quem chama nem precisa saber que existe uma exceção HTTP no meio.

Para observar isso em produção, conte erros por status e por tipo de domínio, não por "falhou". Um aumento de FieldValidationException aponta para um contrato quebrado entre os serviços. Um aumento de 5xx ou timeout aponta para disponibilidade. Misturar os dois no mesmo contador é o que transforma um 422 em "instabilidade". Antes de mandar body de erro para o log, confira o que ele carrega: JSON de validação às vezes repete o valor que o usuário enviou, e isso pode ser dado pessoal.

Limites do experimento

Foi uma execução funcional, com um único status (422), um único formato de JSON e um servidor HttpServer local. Não é medição de desempenho, e nenhum número aqui fala de latência ou throughput.

O cliente foi criado com RestClient.create(baseUrl), sem o RestClient.Builder autoconfigurado do Boot. Se o seu projeto aplica RestClientCustomizer ou define um status handler padrão no builder, repita o teste com esse builder. getResponseBodyAs(Class) existe no javadoc, mas não foi exercitado nesta execução: o JSON foi lido como string. O texto do getMessage() incluiu o body nesta versão, mas o formato da mensagem não é contrato, então não faça parse dele. Também não foi testado um handler de onStatus que não lança. O fixture sempre lança.

Este artigo também não trata de dependência lenta. O Bulkhead Resilience4j no Boot 4.1 isola uma dependência lenta usando WireMock, mas não cobre retrieve/exchange/onStatus nem body JSON de 4xx.

Recomendação

A DevDojo não colocaria em produção um retrieve() contra uma API que devolve 422 com erros de campo sem um onStatus ou um exchange() que leia o JSON. O handler padrão é razoável para falhar alto quando algo é realmente inesperado. Mas, quando a API documenta um formato de erro, esse formato faz parte do contrato, e o cliente deve transformá-lo num tipo que o resto do código entenda.

Dá para ficar só com o comportamento padrão quando o 4xx daquela API só acontece por bug de programação e não existe ação diferente a tomar. Nesse caso, basta o catch registrar status e body. Se o time começar a ver retry em 4xx, alerta de "instabilidade" que no fim era validação ou um catch de RestClientException sem leitura do body, é hora de rever o cliente.

Próximo passo

Escolha o cliente que conversa com a API que devolve 422 e adicione um onStatus(status -> status.value() == 422, ...) que lance o seu tipo de domínio, ou troque a chamada por exchange() se ela precisa tratar sucesso e erro juntos. Depois, escreva um teste local no formato do fixture: HttpServer em porta efêmera devolvendo o JSON real da API e uma asserção de que field chega como email no seu tipo. Sem Docker, roda em segundos.

javaspring-bootrestclient

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos