Back to Articles

Spring Boot · Integration

Your HTTP Client Needs More Than RestClient

2026-10-018 min read
Spring BootHTTP

Every Spring Boot service ends up calling at least one other service. The client that does that calling gets far less attention than the controllers it feeds, which is exactly why it is where outages come from.

The problem

A payment service that responds slowly should make your order service degrade gracefully, not hang every thread in your servlet pool waiting on a connection that will never resolve.

The naive solution

OrderService.java
1RestTemplate restTemplate = new RestTemplate();
2
3PaymentResponse response = restTemplate.postForObject(
4 "https://payments.internal/payments",
5 request,
6 PaymentResponse.class
7);

RestTemplate constructed like this has no connect timeout and no read timeout. If the payment service accepts the TCP connection and then never responds, this call blocks forever. Under load, every request thread ends up stuck the same way, and the entire order service goes down because of a slow dependency it does not even own.

Why it breaks

  • No timeout means one slow dependency exhausts your thread pool, taking down endpoints that have nothing to do with payments.
  • No connection pooling means every call pays the cost of a new TCP and TLS handshake.
  • No correlation ID propagation means the trace this series built in article 3 stops at your service boundary, and debugging a cross-service failure goes back to guesswork.
  • No consistent error mapping means a 500 from the payment service and a network timeout look identical to your calling code, when they usually call for different handling.

The production solution

Spring's RestClient (the modern replacement for RestTemplate) configured with explicit timeouts, connection pooling, and an interceptor for correlation-id propagation:

HttpClientConfig.java
1@Bean
2RestClient paymentRestClient() {
3 ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults()
4 .withConnectTimeout(Duration.ofSeconds(2))
5 .withReadTimeout(Duration.ofSeconds(5));
6
7 return RestClient.builder()
8 .baseUrl("https://payments.internal")
9 .requestFactory(ClientHttpRequestFactories.get(settings))
10 .requestInterceptor((request, body, execution) -> {
11 String traceId = MDC.get("traceId");
12 if (traceId != null) {
13 request.getHeaders().add("X-Request-ID", traceId);
14 }
15 return execution.execute(request, body);
16 })
17 .build();
18}

Two seconds to connect, five seconds to read, is a starting point, not a universal constant. It should reflect the actual latency budget of the calling endpoint, not a copy-pasted default.

For a cleaner call site, Spring's declarative HTTP interface support turns the client into a typed interface instead of manual postForObject calls:

PaymentClient.java
1public interface PaymentClient {
2
3 @PostExchange("/payments")
4 PaymentResponse createPayment(@RequestBody PaymentRequest request);
5
6 @GetExchange("/payments/{id}")
7 PaymentResponse getPayment(@PathVariable String id);
8}
9
10@Bean
11PaymentClient paymentClient(RestClient paymentRestClient) {
12 HttpServiceProxyFactory factory = HttpServiceProxyFactory
13 .builderFor(RestClientAdapter.create(paymentRestClient))
14 .build();
15 return factory.createClient(PaymentClient.class);
16}

Making it reusable

The timeout-and-interceptor configuration is identical across every outbound client in a service, so it belongs in one factory method that takes a base URL and returns a configured RestClient.Builder, not copy-pasted per client bean. Each downstream service then gets its own typed interface built on top of that shared builder.

Testing it

MockRestServiceServer or a lightweight WireMock stub lets you assert the actual behavior that matters: a slow response triggers a timeout within the configured window rather than hanging, and the outgoing request carries the X-Request-IDheader. Testing the happy path alone misses the entire reason this configuration exists.

What I learned

A client with no timeout is not "just being generous", it is a single point of failure disguised as a convenience default. Explicit timeouts feel like they might reject requests you wanted to succeed; in practice, an eventual timeout with a clear error beats an indefinite hang every time.