Errors and Timeouts Between Services
The Network Will Fail
A local method call almost never fails. A call to another service can: the service may be down, slow, return 404/409/500, or the network may drop. Your client must handle each case on purpose.
| What happens | What RestClient does by default | What you should do |
|---|---|---|
| 4xx response | throws HttpClientErrorException (e.g. .NotFound) | Translate into your own error (400/404/409 for your caller) |
| 5xx response | throws HttpServerErrorException | Return 503 or retry (Resilience4j, medium level) |
| Service down | throws ResourceAccessException (connection refused) | Fail fast with a clear 503 |
| Service very slow | waits for the read timeout | Always set timeouts |
Handling Error Status Codes
public ProductResponse reserve(Long id, int quantity) {
return restClient.post()
.uri("/api/products/{id}/reserve", id)
.body(new StockRequest(quantity))
.retrieve()
.onStatus(status -> status.value() == 404, (request, response) -> {
throw new ProductNotFoundException(id);
})
.onStatus(status -> status.value() == 409, (request, response) -> {
throw new OutOfStockException(id, quantity);
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new ProductServiceUnavailableException();
})
.body(ProductResponse.class);
}
// in order-service: turn those into proper HTTP answers
@RestControllerAdvice
class OrderErrors {
@ExceptionHandler(OutOfStockException.class)
ProblemDetail outOfStock(OutOfStockException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
@ExceptionHandler({ProductServiceUnavailableException.class, ResourceAccessException.class})
ProblemDetail down(Exception e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
"Product service is not available, please try again");
}
}Setting Timeouts
package com.shopease.order.config;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
@Configuration
public class HttpClientConfig {
@Bean
RestClientCustomizer timeouts(ServiceUrls urls) {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(urls.timeout().connect()); // 2s: give up if the host does not answer
factory.setReadTimeout(urls.timeout().read()); // 3s: give up if the response is too slow
return builder -> builder.requestFactory(factory);
}
}Tip: From Spring Boot 3.4 you can also set global client timeouts in YAML with spring.http.client.connect-timeout and spring.http.client.read-timeout.
Example
With product-service stopped:
curl -i -X POST localhost:8082/api/orders -H "Content-Type: application/json" \
-d '{"userId":7,"items":[{"productId":1,"quantity":2}]}'Example output
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
{"type":"about:blank","title":"Service Unavailable","status":503,
"detail":"Product service is not available, please try again","instance":"/api/orders"}Example: Why Timeouts Matter
Example
A slow dependency without a timeout blocks your thread for as long as it likes. With a timeout, the caller gives up and answers quickly. Here the "product service" takes 1.5 s; the client waits at most 200 ms:
import java.util.concurrent.*;
public class Main {
static String slowProductService() {
try { Thread.sleep(1500); } catch (InterruptedException e) { return "interrupted"; }
return "{\"id\":1,\"price\":1499.00}";
}
static String fastProductService() {
return "{\"id\":1,\"price\":1499.00}";
}
static String callWithTimeout(Callable<String> remote, long timeoutMs) {
ExecutorService pool = Executors.newSingleThreadExecutor();
try {
Future<String> f = pool.submit(remote);
return "200 OK " + f.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
return "503 Service Unavailable (product-service did not answer in " + timeoutMs + " ms)";
} catch (Exception e) {
return "500 " + e.getMessage();
} finally {
pool.shutdownNow();
}
}
public static void main(String[] args) {
System.out.println("Fast dependency: " + callWithTimeout(Main::fastProductService, 200));
System.out.println("Slow dependency: " + callWithTimeout(Main::slowProductService, 200));
}
}Output
Fast dependency: 200 OK {"id":1,"price":1499.00}
Slow dependency: 503 Service Unavailable (product-service did not answer in 200 ms)Note: Without timeouts, a slow product-service makes order-service threads pile up until order-service also stops answering: one slow service takes down another. This is called a cascading failure. Circuit breakers (medium level) build on top of timeouts.