Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 039 / 100

Calling other services: RestClient and WebClient explained

Typed HTTP clients with timeouts, errors and retries.

Expona Admin7 min read

The challenge of HTTP communication in Spring Boot

When your Spring Boot service needs to call another API—whether it's a payment gateway, a user service, or an external SaaS provider—you need a reliable, type-safe HTTP client. For years, developers reached for RestTemplate, but that's now deprecated. The modern Spring Framework gives us two paths forward: RestClient for blocking code and WebClient for reactive workloads. Calling other services: RestClient and WebClient explained covers both, along with the timeouts, error handling, and retries that separate production-ready code from what breaks at 3 AM.

RestClient for blocking code — The modern synchronous client

RestClient is the synchronous successor to RestTemplate. It's part of Spring Framework 6.1+ and pairs beautifully with Spring Boot 3.2 and virtual threads. If your service processes requests in the traditional servlet model—one thread per request—RestClient is your answer.

Here's the foundational pattern:

java
@Service
public class UserClient {
  private final RestClient restClient;

  public UserClient(RestClient.Builder builder) {
    this.restClient = builder
      .baseUrl("http://user-service:8080")
      .build();
  }

  public UserDto getUserById(Long id) {
    return restClient.get()
      .uri("/users/{id}", id)
      .retrieve()
      .body(UserDto.class);
  }
}

Notice that we inject RestClient.Builder rather than a singleton. Spring wires this for us. The API is fluent and intent-clear: get(), uri(), retrieve(), body(). No XML config, no bean factories, no boilerplate.

For a POST with a body:

java
public void createOrder(OrderRequest request) {
  restClient.post()
    .uri("/orders")
    .body(request)
    .retrieve()
    .toBodilessEntity();
}

The thread blocks until the response arrives. Virtual threads make this scale: Java 21+ virtual threads are cheap enough that blocking I/O becomes acceptable again, even with thousands of concurrent requests.

Set timeouts — Default timeouts can be infinite

This is where many production incidents begin. If you don't set timeouts explicitly, your requests may hang forever, exhausting the thread pool and cascading failures across your infrastructure.

Configure timeouts at the HTTP client level:

java
@Bean
public RestClient restClient(RestClient.Builder builder) {
  HttpComponentsClientHttpRequestFactory factory = 
    new HttpComponentsClientHttpRequestFactory();
  
  factory.setConnectTimeout(Duration.ofSeconds(5));
  factory.setReadTimeout(Duration.ofSeconds(10));
  
  return builder
    .baseUrl("http://user-service:8080")
    .requestFactory(factory)
    .build();
}

Connect timeout: How long to wait establishing a TCP connection. Set this low (3-5 seconds). If the remote host is unreachable, fail fast.

Read timeout: How long to wait for data after the connection is established. This depends on your SLA. For a user lookup, 10 seconds is reasonable. For a bulk export, you might need 60 seconds.

Always consider per-endpoint timeouts too. A critical path might tolerate 5 seconds; a batch job might allow 30. Use separate RestClient instances or per-request configuration:

java
public UserDto getUserById(Long id) {
  return restClient.get()
    .uri("/users/{id}", id)
    .retrieve()
    .body(UserDto.class);
}

public List<UserDto> bulkExport() {
  // Use a separate, longer-timeout client for batch operations
  return bulkRestClient.get()
    .uri("/users/export")
    .retrieve()
    .body(new ParameterizedTypeReference<>() {});
}

HTTP interfaces — Declare an interface with @GetExchange

Spring 6.0 introduced HTTP interface proxies: declare a Java interface and Spring generates the implementation. This moves HTTP plumbing to the framework and makes your client layer look like a regular service dependency.

java
public interface UserServiceClient {
  
  @GetExchange("/users/{id}")
  UserDto getUser(@PathVariable Long id);
  
  @PostExchange("/users")
  UserDto createUser(@RequestBody UserRequest request);
  
  @DeleteExchange("/users/{id}")
  void deleteUser(@PathVariable Long id);
}

Register it in your configuration:

java
@Configuration
public class HttpClientConfig {
  
  @Bean
  public UserServiceClient userServiceClient(RestClient.Builder builder) {
    RestClient restClient = builder
      .baseUrl("http://user-service:8080")
      .build();
    
    return HttpServiceProxyFactory
      .builderFor(RestClientAdapter.create(restClient))
      .build()
      .createClient(UserServiceClient.class);
  }
}

Inject and use it like any other service:

java
@Service
public class OrderService {
  private final UserServiceClient userClient;

  public OrderService(UserServiceClient userClient) {
    this.userClient = userClient;
  }

  public void processOrder(Long userId) {
    UserDto user = userClient.getUser(userId);
    // Process order with user data
  }
}

