Im dritten Teil unserer Serie ging’s um If-Match und wie du damit Lost Updates bei PUT und PATCH verhinderst – zwei Clients bearbeiten dieselbe Ressource, der Server erkennt die Kollision anhand des ETags und lehnt den veralteten Schreibzugriff ab. Eine Frage ist dabei offen geblieben: Was ist mit POST? Beim Anlegen einer neuen Ressource gibt es noch kein ETag, an das du eine Precondition knüpfen könntest – die Ressource existiert ja noch gar nicht. Genau um diese Lücke geht’s heute.
Kennst du das – ein Checkout-Formular, der User klickt auf “Bestellen”, das Handynetz hakt kurz, die Antwort kommt nicht rechtzeitig an. Was macht der User? Er klickt nochmal. Was macht ein gut gemeinter Retry-Mechanismus im Frontend oder ein Load Balancer, der einen Timeout für einen Serverfehler hält? Er schickt den Request automatisch erneut. In beiden Fällen kommt derselbe POST-Request zweimal beim Server an – und wenn der naiv jede Anfrage als neue Bestellung behandelt, hat dein Kunde plötzlich zwei Lieferungen und zwei Abbuchungen, obwohl er nur einmal bestellen wollte.
Das ist kein Sonderfall, sondern eine direkte Konsequenz davon, wie HTTP POST definiert: nicht idempotent. Jeder Aufruf ist aus Sicht der Spezifikation eine eigenständige neue Operation. Anders als bei Lost Updates gibt’s dafür keinen offiziellen HTTP-Standard – aber eine De-facto-Konvention, die sich in praktisch jeder ernstzunehmenden Payment- und Order-API wiederfindet: Idempotency Keys. In diesem letzten Teil der Serie schauen wir uns an, warum POST dieses Problem hat, wie Idempotency Keys es lösen, und wie du sie in Spring Boot sauber umsetzt – inklusive des Race-Window-Problems, das wir aus Teil 3 schon kennen.
Das Problem: Non-Idempotenz von POST
Die HTTP-Spezifikation (RFC 9110) teilt Methoden in idempotente und nicht-idempotente ein. Idempotent heißt: Führst du denselben Request mehrfach aus, ist das Ergebnis auf dem Server dasselbe wie bei einmaliger Ausführung. GET ist idempotent – du liest einfach mehrfach denselben Zustand. PUT ist idempotent – setzt du eine Ressource zweimal auf denselben Wert, ändert sich beim zweiten Mal nichts mehr. Sogar DELETE ist idempotent – die Ressource ist nach dem ersten Aufruf weg, jeder weitere Aufruf ändert daran nichts (auch wenn der Statuscode sich unterscheiden kann, 404 statt 200).
POST ist explizit die Ausnahme. Die Spezifikation sagt bewusst nichts über Idempotenz, weil POST typischerweise etwas erzeugt – und “erzeuge dieselbe Sache nochmal” ist per Definition eine neue Sache, keine Wiederholung der alten. Das ist an sich kein Fehler im Design von HTTP, sondern korrektes Verhalten für den Regelfall. Das Problem entsteht erst, wenn Netzwerke unzuverlässig sind – und das sind sie ständig.
Ein ganz gewöhnlicher Bestell-Endpunkt, wie ihn vermutlich jeder Checkout-Flow braucht:
// Anti-Pattern: Jeder POST erzeugt garantiert eine neue Bestellung – auch Retries
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest orderRequest) {
Order order = orderService.create(orderRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(order);
}
Funktional korrekt für den Fall, dass jeder Request genau einmal ankommt. Aber genau diese Annahme hält in der Praxis nicht:
- Client schickt
POST /ordersmit dem Warenkorb. - Server verarbeitet die Bestellung erfolgreich, schreibt sie in die Datenbank, stößt die Zahlung an.
- Die Antwort geht auf dem Rückweg verloren – Verbindungsabbruch, Timeout, Proxy-Fehler. Der Client sieht keinen Erfolg, nur einen Fehler oder gar nichts.
- Der Client weiß nicht, ob der Request beim Server angekommen ist oder nicht – aus seiner Sicht ist beides plausibel. Also retryt er, automatisch oder weil der User nochmal klickt.
- Der Server bekommt einen zweiten, inhaltlich identischen
POST /orders– und weil er keinerlei Information hat, dass das derselbe logische Vorgang ist, legt er eine zweite Bestellung an.
Das Tückische: Der Server macht dabei nichts falsch, gemessen an dem, was er weiß. Er hat schlicht keine Möglichkeit zu erkennen, dass Request 2 eine Wiederholung von Request 1 ist und keine neue, eigenständige Bestellung. Diese Information – “das ist derselbe Vorgang wie vorhin” – muss der Client explizit mitliefern. Genau das leistet der Idempotency Key.
Die Lösung: Der Idempotency-Key-Header
Das Prinzip ist im Kern simpel: Der Client erzeugt für jeden logischen Vorgang – nicht für jeden HTTP-Request, sondern für jeden fachlichen Versuch, “diese eine Bestellung aufzugeben” – einen eindeutigen Schlüssel, typischerweise eine UUID, und schickt ihn als Header mit:
POST /orders HTTP/1.1
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json
{ "items": [...], "total": 49.90 }
Der Server merkt sich diesen Key zusammen mit dem Ergebnis der Operation. Kommt derselbe Key ein zweites Mal an, führt der Server die Operation nicht erneut aus, sondern liefert einfach das gespeicherte Ergebnis von damals zurück – identischer Statuscode, identischer Response-Body, als wäre nichts passiert. Aus Sicht des Clients ist der Retry damit sicher: Egal ob der ursprüngliche Request erfolgreich war und nur die Antwort verloren ging, oder ob er den Server nie erreicht hat – der Client bekommt am Ende genau eine Bestellung, nie zwei.
Wichtig für die Einordnung: Anders als If-Match aus Teil 3 ist der Idempotency-Key-Header kein etablierter HTTP-Standard mit fester RFC-Nummer, den jeder Server automatisch versteht. Es gibt zwar einen IETF-Entwurf dazu (draft-ietf-httpapi-idempotency-key-header), aber verbreitet ist das Muster vor allem als De-facto-Konvention aus der Payment-Welt – Stripe hat den Header maßgeblich populär gemacht, und mittlerweile setzen ihn die meisten APIs ein, bei denen doppelte Schreibvorgänge echtes Geld kosten würden. Du implementierst den Mechanismus also selbst, statt ihn wie If-Match “geschenkt” von Framework und Spec zu bekommen.
Der Ablauf im Detail, wenn der Key zum zweiten Mal ankommt:
- Server prüft: Existiert dieser Idempotency Key bereits?
- Falls ja, und die ursprüngliche Operation ist abgeschlossen: Server liefert das gespeicherte Ergebnis zurück, ohne
orderService.create()erneut aufzurufen. - Falls ja, aber die ursprüngliche Operation läuft gerade noch (paralleler Retry, während der erste Request noch verarbeitet wird): Server antwortet mit 409 Conflict – “hier läuft schon was mit diesem Key, warte oder versuch’s gleich nochmal”.
- Falls nein: Server führt die Operation ganz normal aus und speichert Key + Ergebnis für zukünftige Wiederholungen.
Der entscheidende Unterschied zu If-Match: Dort hat der Client eine Bedingung an den Zustand der Ressource geknüpft (“nur wenn ETag X”). Beim Idempotency Key knüpft der Client eine Bedingung an den Vorgang selbst (“das hier ist derselbe Versuch wie vorhin”). Beide Male macht der Client implizites Wissen explizit, das der Server sonst nicht hätte – nur an unterschiedlichen Stellen im Request-Lifecycle.
Umsetzung in Spring Boot
Anders als bei If-Match gibt es hier keinen eingebauten Mechanismus wie WebRequest.checkNotModified() – du baust dir die Idempotenz-Prüfung selbst, mit einer Tabelle, die sich für jeden Key merkt, was beim ersten Mal passiert ist.
Die Tabelle: Key, Status, Ergebnis
@Entity
@Table(name = "idempotency_keys")
public class IdempotencyRecord {
@Id
private String idempotencyKey;
private String requestHash;
@Enumerated(EnumType.STRING)
private IdempotencyStatus status;
private Integer responseStatus;
@Lob
private String responseBody;
private Instant createdAt = Instant.now();
protected IdempotencyRecord() {
// für JPA
}
public IdempotencyRecord(String idempotencyKey, String requestHash, IdempotencyStatus status) {
this.idempotencyKey = idempotencyKey;
this.requestHash = requestHash;
this.status = status;
}
// Getter, Setter
}
public enum IdempotencyStatus {
IN_PROGRESS,
COMPLETED
}
requestHash ist wichtig und wird oft vergessen: Er stellt sicher, dass ein Key wirklich für denselben Request wiederverwendet wird. Schickt ein Client denselben Idempotency Key mit einem anderen Payload – aus welchem Grund auch immer, Bug im Client oder Copy-Paste-Fehler – willst du das nicht stillschweigend mit dem alten Ergebnis beantworten, sondern klar ablehnen.
Der Service: Prüfen, ausführen, merken
@Service
public class IdempotencyService {
private final IdempotencyRecordRepository repository;
private final ObjectMapper objectMapper;
public IdempotencyService(IdempotencyRecordRepository repository, ObjectMapper objectMapper) {
this.repository = repository;
this.objectMapper = objectMapper;
}
@Transactional
public <T> ResponseEntity<?> executeIdempotent(
String idempotencyKey,
Object requestBody,
Supplier<ResponseEntity<T>> operation) {
String requestHash = hash(requestBody);
Optional<IdempotencyRecord> existing = repository.findById(idempotencyKey);
if (existing.isPresent()) {
IdempotencyRecord record = existing.get();
if (!record.getRequestHash().equals(requestHash)) {
// Gleicher Key, anderer Payload -> kein sicherer Retry, klare Ablehnung
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
if (record.getStatus() == IdempotencyStatus.IN_PROGRESS) {
// Der erste Request läuft noch, dieser hier ist ein paralleler Retry
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
// Abgeschlossen -> gespeichertes Ergebnis zurückgeben, nichts erneut ausführen
return replayResponse(record);
}
// Neuer Key: Platzhalter anlegen, BEVOR die eigentliche Operation läuft
IdempotencyRecord record = new IdempotencyRecord(idempotencyKey, requestHash, IdempotencyStatus.IN_PROGRESS);
try {
repository.saveAndFlush(record);
} catch (DataIntegrityViolationException e) {
// Zwei Requests mit demselben neuen Key kamen praktisch gleichzeitig an
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
ResponseEntity<T> response = operation.get();
record.setStatus(IdempotencyStatus.COMPLETED);
record.setResponseStatus(response.getStatusCode().value());
record.setResponseBody(serialize(response.getBody()));
repository.save(record);
return response;
}
private String hash(Object requestBody) {
try {
byte[] json = objectMapper.writeValueAsBytes(requestBody);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(json));
} catch (JsonProcessingException | NoSuchAlgorithmException e) {
throw new IllegalStateException("Request konnte nicht gehasht werden", e);
}
}
private String serialize(Object body) {
try {
return objectMapper.writeValueAsString(body);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Response konnte nicht serialisiert werden", e);
}
}
private ResponseEntity<?> replayResponse(IdempotencyRecord record) {
try {
Object body = objectMapper.readValue(record.getResponseBody(), Object.class);
return ResponseEntity.status(record.getResponseStatus()).body(body);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Response konnte nicht wiederhergestellt werden", e);
}
}
}
public interface IdempotencyRecordRepository extends JpaRepository<IdempotencyRecord, String> {
}
Der Controller
@PostMapping("/orders")
public ResponseEntity<?> createOrder(
@RequestBody OrderRequest orderRequest,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
return idempotencyService.executeIdempotent(
idempotencyKey,
orderRequest,
() -> ResponseEntity.status(HttpStatus.CREATED).body(orderService.create(orderRequest))
);
}
Das Race-Window – und wie die Datenbank es schließt
Zwischen “Key existiert noch nicht” und “Platzhalter ist gespeichert” liegt auch hier ein kleines Zeitfenster, genau wie beim If-Match-Check aus Teil 3 zwischen Prüfung und Schreiben. Kommen zwei Requests mit demselben neuen Key wirklich gleichzeitig an, könnten theoretisch beide repository.findById() mit “nicht vorhanden” beantwortet bekommen und beide versuchen, einen Platzhalter anzulegen.
Genau deshalb ist idempotencyKey in der Entity oben @Id – der Primary Key erzwingt auf Datenbankebene Eindeutigkeit. Der zweite saveAndFlush()-Aufruf scheitert unweigerlich mit einer DataIntegrityViolationException, weil die Datenbank die Zeile mit diesem Schlüssel nicht zweimal zulässt. Der Service fängt das ab und antwortet mit 409 Conflict. Dasselbe Prinzip wie @Version in Teil 3: Der Applikations-Check reduziert das Risiko, die Datenbank-Constraint schließt die letzte Lücke – Defense in Depth, nicht Vertrauen in eine einzelne Ebene.
Pro-Tipps / Warnungen
Tipp: Skaliere den
422-Fall aus dem Service-Code sauber in eine aussagekräftige Antwort statt eines leeren Bodys. Ein Client, der plötzlich ein422 Unprocessable Entityauf einen scheinbar normalenPOSTbekommt, braucht einen Hinweis, was los ist:if (!record.getRequestHash().equals(requestHash)) { return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) .body(Map.of( "error", "idempotency_key_reused", "message", "Dieser Idempotency-Key wurde bereits mit einem anderen Request-Body verwendet." )); }Das ist meistens ein Bug im Client – ein Retry-Mechanismus, der den Key cacht, aber vergisst, dass sich der Payload zwischendurch geändert hat (z. B. weil der Warenkorb nach einem Fehler nochmal befüllt wurde). Eine klare Fehlermeldung macht das beim Debuggen sofort sichtbar, statt dass jemand stundenlang rätselt, warum “derselbe” Request plötzlich anders behandelt wird.
Warnung: Speichere Idempotency-Keys nicht für immer. Ohne Aufräumen wächst die Tabelle unbegrenzt – und je länger ein Key gültig bleibt, desto größer das Risiko, dass ein Client ihn Wochen später versehentlich für einen komplett neuen, aber zufällig hash-gleichen Request wiederverwendet. Üblich ist eine TTL von 24 bis 72 Stunden, je nachdem wie lange dein System realistischerweise mit Retries rechnen muss:
@Scheduled(cron = "0 0 3 * * *") // täglich um 3 Uhr public void cleanupExpiredKeys() { Instant cutoff = Instant.now().minus(Duration.ofHours(72)); idempotencyRecordRepository.deleteByCreatedAtBefore(cutoff); }Die genaue Dauer richtet sich danach, wie lange dein Client realistischerweise retryt – ein mobiler Client mit Offline-Queue braucht länger als ein Server-zu-Server-Call mit sofortigem Timeout.
Warnung: Verwechsle einen Idempotency Key nicht mit echter Idempotenz der Methode.
PUTist idempotent, weil das Ergebnis bei mehrfacher Ausführung mit denselben Daten gleich bleibt – das gilt unabhängig davon, ob der Client irgendeinen Header mitschickt. Ein Idempotency Key machtPOSTnicht idempotent im eigentlichen Sinn, er macht wiederholte identische Requests sicher – ein feiner, aber wichtiger Unterschied. Ohne den Header, oder mit einem neuen Key pro Versuch, verhält sich derselbe Endpunkt weiterhin ganz normal nicht-idempotent und legt bei jedem Aufruf eine neue Ressource an. Die Sicherheit gilt nur, solange der Client diszipliniert denselben Key für denselben logischen Vorgang verwendet – verlässt du dich darauf, dass er das tut, verlagerst du einen Teil der Korrektheit in den Client. Bei kritischen Flows (Zahlungen) lohnt es sich deshalb, den Header mit@RequestHeader(required = true)verpflichtend zu machen, statt ihn optional zu lassen.
Fazit
Idempotency Keys lösen ein Problem, das POST von Natur aus hat: Der Client kann nicht sicher wissen, ob sein Request angekommen ist, und die einzig sichere Reaktion – “einfach nochmal schicken” – wird ohne zusätzliche Information zur Gefahr für die Datenintegrität. Der Header macht explizit, was sonst implizit verloren geht: dass zwei Requests derselbe Versuch sind, nicht zwei unabhängige Vorgänge. Serverseitig reicht dafür eine simple Tabelle mit Unique Constraint – kein exotischer Mechanismus, aber einer, den kaum ein CRUD-Endpunkt von sich aus mitbringt.
Damit schließt sich der Bogen dieser Serie. In Teil 1 ging’s um QUERY als saubere Alternative für komplexe, aber sichere Leseoperationen. Teil 2 hat gezeigt, wie Caching-Header unnötige Arbeit verhindern, ohne stale Daten auszuliefern. Teil 3 hat mit If-Match Lost Updates beim Schreiben gestoppt. Und heute haben Idempotency Keys die letzte Lücke geschlossen: doppelte Schreibvorgänge durch Netzwerk-Retries bei POST.
Was diese vier Bausteine verbindet: Keiner davon ist kompliziert für sich genommen. ETag, If-Match, ein Hash im Header – das sind alles kleine, unscheinbare Mechanismen. Der eigentliche Unterschied zwischen einer API, die “meistens funktioniert”, und einer, die auch unter Last, bei Netzwerkfehlern und bei parallelen Zugriffen korrekt bleibt, steckt selten in großen Architekturentscheidungen. Er steckt in genau diesen Details, die man beim ersten Wurf eines Endpunkts leicht übersieht – und die sich rächen, sobald echte Nutzer und echtes Netzwerkchaos draufstoßen.
