RestClient no Spring Boot: como configurar timeout e erros

Uma chamada com RestClient no Spring Boot precisa de prazos definidos e tratamento separado para resposta HTTP de erro e falha de transporte. Se uma API parceira demora, a sua requisição fica ocupada esperando; se o parceiro responde 404, houve uma resposta válida do protocolo com significado próprio. Tratar as duas situações como “retornou null” esconde a causa e torna retries perigosos.

O RestClient é síncrono. Ele combina com serviços de fluxo imperativo, mas a chamada bloqueia a tarefa que a executa. Na documentação do Spring Framework 7, o RestTemplate aparece como deprecated em favor do RestClient, sem que isso signifique remoção imediata. A escolha entre esses clientes e o WebClient deve considerar o modelo da aplicação, conforme a referência oficial de clientes REST.

RestClient no Spring Boot: configuração sem perder o builder

Em uma aplicação Boot, injete o RestClient.Builder configurado pelo framework. Criar um cliente com RestClient.create() em qualquer lugar pode deixar de fora a autoconfiguração e os customizadores do Boot. Quando houver vários clientes, clonar o builder antes de configurá-lo ajuda a evitar que alterações de um cliente afetem construções seguintes que reutilizam o mesmo objeto.

Na documentação consultada do Boot 4.1.2, as propriedades globais usam spring.http.clients, no plural. O exemplo abaixo estabelece valores didáticos para a conexão e a leitura. Confirme os nomes na documentação da linha em uso: copiar propriedades de outra versão pode produzir um arquivo que parece correto e não altera o cliente esperado.

# application.properties, Spring Boot 4.1.2
spring.http.clients.connect-timeout=1s
spring.http.clients.read-timeout=2s

A referência do Boot para chamadas REST detalha o builder, a seleção do transporte e as customizações. Se você fornece uma request factory própria, precisa assumir também sua configuração. Não presuma que cada ajuste global continuará sendo aplicado a uma fábrica criada manualmente com outros limites.

Um serviço de catálogo, por exemplo, pode receber o builder por construtor e criar uma única instância reutilizável do cliente com a URL base da integração. Mantenha essa URL em configuração, valide o ambiente e não aceite um endereço arbitrário enviado pelo consumidor da API. A documentação de ConfigurationProperties no Spring Boot é uma leitura complementar para organizar esses parâmetros.

O que os timeouts cobrem e o que fica fora

O timeout de conexão controla uma etapa diferente do timeout de leitura. A semântica exata depende da implementação HTTP escolhida e de como ela aplica seus limites. DNS, TLS, espera por conexão de um pool e consumo do corpo da resposta merecem testes específicos quando fazem parte do risco da integração. Um único número no YAML não descreve necessariamente o prazo total de uma operação.

Considere um endpoint hipotético com orçamento de resposta de três segundos e duas chamadas sequenciais. Permitir dois segundos para cada chamada já pode ultrapassar esse orçamento antes de contar processamento local. Um retry acrescenta nova tentativa e eventual intervalo. A decisão deve considerar o tempo restante da operação, não apenas o timeout configurado isoladamente em cada cliente.

Há ainda uma diferença entre desistir de esperar e desfazer uma operação remota. Se o parceiro processou um pedido e a conexão caiu antes da resposta, o cliente não sabe automaticamente se o pedido foi criado. Repetir um POST pode duplicar o efeito. Use a estratégia de idempotência acordada com a API parceira e um mecanismo de consulta ou reconciliação quando o resultado ficar incerto.

Exemplo prático com servidor HTTP local

Este teste executável valida três comportamentos do RestClient: leitura de um corpo com status 200, propagação de um status 404 e timeout quando o servidor atrasa o envio da resposta. Ele usa o transporte JDK de forma explícita, sem subir uma aplicação Boot. Isso deixa claro qual cliente foi exercitado e evita depender de uma API externa durante o teste.

