Spring Boot cookbook · Advanced

Make POST endpoints idempotent in Spring Boot

Prevent duplicate orders or charges when clients retry a request after a timeout.

Approach

Store each Idempotency-Key per client in a table with a unique constraint, together with a hash of the request and the eventual response. Claim the key in its own transaction first; duplicates read the stored response (or get 409 while processing, 422 if the body differs).

Solution

@Service
class IdempotencyService {
    private final IdempotencyRepository repo;
    private final TransactionTemplate tx;
    IdempotencyService(IdempotencyRepository repo, PlatformTransactionManager tm) {
        this.repo = repo; this.tx = new TransactionTemplate(tm);
    }

    <T> ResponseEntity<T> execute(String clientId, String key, String requestHash, Class<T> type, Supplier<ResponseEntity<T>> work) {
        try {
            tx.executeWithoutResult(s -> repo.saveAndFlush(IdempotencyRecord.processing(clientId, key, requestHash)));
        } catch (DataIntegrityViolationException duplicate) {
            IdempotencyRecord existing = repo.findByClientIdAndKey(clientId, key).orElseThrow();
            if (!existing.requestHash().equals(requestHash)) throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
            if (existing.isProcessing()) throw new ResponseStatusException(HttpStatus.CONFLICT, "Request in progress");
            return ResponseEntity.status(existing.status()).body(existing.bodyAs(type));
        }
        ResponseEntity<T> response = work.get();
        tx.executeWithoutResult(s -> repo.complete(clientId, key, response.getStatusCode().value(), response.getBody()));
        return response;
    }
}

@PostMapping("/api/v1/orders")
ResponseEntity<OrderResponse> create(@RequestHeader("Idempotency-Key") @Size(min = 8, max = 100) String key,
                                     @Valid @RequestBody CreateOrderRequest body, Authentication auth) {
    return idempotency.execute(auth.getName(), key, hash(body), OrderResponse.class,
            () -> ResponseEntity.status(HttpStatus.CREATED).body(orders.create(auth.getName(), body)));
}

Pitfalls

  • Claiming the key inside the same transaction as the work (a rollback loses the claim and duplicates race).
  • No expiry/cleanup of old keys.
  • Not forwarding the key to payment providers.

Variations

  • Redis SET NX for the claim with a TTL, database for the stored response.

Further reading: Stripe: Idempotent requests

Reference: https://docs.stripe.com/api/idempotent_requests

More Spring Boot recipes