Trilha de Kotlin · parte 44 de 59
Clientes HTTP com RestClient e WebClient
Chame outro serviço com RestClient e WebClient, trate erros de status, faça o timeout disparar de verdade e teste o cliente sem rede, com saídas reais.
intermediárioPublicada em · 13 min de leitura
Clientes HTTP com RestClient e WebClient
Pense numa ligação para a transportadora perguntando quanto custa enviar uma caixa. Você disca, espera alguém atender e recebe um valor. Se a linha ficar muda, você não sabe se o atendente ouviu o CEP. Se ele disser “não atendemos essa região”, a resposta é uma recusa, e não uma falha de linha. A analogia tem limite: o telefone não tem código de status, e quem liga não configura quanto tempo espera.
Esta aula mostra como um serviço Spring Boot chama outro, com o RestClient (síncrono) e com o WebClient (reativo, usado com coroutines). O exemplo é uma consulta de frete por CEP, e todas as saídas vieram de execuções reais.
o mapa da aula
Começamos pelo motivo de um serviço chamar outro. Depois vêm o RestClient, os erros de status, os timeouts, o WebClient com coroutines, as interfaces @HttpExchange e o teste sem rede. Retentativa e disjuntor têm aula própria, em Resiliência e empacotamento.
Versões desta aula: Spring Boot 4.1.2, marcado como “Stable” na documentação em 04/11/2026, com Spring Framework 7.0.10 como mínimo. Kotlin 2.4.21, Gradle 9.7.0, JDK 21 e JDK 25. Outras aulas da fase citam Spring Boot 4.1.1, e esta usa a linha seguinte, que é a padrão do Spring Initializr. O RestClient e o WebClient ficam em projetos separados, porque o starter do WebClient traz o Reactor Netty e isso muda o cliente HTTP que o RestClient escolhe.
1. por que um serviço chama outro
Um serviço de pedidos precisa do frete antes de fechar a compra, e o cálculo mora em outro serviço, com outra equipe. Sem HTTP sobram duas saídas ruins: ler o banco do outro, o que acopla as equipes ao mesmo esquema de tabelas, ou copiar a regra de frete, que diverge na primeira mudança de preço. Uma chamada HTTP deixa o contrato explícito, com URL, verbo, JSON e status. Em troca, ela pode falhar, demorar ou ser recusada, e quem chama decide o que fazer em cada caso.
2. o RestClient: criar e chamar
O RestClient é o cliente síncrono do Spring Framework 7. Você escolhe verbo, URL, cabeçalhos e corpo, e termina com retrieve(). A documentação do Boot recomenda injetar o RestClient.Builder que ele cria, em vez de chamar RestClient.create(), porque o builder já traz os conversores e as propriedades de cliente HTTP. As importações do topo do arquivo ficam fora do trecho.
data class Frete(val cep: String, val valorCentavos: Int, val prazo: Int)
data class NovaCotacao(val cep: String, val peso: Int)
data class Cotacao(val id: Long, val cep: String, val valorCentavos: Int, val prazo: Int)
class CepNaoAtendido(cep: String) : RuntimeException("CEP $cep fora da área de entrega")
class FreteIndisponivel(cep: String) : RuntimeException("tabela de frete indisponível para $cep")
@Component
class FreteClient(builder: RestClient.Builder) {
private val rest = builder.baseUrl("http://127.0.0.1:18791").build()
fun buscar(cep: String): Frete =
rest.get().uri("/fretes/{cep}", cep)
.retrieve()
.body<Frete>()!!
fun buscarMapeado(cep: String): Frete =
rest.get().uri("/fretes/{cep}", cep)
.retrieve()
.onStatus({ it.value() == 404 }) { _, _ -> throw CepNaoAtendido(cep) }
.onStatus(HttpStatusCode::is5xxServerError) { _, _ -> throw FreteIndisponivel(cep) }
.body<Frete>()!!
fun cotar(cep: String): ResponseEntity<Cotacao> =
rest.post().uri("/cotacoes")
.contentType(MediaType.APPLICATION_JSON)
.body(NovaCotacao(cep, 2))
.retrieve()
.toEntity<Cotacao>()
fun comToken(token: String): String =
rest.get().uri("/eco-autorizacao")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.retrieve()
.body<String>()!!
}
body<Frete>() converte o JSON com o conversor que o Boot configurou, que nesta versão é o Jackson 3, como mostra Serialização com kotlinx.serialization e Jackson. toEntity<Cotacao>() devolve também o status e os cabeçalhos. O token viaja num cabeçalho, e o servidor de exemplo devolveu Bearer abc123, o que confirma a chegada.
O servidor de exemplo é um HttpServer do JDK, na porta 18791. Ele responde 200 para o CEP 01001000, 404 para 00000000, 500 para 99999999, e espera dois segundos em /lento. Os endpoints /cotacoes (POST, devolve 201) e /eco-autorizacao (devolve o cabeçalho Authorization) completam o servidor. O código do servidor não está nesta nota. As linhas de sucesso saíram assim:
buscar 01001000: Frete(cep=01001000, valorCentavos=2490, prazo=3)
cotar: 201 CREATED Cotacao(id=7, cep=01001000, valorCentavos=2490, prazo=3)
token recebido: Bearer abc123
3. status de erro
Por padrão, retrieve() lança uma subclasse de RestClientException para 4xx e 5xx: HttpClientErrorException.NotFound no 404 e HttpServerErrorException.InternalServerError no 500. A mensagem traz o corpo da resposta, o que ajuda a diagnosticar, mas faz o log depender do formato de erro do outro serviço. O método buscarMapeado troca os dois erros por exceções do domínio com onStatus. O bloco abaixo chama os dois CEPs de erro pelos dois caminhos:
for (cep in listOf("00000000", "99999999")) {
try {
cliente.buscar(cep)
} catch (e: RestClientResponseException) {
println("sem onStatus: ${e.javaClass.simpleName}: ${e.message}")
}
try {
cliente.buscarMapeado(cep)
} catch (e: RuntimeException) {
println("com onStatus: ${e.javaClass.simpleName}: ${e.message}")
}
}
sem onStatus: NotFound: 404 Not Found: "{"erro":"CEP fora da área de entrega"}"
com onStatus: CepNaoAtendido: CEP 00000000 fora da área de entrega
sem onStatus: InternalServerError: 500 Internal Server Error: "{"erro":"tabela de frete indisponível"}"
com onStatus: FreteIndisponivel: tabela de frete indisponível para 99999999
Na figura, uma resposta 2xx segue para a conversão do corpo, à esquerda. Uma resposta de erro passa pelo teste de onStatus. Os dois caminhos de erro estão em rosa, porque é ali que a aplicação precisa decidir alguma coisa.
4. timeouts
Uma chamada tem dois prazos. O timeout de conexão limita a abertura da conexão, e o timeout de leitura limita a espera pela resposta. Sem nenhum dos dois, a thread espera o servidor pelo tempo que ele quiser.
No Spring Boot 4.1.2, a configuração global para os clientes HTTP são as propriedades spring.http.clients.read-timeout e spring.http.clients.connect-timeout, listadas no apêndice de propriedades. A documentação diz que RestClient.create() não recebe a auto-configuração do Boot. O trecho abaixo compara os dois casos com o endpoint /lento:
val lentoBuilder = builder.baseUrl("http://127.0.0.1:18791").build()
try {
println("lento com RestClient.Builder: ${lentoBuilder.get().uri("/lento").retrieve().body<String>()}")
} catch (e: ResourceAccessException) {
println("lento com RestClient.Builder: ${e.javaClass.simpleName}: ${e.message}")
println("causa: ${e.cause?.javaClass?.name}: ${e.cause?.message}")
}
val solto = RestClient.create("http://127.0.0.1:18791")
val inicio = System.nanoTime()
val resposta = solto.get().uri("/lento").retrieve().body<String>()
val ms = (System.nanoTime() - inicio) / 1_000_000
println("lento com RestClient.create(): $resposta, esperou 2 s: ${ms >= 1900}")
A propriedade entra como argumento da linha de comando. Com --spring.http.clients.read-timeout=500ms, a saída foi:
lento com RestClient.Builder: ResourceAccessException: I/O error on GET request for "http://127.0.0.1:18791/lento": Request cancelled
causa: java.net.http.HttpTimeoutException: Request cancelled
lento com RestClient.create(): {"ok":true}, esperou 2 s: true
A mensagem Request cancelled vem do cliente HTTP do JDK, por isso vale olhar o tipo da causa. Sem a propriedade, o builder também esperou os dois segundos:
lento com RestClient.Builder: {"ok":true}
lento com RestClient.create(): {"ok":true}, esperou 2 s: true
A figura põe os dois caminhos lado a lado. O ✕ marca a configuração que não chega ao RestClient.create(). O timeout de conexão não foi disparado: a propriedade existe, mas não montei um endereço que não responde.
5. WebClient e coroutines
O WebClient é o cliente reativo do Spring WebFlux, e devolve Mono e Flux em vez de esperar. Para coroutines, a documentação do Spring pede kotlinx-coroutines-core e kotlinx-coroutines-reactor no classpath. As extensões awaitBody e awaitExchange ficam no pacote org.springframework.web.reactive.function.client.
A página de coroutines do Spring mostra .awaitExchange().awaitBody<Banner>(), sem argumento. No jar spring-webflux 7.0.10, porém, awaitExchange só tem a forma com bloco, e a forma sem bloco não compila. O código usa a forma com bloco. Para devolver null no 404, usei awaitExchangeOrNull, porque awaitExchange não aceitou o nulo nesta versão.
suspend fun buscarOuNull(cep: String): Frete? =
web.get().uri("/fretes/{cep}", cep)
.awaitExchangeOrNull { resposta ->
if (resposta.statusCode().value() == 404) null else resposta.awaitBody<Frete>()
}
suspend fun buscarRetrieve(cep: String): Frete =
web.get().uri("/fretes/{cep}", cep)
.retrieve()
.awaitBody<Frete>()
Com retrieve(), o status de erro lança a exceção do WebClient. A mensagem dessa exceção não traz o corpo, ao contrário da do RestClient:
buscarOuNull com 404: null
retrieve 00000000: NotFound: 404 Not Found from GET http://127.0.0.1:18791/fretes/00000000
retrieve 99999999: InternalServerError: 500 Internal Server Error from GET http://127.0.0.1:18791/fretes/99999999
Para o paralelismo, duas chamadas em sequência contra duas em async, cada uma para /lento. O código de cronometragem não está nesta nota; a saída guarda só a comparação:
sequencial levou pelo menos 4 s: true
paralelo levou menos de 3 s: true
Os tempos exatos mudam a cada execução, por isso a saída guarda só a comparação.
Os dois async chegam ao WebClient, que abre as duas requisições dentro do par. As respostas voltam juntas, e o awaitAll devolve o resultado ao escopo. A demonstração usa runBlocking porque roda na inicialização. Num controller, isso seria o erro de Dispatchers e bloqueio. Use suspend, como em Spring Boot com controllers suspend.
Pergunta de entrevistaPor que configurar timeouts em um cliente HTTP, e que armadilha existe com o RestClient no Spring Boot?
Uma chamada tem dois prazos: o timeout de conexão limita a abertura da conexão, e o de leitura limita a espera pela resposta. Sem nenhum dos dois, a thread espera o servidor pelo tempo que ele quiser, e uma dependência lenta pode acabar prendendo threads do meu serviço. No Spring Boot, as propriedades spring.http.clients.read-timeout e connect-timeout configuram os clientes, mas a documentação diz que RestClient.create() não recebe a auto-configuração do Boot. Na aula, a propriedade fez o builder injetado falhar em 500 ms, enquanto o RestClient.create() esperou os 2 segundos do endpoint lento. Por isso injeto o Builder e confiro o timeout contra um endpoint lento.
6. quando usar cada um
O RestClient combina com código imperativo e bloqueante. Sem outra biblioteca no classpath, ele usa o cliente HTTP do JDK, o que a documentação diz poder gerar diferenças sutis de comportamento. O WebClient faz mais sentido quando a pilha já é reativa ou o código é suspend. Sobre virtual threads, não medi nada aqui. O RestTemplate segue no Spring, marcado como depreciado em favor do RestClient.
7. interfaces declarativas com @HttpExchange
Para muitos endpoints, uma interface anotada com @GetExchange descreve o contrato, e a fábrica cria o proxy que faz a chamada. Na documentação do Spring Framework 7.0.10, o recurso aparece como estável.
interface FreteApi {
@GetExchange("/fretes/{cep}")
fun buscar(@PathVariable cep: String): Frete
}
val adaptador = RestClientAdapter.create(builder.baseUrl("http://127.0.0.1:18791").build())
val api = HttpServiceProxyFactory.builderFor(adaptador).build().createClient(FreteApi::class.java)
println("interface HTTP: ${api.buscar("01001000")}")
interface HTTP: Frete(cep=01001000, valorCentavos=2490, prazo=3)
Pergunta de entrevistaQuando você usaria RestClient e quando usaria WebClient?
O RestClient combina com código imperativo e bloqueante. Sem outra biblioteca no classpath, ele usa o cliente HTTP do JDK, e a documentação avisa que isso pode gerar diferenças sutis de comportamento. O WebClient faz mais sentido quando a pilha já é reativa ou o código é suspend, como em controllers com coroutines. O RestTemplate continua no Spring, mas está marcado como depreciado em favor do RestClient, então não o uso em código novo. A escolha deve seguir o modelo de execução do restante da aplicação, para não misturar código bloqueante e reativo sem necessidade.
8. testar o cliente sem a rede real
O Boot recomenda @RestClientTest para clientes REST. A anotação configura um RestClient.Builder e acrescenta o MockRestServiceServer, que responde no lugar do servidor real. Como o builder está no teste, as expectativas usam a URI completa.
@RestClientTest(FreteClient::class)
class FreteClientTest(
@Autowired private val cliente: FreteClient,
@Autowired private val server: MockRestServiceServer,
) {
@Test
fun `interpreta 200 como frete`() {
server.expect(requestTo("http://127.0.0.1:18791/fretes/01001000"))
.andRespond(withSuccess("""{"cep":"01001000","valorCentavos":2490,"prazo":3}""", MediaType.APPLICATION_JSON))
assertEquals(2490, cliente.buscar("01001000").valorCentavos)
server.verify()
}
@Test
fun `mapeia 404 para CepNaoAtendido`() {
server.expect(requestTo("http://127.0.0.1:18791/fretes/00000000")).andRespond(withResourceNotFound())
assertFailsWith<CepNaoAtendido> { cliente.buscarMapeado("00000000") }
}
@Test
fun `mapeia 500 para FreteIndisponivel`() {
server.expect(requestTo("http://127.0.0.1:18791/fretes/99999999")).andRespond(withServerError())
assertFailsWith<FreteIndisponivel> { cliente.buscarMapeado("99999999") }
}
}
Os três testes passaram no JDK 21, com o servidor de exemplo parado, então nenhuma chamada saiu da máquina:
aula.FreteClientTest: tests=3 failures=0 errors=0
9. erros comuns e boas práticas
Usar RestClient.create() esperando os timeouts do Boot. A seção 4 mostra que o cliente ignora a propriedade, e nada aparece na compilação. Injete o RestClient.Builder.
Não configurar timeout. Sem ele a chamada espera o servidor. Escolha o valor pelo tempo que o chamador aguenta, e não pelo que o servidor costuma levar.
Tratar 404 e 500 do mesmo jeito. Um CEP fora da área não é falha do servidor, e um 500 não é recusa do cliente. O onStatus separa os casos.
Esperar que onStatus valha para exchange(). A documentação diz que os handlers não são aplicados nesse método, que dá acesso à resposta completa.
Colocar retentativa à mão no código de negócio. Intervalo crescente, jitter e disjuntor são assunto de Resiliência e empacotamento.
10. exercícios
- Rode a aplicação com
--spring.http.clients.read-timeout=3s. Diga por que o/lento, que espera dois segundos, passa agora, e compare com a seção 4.
Resposta, rodada no JDK 21 e no JDK 25, com a mesma saída nos dois:
lento com RestClient.Builder: {"ok":true}
lento com RestClient.create(): {"ok":true}, esperou 2 s: true
O corte de leitura de 3 s é maior que os 2 s que o servidor leva, então a resposta chega antes do limite. Com 500 ms, o limite venceu antes.
- Escreva um teste com
MockRestServiceServerque responde 400 para o CEP12345678. Antes de rodar, diga se você esperaCepNaoAtendido.
Resposta: o método abaixo foi rodado numa classe separada, Exercicio400Test, com o mesmo cabeçalho da seção 8, e passou nos dois JDKs.
@Test
fun `400 nao vira CepNaoAtendido`() {
server.expect(requestTo("http://127.0.0.1:18791/fretes/12345678")).andRespond(withBadRequest())
assertFailsWith<HttpClientErrorException.BadRequest> { cliente.buscarMapeado("12345678") }
}
aula.Exercicio400Test: tests=1 failures=0 errors=0
A exceção que sai é HttpClientErrorException.BadRequest, e não CepNaoAtendido. O onStatus do exemplo trata só o 404 e a família 5xx, então o 400 cai na exceção padrão do RestClient. Um cliente de produção precisaria de um terceiro onStatus para 4xx, ou da decisão explícita de deixar o erro subir.
11. glossário
Timeout de conexão: tempo máximo para abrir a conexão com o servidor.
Timeout de leitura: tempo máximo de espera pela resposta depois de conectado.
RestClient.Builder: builder que o Boot injeta para criar clientes já configurados.
Interface HTTP: interface anotada com @HttpExchange, cujo proxy faz a chamada.
MockRestServiceServer: servidor simulado do Spring Test que responde ao RestClient no teste.
o que não foi confirmado ou medido
O timeout de conexão não foi disparado. A propriedade foi conferida no apêndice, mas não houve endereço que não responde.
A página de coroutines do Spring mostra awaitExchange sem bloco, e o jar 7.0.10 não tem essa forma. A divergência está registrada, e o código usa a forma com bloco.
Os códigos de cronometragem do paralelismo e do servidor de exemplo não estão nesta nota. O rótulo awaitExchangeOrNull não foi confirmado com javap nesta revisão.
O custo do bloqueio dentro de coroutine e o comportamento de virtual threads com RestClient não foram medidos.
O WebClient foi visto pela lista de dependências, com Reactor Netty. O tipo da fábrica em tempo de execução não foi impresso.
O WebClient não foi testado com @WebClientTest.
O log do Reactor Netty mostrou um erro de biblioteca nativa de DNS do macOS, sem efeito nos resultados, e não investiguei a causa.
Os jars foram compilados com o JDK que rodou o Gradle, sem toolchain fixa. O jar do JDK 25 não roda no JDK 21, e cada JDK usou o próprio jar. A plataforma foi Mac com Apple silicon.
OAuth client credentials, gRPC, retentativa e disjuntor ficaram fora.
Fontes
Consultadas em 04/11/2026.
- Spring Boot, requisitos, com 4.1.2 como “Stable” e Spring Framework 7.0.10 como mínimo: https://docs.spring.io/spring-boot/system-requirements.html
- Spring Boot, chamadas REST, com o
RestClient.Builderinjetado e a nota sobreRestClient.create(): https://docs.spring.io/spring-boot/reference/io/rest-client.html - Spring Boot, apêndice de propriedades, com
spring.http.clients.read-timeouteconnect-timeout: https://docs.spring.io/spring-boot/appendix/application-properties/index.html - Spring Boot, testes, com
@RestClientTeste@WebClientTest: https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html - Spring Framework, REST Clients, com RestClient, WebClient,
onStatus,exchangee HTTP Service Clients: https://docs.spring.io/spring-framework/reference/integration/rest-clients.html - Spring Framework, Kotlin coroutines, com
awaitExchange,awaitBodye as dependências: https://docs.spring.io/spring-framework/reference/languages/kotlin/coroutines.html
Verificações locais, em 04/11/2026: o jar spring-webflux-7.0.10 foi consultado com javap, e as saídas vêm de execuções nos JDKs 21 e 25.
Relacionados
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- Spring Boot com controllers suspend
- Serialização com kotlinx.serialization e Jackson
- Tratamento de erros com ProblemDetail
- Segurança com JWT e OpenAPI
- Starters e configuração por ambiente
- Dispatchers e bloqueio
- Virtual threads e testes com runTest
- Resiliência e empacotamento
- HTTP e REST
- Injeção de dependência e IoC no Spring