Crie um projeto Maven com o arquivo abaixo e salve a classe seguinte em src/main/java/RestClientDemo.java. A execução local usou OpenJDK 21.0.8, distribuição JBR, e Spring Framework 7.0.10. O servidor abre somente uma porta aleatória em localhost. Esses testes verificam comportamento, não medem o desempenho de um serviço em produção.

<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
        https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>br.com.javalizando</groupId>
  <artifactId>restclient-demo</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-web</artifactId>
      <version>7.0.10</version>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.5.1</version>
      </plugin>
    </plugins>
  </build>
</project>
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.Executors;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

public class RestClientDemo {
    public static void main(String[] args) throws Exception {
        var server = HttpServer.create(
                new InetSocketAddress("127.0.0.1", 0), 0);
        var workers = Executors.newCachedThreadPool();
        server.setExecutor(workers);
        server.createContext("/", exchange -> {
            try (exchange) {
                String path = exchange.getRequestURI().getPath();
                if (path.equals("/lento")) {
                    try { Thread.sleep(800); }
                    catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        return;
                    }
                }
                int status = path.equals("/ausente") ? 404 : 200;
                byte[] body = "resposta-local"
                        .getBytes(StandardCharsets.UTF_8);
                exchange.getResponseHeaders().add(
                        "Content-Type", "text/plain; charset=utf-8");
                exchange.sendResponseHeaders(status, body.length);
                exchange.getResponseBody().write(body);
            } catch (java.io.IOException ignored) {
                // O cliente pode fechar a conexão após o timeout.
            }
        });
        server.start();
        try {
            var http = HttpClient.newBuilder()
                    .connectTimeout(Duration.ofSeconds(1)).build();
            var factory = new JdkClientHttpRequestFactory(http);
            factory.setReadTimeout(Duration.ofMillis(200));
            var client = RestClient.builder().requestFactory(factory)
                    .baseUrl("http://127.0.0.1:"
                            + server.getAddress().getPort()).build();
            String body = client.get().uri("/ok")
                    .retrieve().body(String.class);
            if (!"resposta-local".equals(body))
                throw new AssertionError("Resposta inesperada");
            try {
                client.get().uri("/ausente").retrieve()
                        .toBodilessEntity();
                throw new AssertionError("Esperava 404");
            } catch (RestClientResponseException e) {
                if (e.getStatusCode().value() != 404) throw e;
            }
            try {
                client.get().uri("/lento").retrieve()
                        .toBodilessEntity();
                throw new AssertionError("Esperava timeout");
            } catch (ResourceAccessException e) {
                if (!(e.getCause() instanceof
                        java.net.http.HttpTimeoutException)) throw e;
            }
            System.out.println("OK: corpo 200, status 404 e timeout HTTP");
        } finally {
            server.stop(0);
            workers.shutdownNow();
        }
    }
}
mvn -q compile exec:java -Dexec.mainClass=RestClientDemo

OK: corpo 200, status 404 e timeout HTTP

O teste lento espera 800 milissegundos antes de enviar os cabeçalhos, enquanto a request factory usa 200 milissegundos de timeout de leitura. A asserção verifica a exceção de timeout como causa da falha de acesso. Ela não exige que a execução termine em um milissegundo exato, porque o escalonamento e o ambiente introduzem variação. O teste não cobre lentidão durante streaming do corpo, DNS, TLS ou timeout de conexão.

O uso de RestClient.builder() nesse programa é intencional: trata-se de um teste independente, no qual criamos e configuramos o transporte. Na aplicação Boot, mantenha a injeção do builder e teste também o bean efetivo. Assim você verifica se o perfil, o customizador ou uma dependência no classpath alterou a fábrica que realmente atende às chamadas.

RestClient no Spring Boot: erros comuns na integração