This approach scales well: your client layer is just interfaces, easy to test with mocks, and the Spring framework handles all the HTTP mechanics.

WebClient when reactive — Use WebClient only if the rest of your stack is reactive

WebClient is Spring's non-blocking HTTP client. It returns Mono<T> and Flux<T> for reactive composition. Use it only if your controllers, repositories, and services already use reactive types.

java
@Service
public class ReactiveUserClient {
  private final WebClient webClient;

  public ReactiveUserClient(WebClient.Builder builder) {
    this.webClient = builder
      .baseUrl("http://user-service:8080")
      .build();
  }

  public Mono<UserDto> getUserById(Long id) {
    return webClient.get()
      .uri("/users/{id}", id)
      .retrieve()
      .bodyToMono(UserDto.class);
  }

  public Flux<UserDto> getAllUsers() {
    return webClient.get()
      .uri("/users")
      .retrieve()
      .bodyToFlux(UserDto.class);
  }
}

WebClient also uses non-blocking I/O under the hood (Netty by default) and requires fewer threads. But it demands reactive thinking throughout your stack.

Configure timeouts with HttpClient:

java
@Bean
public WebClient webClient(WebClient.Builder builder) {
  HttpClient httpClient = HttpClient.create()
    .responseTimeout(Duration.ofSeconds(10))
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);

  return builder
    .baseUrl("http://user-service:8080")
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .build();
}

When not to use WebClient: If most of your code is imperative (traditional Spring MVC controllers, standard @Transactional methods), mixing in WebClient forces you into reactor blocking operators like block(), losing all reactive benefits and introducing thread starvation bugs.

Common mistakes

  • No timeouts configured. Leads to thread pool exhaustion and cascading failures. Always set both connect and read timeouts.
  • Mixing RestClient and WebClient carelessly. Don't use WebClient in a blocking context without good reason. The overhead of reactive operators in non-reactive code is real.
  • Forgetting to handle errors. RestClient throws HttpClientErrorException and HttpServerErrorException on 4xx/5xx responses. Wrap your calls in try-catch or use Spring's exception translation.
  • Using the same timeout for all endpoints. A quick metadata lookup and a long-running export have very different SLAs. Consider per-endpoint clients.
  • Not testing retries properly. If you add retry logic, verify it under failure scenarios. Use WireMock or Testcontainers to simulate timeouts and 5xx errors.

Error handling and retries

Production HTTP clients need graceful degradation. RestClient throws exceptions; catch them and decide what to do:

java
public UserDto getUserByIdWithFallback(Long id) {
  try {
    return restClient.get()
      .uri("/users/{id}", id)
      .retrieve()
      .body(UserDto.class);
  } catch (HttpClientErrorException.NotFound e) {
    return new UserDto(id, "Unknown User");
  } catch (HttpServerErrorException e) {
    log.error("User service returned 5xx", e);
    throw new ServiceUnavailableException("User service down");
  } catch (ResourceAccessException e) {
    log.error("User service unreachable", e);
    throw new ServiceUnavailableException("User service unreachable");
  }
}

For retries, use Spring Retry or Resilience4j. Here's a simple Spring Retry example:

java
@Service
public class ResilientUserClient {
  private final RestClient restClient;

  public ResilientUserClient(RestClient.Builder builder) {
    this.restClient = builder.baseUrl("http://user-service:8080").build();
  }

  @Retryable(
    maxAttempts = 3,
    backoff = @Backoff(delay = 100, multiplier = 2.0)
  )
  public UserDto getUserById(Long id) {
    return restClient.get()
      .uri("/users/{id}", id)
      .retrieve()
      .body(UserDto.class);
  }

  @Recover
  public UserDto recover(Exception e, Long id) {
    log.warn("All retries exhausted for user {}", id);
    return new UserDto(id, "Fallback User");
  }
}

Enable retries with @EnableRetry on your configuration class.

Key takeaways

  • Use RestClient for blocking HTTP calls in Spring Boot 3.2+. It's simpler, modern, and works well with virtual threads.
  • Always set explicit timeouts (connect and read). Infinite timeouts cause thread pool exhaustion and cascading failures.
  • Declare HTTP interfaces with @GetExchange, @PostExchange, etc. Let Spring generate your client implementations.
  • Use WebClient only in fully reactive stacks. Mixing it into imperative code (with block()) defeats the purpose and introduces bugs.
  • Handle errors explicitly: catch HttpClientErrorException and HttpServerErrorException, and implement retries with Spring Retry or Resilience4j for transient failures.
  • Test client behavior under failure conditions: timeouts, 5xx responses, and connection drops. Use WireMock or Testcontainers.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles