DEV Community

Thellu
Thellu

Posted on

Idempotency Keys in Spring Boot: Make POST Safe Against Retries

A user clicks “Pay”, the network drops, the client retries… and your backend creates two payments.

This isn’t a “frontend bug”. Retries happen everywhere:

  • mobile networks
  • API gateways with automatic retry
  • load balancers / timeouts
  • users smashing the refresh button

If your POST endpoint creates something with side effects (charge card, create order, send email), you want idempotency.

This post shows a pragmatic, production-friendly implementation of Idempotency-Key in Spring Boot.


What idempotency means

With an idempotency key, the client sends a header:

Idempotency-Key: 7f3b2c1c-4f4f-4c8d-9b7e-0f18d4d0b2aa
Enter fullscreen mode Exit fullscreen mode

Your API guarantees:

  • First request with that key executes normally
  • Subsequent requests with the same key return the same result
  • If another request with the same key is in progress, you respond predictably (409/202) instead of doing the work twice

The key idea: store the outcome of the first request and replay it.


Contract design

A minimal contract that works well:

  1. Clients must send Idempotency-Key for POST endpoints with side effects
  2. The key is unique per endpoint + per user (avoid collisions)
  3. You store successful outcomes for a short TTL (e.g. 24h)
  4. If a request is already in progress:
    • return 409 Conflict with a “try again” message, or
    • return 202 Accepted + Retry-After and let clients poll

This post uses 409 (simple and explicit).


Storage model

You need somewhere to store results. A simple table works:

CREATE TABLE idempotency_record (
  idempotency_key VARCHAR(200) PRIMARY KEY,
  status          VARCHAR(20)  NOT NULL,  -- IN_PROGRESS / COMPLETED
  response_status INT          NULL,
  content_type    VARCHAR(100) NULL,
  response_body   TEXT         NULL,
  created_at      TIMESTAMP    NOT NULL,
  updated_at      TIMESTAMP    NOT NULL,
  expires_at      TIMESTAMP    NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Two important constraints:

  • idempotency_key must be unique
  • expires_at lets you clean up old keys

Implementation approach

There are several ways to wire this into Spring. The cleanest (for most teams):

  • Mark endpoints with an annotation @Idempotent
  • Use a Spring AOP aspect (@Around) that: 1) checks existing record 2) locks/creates an “in progress” record 3) executes the controller method 4) stores the response if successful 5) returns either stored response or the new response

This avoids filters that must buffer request/response bodies and keeps business code readable.


Step 1) Annotation

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
  /** TTL in seconds for stored outcomes */
  long ttlSeconds() default 24 * 60 * 60; // 24h
}
Enter fullscreen mode Exit fullscreen mode

Step 2) Entity + Repository

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "idempotency_record")
public class IdempotencyRecord {

  @Id
  @Column(name = "idempotency_key", length = 200)
  private String idempotencyKey;

  @Column(nullable = false, length = 20)
  private String status; // IN_PROGRESS / COMPLETED

  @Column(name = "response_status")
  private Integer responseStatus;

  @Column(name = "content_type", length = 100)
  private String contentType;

  @Lob
  @Column(name = "response_body")
  private String responseBody;

  @Column(name = "created_at", nullable = false)
  private Instant createdAt;

  @Column(name = "updated_at", nullable = false)
  private Instant updatedAt;

  @Column(name = "expires_at", nullable = false)
  private Instant expiresAt;

  // getters/setters omitted for brevity

  public static IdempotencyRecord inProgress(String key, Instant now, Instant expiresAt) {
    IdempotencyRecord r = new IdempotencyRecord();
    r.idempotencyKey = key;
    r.status = "IN_PROGRESS";
    r.createdAt = now;
    r.updatedAt = now;
    r.expiresAt = expiresAt;
    return r;
  }

  public void complete(int status, String contentType, String body, Instant now) {
    this.status = "COMPLETED";
    this.responseStatus = status;
    this.contentType = contentType;
    this.responseBody = body;
    this.updatedAt = now;
  }
}
Enter fullscreen mode Exit fullscreen mode

Repository:

import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;

public interface IdempotencyRecordRepository extends JpaRepository<IdempotencyRecord, String> {
  long deleteByExpiresAtBefore(Instant now);
}
Enter fullscreen mode Exit fullscreen mode