Por padrão, a recuperação com retrieve() trata respostas 4xx e 5xx como erros. É possível personalizar esse mapeamento, mas a regra deve refletir a integração. Um 404 em uma consulta de produto pode significar ausência esperada; em outro endpoint, pode indicar URL montada incorretamente. Traduzir todo 404 para “produto não existe” sem avaliar o contrato esconderia um defeito de configuração.

Falhas de acesso, como a do teste de timeout, não carregam necessariamente um status HTTP remoto. A abordagem mais clara preserva essa distinção na camada de integração e deixa a camada web decidir a resposta da sua API. O artigo sobre exceções por camada no Spring Boot ajuda a evitar um handler genérico que mistura falha do parceiro, erro de negócio e bug local.

Em um segundo cenário hipotético, uma API de frete retorna 503 durante um pico. Repetir a chamada imediatamente em todas as instâncias aumenta a carga no momento em que o parceiro está mais frágil. Antes de adicionar retry, defina quais falhas são transitórias, quantas tentativas cabem no orçamento e como a equipe limitará a concorrência. Para operações com efeito, a segurança da repetição precisa ser comprovada pelo contrato.

Outro erro comum é registrar o corpo inteiro de uma falha remota. A resposta pode conter dados pessoais ou informações operacionais. Prefira informações suficientes para diagnóstico, como integração, duração, classe da falha, status quando houver e identificador de correlação permitido. Não coloque tokens, cabeçalhos de autorização ou payloads completos em logs por conveniência.

Quando usar mock e quando testar transporte real

O MockRestServiceServer é útil para testar contratos e comportamento isolado, mas substitui a request factory do cliente. Por isso, um teste que devolve uma resposta simulada por esse mecanismo não comprova o timeout da fábrica de produção. A documentação de testes de clientes do Spring recomenda servidores mock HTTP para exercitar de forma mais completa a camada de transporte.

Um stub local, como o do exemplo, permite atrasar a resposta e observar o cliente real. Para uma suíte maior, ferramentas dedicadas podem facilitar cenários de desconexão, corpo incompleto e respostas sequenciais. A escolha deve acompanhar o risco: um teste simples de montagem de URL não precisa reproduzir TLS, mas uma política de timeout não deve depender exclusivamente de um mock que troca o transporte.

Organize essas verificações junto da estratégia descrita no guia de testes no Spring Boot. Mantenha poucos cenários de rede bem escolhidos e asserções que expliquem a falha. Um teste instável baseado em um limite de tempo apertado e sem tolerância pode esconder regressões entre falsos alarmes.

FAQ sobre RestClient e timeout

RestClient é assíncrono?

Não. Seu modelo é síncrono e a tarefa chamadora espera pela operação. Se a aplicação usa um fluxo reativo, avalie o WebClient dentro desse modelo. Trocar apenas o nome do cliente não transforma uma cadeia bloqueante em uma cadeia não bloqueante.

Posso usar o mesmo timeout para todas as integrações?

Um padrão global ajuda como ponto de partida, mas integrações podem ter orçamentos e contratos diferentes. Defina exceções de forma explícita e teste o cliente efetivo. Uma exportação longa e uma consulta usada no checkout dificilmente têm a mesma tolerância de espera.

O teste do exemplo valida as propriedades do Boot?

Não. Ele valida o RestClient com uma fábrica JDK configurada no código. Para verificar as propriedades, carregue o contexto Boot da aplicação e exercite o cliente injetado contra um servidor local com atraso controlado, usando o perfil de teste correto.

Conclusão: teste o limite que a integração promete

O próximo passo é escolher uma chamada externa da aplicação, registrar seu orçamento de resposta e testar tanto um erro HTTP quanto uma demora de transporte. Confirme qual request factory está em uso e se o retry cabe no prazo sem duplicar efeitos. Como leitura complementar, os textos sobre configuração, exceções e testes ligados acima ajudam a transformar essas decisões em um contrato que a equipe consegue manter.

Deixe um comentário