Timeouts Errors and Retries

Always Set Timeouts

A slow partner API without a timeout can block all your threads and take your whole site down. Configure connect and read timeouts on the request factory:

@Bean
RestClient paymentClient(RestClient.Builder builder, PaymentProperties props) {
    HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(2))
            .build();
    JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(http);
    factory.setReadTimeout(props.timeout());                     // e.g. 5s

    return builder.baseUrl(props.baseUrl())
            .requestFactory(factory)
            .defaultHeaders(h -> h.setBasicAuth(props.keyId(), props.keySecret()))
            .build();
}

Handling Error Responses

By default retrieve() throws HttpClientErrorException (4xx) or HttpServerErrorException (5xx). Use onStatus to translate them into your own exceptions:

PaymentResponse charge(PaymentRequest req) {
    return client.post()
            .uri("/v1/payments")
            .body(req)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                throw new PaymentFailedException("Payment rejected: " + response.getStatusCode());
            })
            .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                throw new PaymentGatewayDownException("Gateway error " + response.getStatusCode());
            })
            .body(PaymentResponse.class);
}

Retry with Backoff and Idempotency

Temporary failures (timeouts, 503) are worth retrying a few times, waiting longer each time. Retrying a payment is only safe with an idempotency key: the gateway sees the same key and never charges twice.

Example

The retry logic in plain Java, with a fake gateway that fails twice and then succeeds:

import java.util.*;

public class Main {
    static class FakeGateway {
        private int calls = 0;
        private final Set<String> processedKeys = new LinkedHashSet<>();

        String charge(String idempotencyKey, int amount) {
            calls++;
            if (calls <= 2) throw new IllegalStateException("503 Service Unavailable");
            if (!processedKeys.add(idempotencyKey)) return "already charged (same key), no double charge";
            return "charged Rs " + amount;
        }
    }

    static String chargeWithRetry(FakeGateway gw, String key, int amount, int maxAttempts) {
        long backoffMs = 200;
        for (int attempt = 1; ; attempt++) {
            try {
                String result = gw.charge(key, amount);
                System.out.println("attempt " + attempt + ": " + result);
                return result;
            } catch (IllegalStateException e) {
                if (attempt == maxAttempts) throw e;
                System.out.println("attempt " + attempt + ": " + e.getMessage() + ", retry in " + backoffMs + " ms");
                backoffMs *= 2;                        // exponential backoff (sleep skipped in this demo)
            }
        }
    }

    public static void main(String[] args) {
        FakeGateway gateway = new FakeGateway();
        chargeWithRetry(gateway, "order-SE-1001", 1249, 4);
        System.out.println("--- client times out and sends the same request again ---");
        chargeWithRetry(gateway, "order-SE-1001", 1249, 4);
    }
}

Output

attempt 1: 503 Service Unavailable, retry in 200 ms
attempt 2: 503 Service Unavailable, retry in 400 ms
attempt 3: charged Rs 1249
--- client times out and sends the same request again ---
attempt 1: already charged (same key), no double charge

Note: In real projects use a library for this: Spring Retry (@Retryable) or Resilience4j (retry + circuit breaker), covered in the Microservices material.