Step 3) Service: acquire + complete + replay

This service is where you handle concurrency cleanly.

Key points:

  • Try to insert an IN_PROGRESS record first
  • If insert fails (duplicate key), read existing record
  • If existing record is COMPLETED, replay it
  • If existing record is IN_PROGRESS, return “in progress”
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;

@Service
public class IdempotencyService {

  public enum AcquireState { ACQUIRED, IN_PROGRESS, COMPLETED }

  public static class AcquireResult {
    private final AcquireState state;
    private final ResponseEntity<String> replay;

    private AcquireResult(AcquireState state, ResponseEntity<String> replay) {
      this.state = state;
      this.replay = replay;
    }

    public static AcquireResult acquired() { return new AcquireResult(AcquireState.ACQUIRED, null); }
    public static AcquireResult inProgress() { return new AcquireResult(AcquireState.IN_PROGRESS, null); }
    public static AcquireResult completed(ResponseEntity<String> replay) { return new AcquireResult(AcquireState.COMPLETED, replay); }

    public AcquireState state() { return state; }
    public ResponseEntity<String> replay() { return replay; }
  }

  private final IdempotencyRecordRepository repo;
  private final ObjectMapper objectMapper;

  public IdempotencyService(IdempotencyRecordRepository repo, ObjectMapper objectMapper) {
    this.repo = repo;
    this.objectMapper = objectMapper;
  }

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public AcquireResult acquire(String internalKey, long ttlSeconds) {
    Instant now = Instant.now();
    Instant expiresAt = now.plus(ttlSeconds, ChronoUnit.SECONDS);

    try {
      repo.saveAndFlush(IdempotencyRecord.inProgress(internalKey, now, expiresAt));
      return AcquireResult.acquired();
    } catch (DataIntegrityViolationException dup) {
      Optional<IdempotencyRecord> existing = repo.findById(internalKey);
      if (existing.isEmpty()) return AcquireResult.inProgress();

      IdempotencyRecord r = existing.get();
      if ("COMPLETED".equals(r.getStatus())) {
        return AcquireResult.completed(replay(r));
      }
      return AcquireResult.inProgress();
    }
  }

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void completeSuccess(String internalKey, ResponseEntity<?> response) {
    repo.findById(internalKey).ifPresent(r -> {
      if (!response.getStatusCode().is2xxSuccessful()) return;

      try {
        String bodyJson = response.getBody() == null
            ? ""
            : objectMapper.writeValueAsString(response.getBody());

        String contentType = response.getHeaders().getContentType() == null
            ? MediaType.APPLICATION_JSON_VALUE
            : response.getHeaders().getContentType().toString();

        r.complete(response.getStatusCodeValue(), contentType, bodyJson, Instant.now());
        repo.save(r);
      } catch (Exception ignore) {
        // If serialization fails, don’t cache a broken response.
      }
    });
  }

  private ResponseEntity<String> replay(IdempotencyRecord r) {
    MediaType ct = r.getContentType() == null
        ? MediaType.APPLICATION_JSON
        : MediaType.parseMediaType(r.getContentType());

    int status = (r.getResponseStatus() == null) ? 200 : r.getResponseStatus();
    String body = (r.getResponseBody() == null) ? "" : r.getResponseBody();

    return ResponseEntity.status(status).contentType(ct).body(body);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why REQUIRES_NEW?

If your controller method runs in a transaction (common in service-layer designs), you don’t want the idempotency record to be rolled back together with the business transaction. The “acquire” should be committed early so a retry can see it.


Step 4) The Aspect: wire it into controllers

This aspect:

  • reads Idempotency-Key from request header
  • builds an internal key to avoid collisions across endpoints/users
  • checks/replays/acquires/completes
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.*;

@Aspect
@Component
public class IdempotencyAspect {

  private final IdempotencyService service;

  public IdempotencyAspect(IdempotencyService service) {
    this.service = service;
  }

  @Around("@annotation(idempotent)")
  public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
    HttpServletRequest req = currentRequest();
    String key = req.getHeader("Idempotency-Key");

    if (key == null || key.isBlank()) {
      return ResponseEntity.badRequest()
          .body(new SimpleError("idempotency.key.required", "Missing Idempotency-Key"));
    }

    String internalKey = buildInternalKey(req, key);

