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.