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
Lesson 1 of 3
1d streakThe 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.
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
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.
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);
}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.
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:
@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.
Configure RestClient with base URL, headers, and timeouts
Ask how each option changes this guarantee, its operational cost, and its failure mode.
Map error statuses to domain exceptions via the response extractor
Ask how each option changes this guarantee, its operational cost, and its failure mode.
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.
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);
}
}Synthesis & Production Verification
Deliberate Failure Condition
- Break: Create RestClient without connect/response timeouts
- Observe: One hung upstream pins Tomcat threads until the whole service stops responding
- 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
Key Takeaway
Spring 6 RestClient provides a modern fluent synchronous HTTP client, unified error status mapping, and declarative @HttpExchange interface proxies.
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
Lesson Primary References
Supplementary Module Resources