    IdempotencyService.AcquireResult ar = service.acquire(internalKey, idempotent.ttlSeconds());

    if (ar.state() == IdempotencyService.AcquireState.COMPLETED) {
      return ar.replay();
    }
    if (ar.state() == IdempotencyService.AcquireState.IN_PROGRESS) {
      return ResponseEntity.status(409)
          .body(new SimpleError("idempotency.in_progress", "Request with the same key is in progress"));
    }

    Object result = pjp.proceed();

    // Store only if it's ResponseEntity (recommended controller style)
    if (result instanceof ResponseEntity<?> re) {
      service.completeSuccess(internalKey, re);
    }

    return result;
  }

  private static HttpServletRequest currentRequest() {
    RequestAttributes attrs = RequestContextHolder.currentRequestAttributes();
    return ((ServletRequestAttributes) attrs).getRequest();
  }

  private static String buildInternalKey(HttpServletRequest req, String clientKey) {
    // Minimum: method + path + client key.
    // Better: include authenticated user id if available.
    return req.getMethod() + ":" + req.getRequestURI() + ":" + clientKey.trim();
  }

  public record SimpleError(String error, String message) {}
}
Enter fullscreen mode Exit fullscreen mode

Step 5) Use it in a controller

import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

  @PostMapping
  @Idempotent(ttlSeconds = 24 * 60 * 60)
  public ResponseEntity<PaymentResponse> create(@RequestBody CreatePaymentRequest req) {
    // Imagine this is a real side effect (charge card, create payment record, etc.)
    String paymentId = "pay_" + System.currentTimeMillis();

    return ResponseEntity.ok(new PaymentResponse(paymentId, "CREATED"));
  }

  public record CreatePaymentRequest(@NotBlank String orderId) {}
  public record PaymentResponse(String paymentId, String status) {}
}
Enter fullscreen mode Exit fullscreen mode

Now retrying the same request with the same Idempotency-Key returns the same paymentId and status.


Step 6) Cleanup job

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.Instant;

@Component
public class IdempotencyCleanupJob {

  private final IdempotencyRecordRepository repo;

  public IdempotencyCleanupJob(IdempotencyRecordRepository repo) {
    this.repo = repo;
  }

  @Scheduled(cron = "0 0 * * * *") // hourly
  public void cleanup() {
    repo.deleteByExpiresAtBefore(Instant.now());
  }
}
Enter fullscreen mode Exit fullscreen mode

Tests (MockMvc): prove replay works

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class IdempotencyKeyTest {

  @Autowired MockMvc mvc;

  @Test
  void sameKey_replaysSameResponse() throws Exception {
    String body = "{\"orderId\":\"o_1\"}";

    String r1 = mvc.perform(post("/api/payments")
            .contentType(MediaType.APPLICATION_JSON)
            .header("Idempotency-Key", "abc-123")
            .content(body))
        .andExpect(status().isOk())
        .andReturn().getResponse().getContentAsString();

    String r2 = mvc.perform(post("/api/payments")
            .contentType(MediaType.APPLICATION_JSON)
            .header("Idempotency-Key", "abc-123")
            .content(body))
        .andExpect(status().isOk())
        .andReturn().getResponse().getContentAsString();

    org.junit.jupiter.api.Assertions.assertEquals(r1, r2);
  }
}
Enter fullscreen mode Exit fullscreen mode

Practical notes (what teams trip over)

  • What if the client reuses the same key with a different body?

    You can store a request_hash and reject mismatches (409). This is a great hardening step once the basic version works.

  • Should you cache 4xx errors?

    Depends. Many payment/order APIs only cache successful results.

    If you cache validation errors, clients get stable errors too—but be careful with sensitive details.

  • What if a request crashes after “acquire” but before “complete”?

    You’ll have an IN_PROGRESS record. Options:

    • return 409 until TTL expires (simple)
    • add “stale in-progress” detection (e.g., updated_at older than N minutes) and allow retry
  • Don’t use traceId as part of the idempotency key.


    Trace IDs change per request. Idempotency keys must be stable across retries.


Wrap-up

Idempotency keys are one of those “boring” backend features that prevent expensive incidents.

You don’t need a fancy distributed lock system. A unique key + stored response is enough for most APIs.

Top comments (0)