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 happensWhat RestClient does by defaultWhat you should do
4xx responsethrows HttpClientErrorException (e.g. .NotFound)Translate into your own error (400/404/409 for your caller)
5xx responsethrows HttpServerErrorExceptionReturn 503 or retry (Resilience4j, medium level)
Service downthrows ResourceAccessException (connection refused)Fail fast with a clear 503
Service very slowwaits for the read timeoutAlways 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.