🪟 Windows TippsModify Windows Support Phone Number with PowerShell(03.09.2026 um 00:00 Uhr)
🔧 AI Nachrichten Podcast: ChatGPT schwatzt Nutzern in Deutschland jetzt Werbung auf(28.08.2026 um 08:46 Uhr)
🪟 Windows TippsMicrosoft bringt Emoji 17.0 auf Windows 11(31.08.2026 um 08:16 Uhr)
🪟 Windows TippsModify Windows Support Phone Number with PowerShell(03.09.2026 um 00:00 Uhr)
🔧 AI Nachrichten Podcast: ChatGPT schwatzt Nutzern in Deutschland jetzt Werbung auf(28.08.2026 um 08:46 Uhr)
🪟 Windows TippsMicrosoft bringt Emoji 17.0 auf Windows 11(31.08.2026 um 08:16 Uhr)

🔧 Programmierung 🕛 vor 3 Monaten 9 Min Lesezeit
0

Idempotency Keys in Spring Boot: Make POST Safe Against Retries

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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:




CODE
Idempotency-Key: 7f3b2c1c-4f4f-4c8d-9b7e-0f18d4d0b2aa






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:




CODE
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
);






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






CODE
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
}












Step 2) Entity + Repository






CODE
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;
}
}






Repository:




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

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












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”




CODE
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);
}
}






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




CODE
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) {}
}












Step 5) Use it in a controller






CODE
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) {}
}






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









Step 6) Cleanup job






CODE
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());
}
}












Tests (MockMvc): prove replay works






CODE
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);
}
}












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.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Modify Windows Support Phone Number with PowerShell
1 Quelle
Die Zukunft des Einkaufens: Warum wir ein neues Kapitel aufschlagen (und wie du es mitschreiben kannst)
1 Quelle
ZDE Podcast 251: Wie sieht digitales Instore Marketing 2026 aus, Amit Chatterjee?
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Idempotency Keys in Spring Boot: Make POST Safe Against Retries

Thematisch verwandte Begriffe: Idempotency, Keys, Spring, Boot · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...