Intermediate · Harden

Calling External APIs with RestClient

Use the modern synchronous RestClient for outbound HTTP; know where WebClient fits and why RestTemplate is legacy.

Primary source: Spring Boot 3.5 Reference: Calling REST Services with RestClient

30 min

Lesson 1 of 3

1d streak

The Architectural Question

Default clients hide no-timeout defaults, leak connection pools, and return nulls or raw strings that push protocol details into business code.

What this guide proves

  • Configure RestClient with base URL, headers, and timeouts
  • Map error statuses to domain exceptions via the response extractor
  • Choose between RestClient and WebClient by stack, not fashion

Modern Synchronous HTTP: Spring 6 RestClient

Introduced in Spring Framework 6.1 and Spring Boot 3.2, RestClient is the modern, synchronous HTTP client that combines the familiar template-free fluent API of WebClient with the infrastructure simplicity of RestTemplate. It is the preferred modern option for new synchronous HTTP code. RestTemplate remains supported but is in maintenance mode in Spring Framework 6.2.

Unlike RestTemplate, which relies on heavy method overloading with dozens of parameters, RestClient provides an expressive, chained builder for headers, URI variables, request bodies, and error response handling.

src/main/java/com/example/client/PaymentGatewayClient.java
package com.example.client;

import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class PaymentGatewayClient {

    private final RestClient restClient;

    public PaymentGatewayClient(RestClient.Builder builder) {
        this.restClient = builder
            .baseUrl("https://api.payments.internal/v1")
            .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
            .build();
    }

    public PaymentReceipt charge(ChargeRequest command) {
        return restClient.post()
            .uri("/charges")
            .contentType(MediaType.APPLICATION_JSON)
            .header("Idempotency-Key", command.idempotencyKey())
            .body(command)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                throw new PaymentDeclinedException("Client error: " + response.getStatusCode());
            })
            .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                throw new GatewayUnavailableException("Payment gateway down: " + response.getStatusCode());
            })
            .body(PaymentReceipt.class);
    }
}

Always Inject RestClient.Builder

Prefer injecting the auto-configured RestClient.Builder so shared message converters, request factories, metrics, and tracing interceptors are preserved. RestClient.create() can still be appropriate when those application-wide customizations are not needed.

Declarative HTTP Interfaces with @HttpExchange

Spring 6 introduced declarative HTTP service interfaces. Similar to OpenFeign, you define a clean Java interface with @HttpExchange annotations, and Spring generates the dynamic proxy implementation backed by RestClient or WebClient at startup.

src/main/java/com/example/client/CustomerClient.java
package com.example.client;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;

@HttpExchange("/customers")
public interface CustomerClient {

    @GetExchange("/{id}")
    CustomerDto findById(@PathVariable("id") String id);

    @GetExchange
    CustomerListResponse search(@RequestParam("email") String email);

    @PostExchange
    CustomerDto create(CreateCustomerRequest request);
}
src/main/java/com/example/config/ClientConfig.java
package com.example.config;

import com.example.client.CustomerClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

@Configuration(proxyBeanMethods = false)
public class ClientConfig {

    @Bean
    public CustomerClient customerClient(RestClient.Builder builder) {
        RestClient restClient = builder.baseUrl("https://api.customers.internal").build();
        var adapter = RestClientAdapter.create(restClient);
        var factory = HttpServiceProxyFactory.builderFor(adapter).build();
        return factory.createClient(CustomerClient.class);
    }
}

Connection Pooling and Socket Factory Tuning

If RestClient is left on SimpleClientHttpRequestFactory backed by the JDK, it does not provide the connection-pool behavior needed for high-concurrency workloads. Spring Boot may select a different request factory when a supported HTTP client is configured, so verify the actual factory and pool settings.

In production on Spring Boot 3.5, back RestClient with Apache HttpClient 5 or JDK HttpClient, tuning max connections, keep-alive, and idle connection eviction.

src/main/java/com/example/config/HttpClientCustomizerConfig.java
package com.example.config;

import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.core5.util.Timeout;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;

@Configuration(proxyBeanMethods = false)
public class HttpClientCustomizerConfig {

    @Bean
    public RestClientCustomizer restClientCustomizer() {
        PoolingHttpClientConnectionManager pool = new PoolingHttpClientConnectionManager();
        pool.setMaxTotal(200);
        pool.setDefaultMaxPerRoute(50);

        RequestConfig requestConfig = RequestConfig.custom()
            .setConnectTimeout(Timeout.ofMilliseconds(1000))
            .setResponseTimeout(Timeout.ofMilliseconds(3000))
            .build();

        CloseableHttpClient httpClient = HttpClients.custom()
            .setConnectionManager(pool)
            .setDefaultRequestConfig(requestConfig)
            .evictExpiredConnections()
            .build();

        return builder -> builder.requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient));
    }
}

Deterministic Client Testing with MockRestServiceServer

Verify RestClient error handling and headers deterministically in tests using MockRestServiceServer without booting an external HTTP mock:

src/test/java/com/example/client/PaymentGatewayClientTest.java
@RestClientTest(PaymentGatewayClient.class)
class PaymentGatewayClientTest {

    @Autowired
    private PaymentGatewayClient client;

    @Autowired
    private MockRestServiceServer server;

