Retry the bank call when it times out
Java · Spring Boot · advanced · greenfield
Payroll sends salary payouts through the bank's REST API, and the hourly job has been leaving payouts `PENDING` whenever bank responses exceed the read timeout, so someone has to chase them by hand. This adds a `BankPayoutClient` that retries a payout call that times out or cannot connect, up to three attempts with a short pause, and a `PayoutService.send` that pays one pending payout and records the bank's payout id. Error statuses are not retried. Paid a test payout against the bank's sandbox, and the retry took over after I paused the sandbox for a few seconds.
Payroll service on Spring Boot 3.3 (Java 21), Spring Data JPA, `RestClient`. Existing `Payout` entity (`UUID getId()`, `String getIban()`, validated at creation, `long getAmountCents()` between 1 and 100,000,000, `String getCurrency()`, an ISO code, `PayoutStatus getStatus()`, `markSent(String bankRef)`) and `PayoutRepository`. A successful bank response always carries a non-empty payout `id`. `PayoutService.send` is called by the existing `PayoutRun` job, which runs hourly on a single instance, calls `send` for each `PENDING` payout younger than 24 hours, one at a time, catches and logs every exception per payout, and flags older ones for manual review without calling `send`. `bank.base-url` and `bank.api-key` are configured; the bank accepts the `Authorization: Bearer` key shown. A connect or read timeout from the JDK HTTP client surfaces as `ResourceAccessException`.
Requirements
- `PayoutService.send(payoutId)` pays one pending payout through the bank's `POST /v1/payouts` and then marks it `SENT` with the bank's payout id. A payout that is already `SENT` is skipped. The HTTP call is not made inside a database transaction.
- A call that gets no answer within the 5 second read timeout, or cannot connect, is retried, up to 3 attempts in all with a short pause between them. Any other failure (an HTTP error status, a bad body) is not retried and reaches the caller, which leaves the payout `PENDING` for the next hourly run.
- The bank API creates one payout per `POST /v1/payouts` unless the request carries an `Idempotency-Key` header (any string up to 64 characters). A request with a key the bank has seen in the last 24 hours returns the first response unchanged and creates nothing; a key whose first request the bank rejected with an error is not remembered. The body's `reference` is free text printed on the beneficiary's statement and is not used to deduplicate. The bank may complete a payout and still answer too late for our timeout.
- Each payout must reach the beneficiary exactly once, however many times `send` or the retry loop calls the bank for it within a day.
Files touched
- src/main/java/com/acme/payroll/payout/BankPayoutClient.java
- src/main/java/com/acme/payroll/payout/PayoutService.java
--- src/main/java/com/acme/payroll/payout/BankPayoutClient.java
+package com.acme.payroll.payout;
+
+import java.net.http.HttpClient;
+import java.time.Duration;
+import java.util.UUID;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.ResourceAccessException;
+import org.springframework.web.client.RestClient;
+
+@Component
+public class BankPayoutClient {
+
+ private static final int MAX_ATTEMPTS = 3;
+
+ private final RestClient http;
+
+ public BankPayoutClient(
+ RestClient.Builder builder,
+ @Value("${bank.base-url}") String baseUrl,
+ @Value("${bank.api-key}") String apiKey,
+ @Value("${bank.read-timeout-ms:5000}") long readTimeoutMs) {
+ JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(
+ HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build());
+ factory.setReadTimeout(Duration.ofMillis(readTimeoutMs));
+ this.http = builder