    @Test
    void maps500ToGatewayUnavailableException() {
        server.expect(requestTo("https://api.payments.internal/v1/charges"))
            .andExpect(method(HttpMethod.POST))
            .andExpect(header("Idempotency-Key", "idemp-42"))
            .andRespond(withServerError());

        assertThrows(GatewayUnavailableException.class, () -> 
            client.charge(new ChargeRequest("idemp-42", 5000)));

        server.verify();
    }
}

Active Recall Checkpoint

Why should production RestClient instances use a pooled client request factory like Apache HttpClient 5?

Hint: Think about TCP 3-way handshakes and TIME_WAIT socket states.

Show answer & explanation

Default JDK HttpURLConnection does not reuse TCP sockets across threads, causing TCP socket churn, TLS handshake latency spikes, and socket exhaustion under load.

Runtime Architecture & Flow

Decision canvas

Evaluate the choice across three constraints

These are comparison criteria, not sequential steps. A strong answer states which constraint dominates in the current system.

CRITERION 01

Configure RestClient with base URL, headers, and timeouts

Ask how each option changes this guarantee, its operational cost, and its failure mode.

CRITERION 02

Map error statuses to domain exceptions via the response extractor

Ask how each option changes this guarantee, its operational cost, and its failure mode.

CRITERION 03

Choose between RestClient and WebClient by stack, not fashion

Ask how each option changes this guarantee, its operational cost, and its failure mode.

Decision prompt: Call a fake exchange-rate API from a service, translate 4xx/5xx into typed exceptions, and unit-test the mapper without a server.

Working Implementation

Code Walkthrough

Call a fake exchange-rate API from a service, translate 4xx/5xx into typed exceptions, and unit-test the mapper without a server.

RestClientHttpExchangeLab.java
package com.example.client;

import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

@Service
public class RestClientHttpExchangeLab {

    public record UserProfile(String id, String username, String email) {}

    @HttpExchange("/users")
    public interface UserClient {
        @GetExchange("/{id}")
        UserProfile getUserById(@PathVariable("id") String id);
    }

    public static RestClient createRestClient(RestClient.Builder builder, String baseUrl) {
        return builder
            .baseUrl(baseUrl)
            .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
            .build();
    }

    public static UserClient createDeclarativeClient(RestClient restClient) {
        var adapter = RestClientAdapter.create(restClient);
        var factory = HttpServiceProxyFactory.builderFor(adapter).build();
        return factory.createClient(UserClient.class);
    }

    public UserProfile fetchWithFluentApi(RestClient restClient, String userId) {
        return restClient.get()
            .uri("/users/{id}", userId)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
                throw new IllegalArgumentException("User not found: " + userId);
            })
            .onStatus(HttpStatusCode::is5xxServerError, (req, res) -> {
                throw new IllegalStateException("Remote service unavailable: " + res.getStatusCode());
            })
            .body(UserProfile.class);
    }
}
RestClient is the modern fluent synchronous client available with Spring Framework 6.1 and Spring Boot 3.2+; existing RestTemplate applications can migrate deliberately rather than treating it as an automatic replacement.
@HttpExchange interfaces allow Spring to generate type-safe HTTP client proxies automatically without OpenFeign.
Injecting the Boot-configured RestClient.Builder preserves its configured message converters and request-factory selection. Connection pooling and observations still depend on the chosen HTTP client and observability configuration.

Synthesis & Production Verification

Deliberate Failure Condition

  1. Break: Create RestClient without connect/response timeouts
  2. Observe: One hung upstream pins Tomcat threads until the whole service stops responding
  3. Repair: Explicit timeouts are the first line of resilience; typed exception translation keeps HTTP out of the domain layer.

Production Acceptance Criteria

  • Set connect and read timeouts explicitly at build time
  • Translate 404 and 503 responses into distinct exceptions
  • State when WebClient is required despite using MVC

Architectural & Technical Review

Core Interview Questions

Q01What are the architectural advantages of RestClient over RestTemplate in Spring Boot 3.2+?Show answer

RestClient provides a fluent, non-blocking-style API for synchronous HTTP without hundreds of overloaded methods, offers expressive status code mapping via onStatus(), and supports declarative HTTP interfaces.

Q02How do declarative @HttpExchange interfaces generate type-safe HTTP clients without third-party libraries?Show answer

Developers define a Java interface annotated with @HttpExchange and @GetExchange/@PostExchange. HttpServiceProxyFactory creates a dynamic proxy backed by RestClient that handles URL mapping, parameter binding, and serialization automatically.

Q03Why must production RestClient instances configure an underlying pooled ClientHttpRequestFactory?Show answer

Default JDK HttpURLConnection creates new TCP sockets for each request. Configuring Apache HttpClient 5 or JDK HttpClient with a connection pool enables HTTP keep-alive, connection reuse, and socket lifecycle management.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Using default JDK HttpURLConnection for high-throughput external service integration — Fails to reuse TCP sockets, causing TCP socket churn, TLS handshake delays, and port exhaustion.
  • Pitfall: Using RestClient.create() directly instead of injecting the auto-configured RestClient.Builder — Bypasses Spring Boot metrics, distributed tracing propagation, and global message converters.

Primary Documentation & Specifications

Supplementary Module Resources