Skip to content
~/sph.sh

Idempotenz: Einsteigerleitfaden für sichere Retries in APIs

Eine praktische Einführung in Idempotenz für Entwickler, die APIs, Payment-Flows und Message-Consumer bauen. Deckt HTTP-Methoden-Semantik, Idempotency-Keys, Datenbank-Upserts und häufige Fallstricke mit lauffähigen Node.js-Beispielen ab.

Netzwerke fallen aus. Requests laufen in Timeouts. Clients wiederholen Anfragen. Wenn deine API nicht unterscheiden kann, ob es sich um einen neuen Request oder den Retry eines vorherigen handelt, werden Kunden doppelt belastet, Bestellungen dupliziert und dein Support-Postfach läuft über.

Idempotenz ist die Eigenschaft, die Retries sicher macht. Dieser Leitfaden erklärt, was sie bedeutet, warum sie wichtig ist und wie du sie in echtem Code anwendest.

Was Idempotenz bedeutet

Eine Operation ist idempotent, wenn sie beim mehrfachen Ausführen dasselbe Ergebnis liefert wie beim einmaligen. Die mathematische Definition lautet f(f(x)) = f(x). In der Praxis: Wenn du denselben Endpoint zweimal mit derselben Eingabe aufrufst, befindet sich das System im selben Zustand wie nach einem einzigen Aufruf.

Achte auf die Feinheit. Idempotenz bedeutet nicht, doppelte Requests zu blockieren. Es bedeutet sicherzustellen, dass Duplikate keine doppelten Effekte verursachen. Das Netzwerk wird Retries machen, ob du willst oder nicht. Deine Aufgabe ist es, das zu tolerieren.

Drei verwandte Begriffe

  • Safe: überhaupt keine Seiteneffekte (ein GET-Request)
  • Idempotent: Seiteneffekte passieren, aber Wiederholungen fügen nichts Neues hinzu (PUT, DELETE)
  • Pure: deterministisch, keine Seiteneffekte, kein externer State (mathematische Funktionen)

Jede sichere Operation ist idempotent. Nicht jede idempotente Operation ist sicher.

Warum es wichtig ist: Das Doppelt-Belastet-Problem

Betrachte einen Checkout-Flow:

Die erste Belastung hat auf dem Server geklappt, aber die Antwort kam nie beim Client an. Der Client hat es noch einmal versucht und jetzt wurde der Kunde zweimal belastet. Der Server hatte keine Möglichkeit zu erkennen, dass der zweite Request ein Retry des ersten war.

Die Lösung ist ein Idempotency-Key: eine eindeutige ID, die der Client einmal generiert und bei allen Retries derselben logischen Operation wiederverwendet.

HTTP-Methoden und Idempotenz

RFC 9110 definiert den Vertrag für Standard-HTTP-Methoden:

MethodeSafeIdempotentTypische Nutzung
GETJaJaDaten lesen
PUTNeinJaVollständige Ersetzung
DELETENeinJaRessource entfernen
POSTNeinNeinNeue Ressource anlegen
PATCHNeinKommt drauf anTeilweise Aktualisierung

Ein PUT /users/123 mit demselben Body dreimal aufgerufen lässt den User-Record im selben Zustand. Ein zweimaliges DELETE /orders/456 führt dazu, dass die Bestellung weg ist. Aber POST /orders erzeugt jedes Mal eine neue Bestellung und ist daher standardmäßig nicht idempotent.

Das ist wichtig, weil Browser, Proxies und HTTP-Clients GET-, PUT- und DELETE-Requests automatisch wiederholen können, weil sie sie als sicher annehmen. Wenn du POST dort verwendest, wo PUT passen würde, verlierst du diese Garantie.

Das Idempotency-Key-Pattern

Das ist der Standardweg, POST-Operationen idempotent zu machen, populär gemacht von Stripe.

So funktioniert es

  1. Der Client erzeugt vor dem Senden des Requests eine UUID.
  2. Der Client schickt sie in einem Idempotency-Key-Header mit.
  3. Der Server prüft einen schnellen Store (Redis, DynamoDB) auf diesen Key.
  4. Ist der Key neu, verarbeitet der Server den Request und speichert die vollständige Antwort mit TTL.
  5. Existiert der Key bereits, gibt der Server die gespeicherte Antwort zurück, ohne die Business-Logik erneut auszuführen.

Eine minimale Express-Implementierung

typescript
import express, { Request, Response, NextFunction } from "express";import { createClient } from "redis";import { randomUUID } from "crypto";
const redis = createClient();await redis.connect();
interface StoredResponse {  status: number;  body: unknown;}
async function idempotency(req: Request, res: Response, next: NextFunction) {  const key = req.header("Idempotency-Key");  if (!key) return next();
  // Scope nach Benutzer und Endpoint, um Tenant-Kollisionen zu vermeiden  const storeKey = `idem:${req.user?.id}:${req.path}:${key}`;
  const cached = await redis.get(storeKey);  if (cached) {    const stored: StoredResponse = JSON.parse(cached);    return res.status(stored.status).json(stored.body);  }
  // Key 30 Sekunden sperren, um gleichzeitige Retries zu behandeln  const locked = await redis.set(storeKey + ":lock", "1", {    NX: true,    EX: 30,  });  if (!locked) {    return res.status(409).json({ error: "Request in progress" });  }
  // Antwort abfangen, damit wir sie speichern koennen  const originalJson = res.json.bind(res);  res.json = (body: unknown) => {    const toStore: StoredResponse = { status: res.statusCode, body };    // 24-Stunden-TTL, wie Stripes Standardwert    redis.set(storeKey, JSON.stringify(toStore), { EX: 86400 });    return originalJson(body);  };
  next();}

Diese Middleware deckt die wesentlichen Punkte ab: Scoping nach Benutzer, Sperre für Nebenläufigkeit, Speichern der vollständigen Antwort und Replay beim Retry. Produktionssysteme fügen in der Regel mehr hinzu: Unterscheidung zwischen laufend und abgeschlossen, Validierung, dass der Request-Body zum gespeicherten Key passt, und bessere Fehlerbehandlung.

Client-Seite

typescript
import { randomUUID } from "crypto";
async function chargeCustomer(amount: number) {  const idempotencyKey = randomUUID();
  // Denselben Key fuer alle Retries dieser logischen Operation verwenden  for (let attempt = 0; attempt < 3; attempt++) {    try {      const res = await fetch("/api/charge", {        method: "POST",        headers: {          "Content-Type": "application/json",          "Idempotency-Key": idempotencyKey,        },        body: JSON.stringify({ amount }),      });      if (res.ok) return res.json();    } catch (err) {      // Netzwerkfehler, mit demselben Key wiederholen      await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));    }  }  throw new Error("Charge failed after retries");}

Der wichtige Punkt: Der Client erzeugt den Key einmal vor dem ersten Versuch und nutzt ihn bei jedem Retry wieder. Eine neue UUID pro Versuch würde das gesamte Pattern aushebeln.

Idempotenz auf Datenbankebene

Manchmal kann die Datenbank die Arbeit für dich übernehmen. Unique-Constraints und Conditional Writes geben dir Idempotenz fast ohne Anwendungscode.

PostgreSQL Upsert

sql
INSERT INTO orders (id, user_id, amount, created_at)VALUES ($1, $2, $3, NOW())ON CONFLICT (id) DO NOTHINGRETURNING *;

Wenn der Aufrufer die Bestell-ID liefert, lässt ein zweimaliges Ausführen dieselbe Zeile in der Tabelle zurück. Der zweite Aufruf gibt wegen DO NOTHING nichts zurück, was dein Handler als erfolgreichen No-Op behandeln kann.

DynamoDB Conditional Write

typescript
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({});
async function createOrder(orderId: string, data: Record<string, string>) {  try {    await client.send(      new PutItemCommand({        TableName: "Orders",        Item: {          id: { S: orderId },          data: { S: JSON.stringify(data) },        },        ConditionExpression: "attribute_not_exists(id)",      }),    );    return { created: true };  } catch (err: any) {    if (err.name === "ConditionalCheckFailedException") {      // Existiert bereits, als Erfolg behandeln      return { created: false };    }    throw err;  }}

Die Bedingung attribute_not_exists sorgt dafür, dass der Write nur beim ersten Mal klappt. Retries landen im catch-Block und werden zum No-Op.

Idempotenz in Message Queues

Die meisten Queues garantieren At-least-once-Delivery, nicht Exactly-once. SQS, Kafka, RabbitMQ und Pub/Sub können dieselbe Nachricht mehrfach ausliefern. Consumer stürzen ab, bevor sie bestätigen, Visibility-Timeouts laufen ab, Producer wiederholen Nachrichten. Dein Consumer muss Replays tolerieren.

Eine einfache Dedup-Tabelle mit Nachrichten-ID als Key und einer TTL, die etwas länger ist als dein Retention-Fenster, reicht oft aus. Für stärkere Garantien kombinierst du den Seiteneffekt und die "verarbeitet"-Markierung in einer Datenbank-Transaktion (Outbox-Pattern).

Exactly-once ist ein Mythos

Exactly-once-Delivery ist in verteilten Systemen unmöglich. Das ist ein theoretisches Resultat (das Problem der zwei Generäle, falls du mehr lesen willst). Was du erreichen kannst, ist Exactly-once-Verarbeitung, indem du At-least-once-Delivery mit idempotenten Handlern kombinierst:

at-least-once + idempotente Verarbeitung = effektiv exactly-once

Kafkas "Exactly-once Semantics" funktioniert innerhalb des Kafka-Ökosystems; sobald du aber eine E-Mail sendest oder eine externe API aufrufst, brauchst du wieder idempotente Handler.

Häufige Fallstricke

Das sind die Fehler, die in der Praxis Idempotenz kaputt machen.

1. Wall-Clock-Zeit im Handler verwenden

Wenn dein Handler bei jedem Aufruf created_at = NOW() berechnet, unterscheiden sich die gespeicherten Zeilen zwischen dem ersten Aufruf und einem Retry. Die Operation ist im strengen Sinne nicht mehr idempotent.

Lösung: Zeitstempel einmal erfassen und mit dem Idempotency-Key speichern oder als Parameter übergeben.

2. Seiteneffekte außerhalb der Idempotenz-Grenze

Ein verbreitetes Muster: In die Datenbank committen, dann eine E-Mail senden. Wenn der E-Mail-Versand fehlschlägt und der Client es erneut versucht, passiert der DB-Write zweimal (wenn ungeschützt) oder die E-Mail geht zweimal raus (wenn geschützt).

Lösung: Schreibe die E-Mail-Intent in derselben Transaktion in die DB und lass einen separaten Worker sie idempotent zustellen.

3. Zeitstempel als Idempotency-Key

Keys wie user-123-1696000000 kollidieren unter Last und brechen bei Uhren-Drift. Nimm UUID v4 oder v7. Verlass dich nie allein auf Wall-Clock-Zeit.

4. Vergessen, den Response-Body zu speichern

Einen Key als "verarbeitet" zu markieren, ohne die Antwort zu speichern, macht einen Replay unmöglich. Der Retry liefert dann einen Fehler oder führt die Logik erneut aus.

Lösung: Speichere die vollständige Antwort (Status, Header, Body) atomar mit der verarbeitet-Markierung.

5. Nebenläufigkeit ignorieren

Zwei gleichzeitige Requests mit demselben Key verfehlen beide den Cache, beide führen den Handler aus, beide speichern ein Ergebnis. Einer gewinnt, aber beide Seiteneffekte sind passiert.

Lösung: Nutze einen Lock oder ein Unique-Constraint auf dem Key während des "als in Bearbeitung markieren"-Schritts.

6. Key-Leaks zwischen Tenants

Ein globaler Key-Store ohne Scope erlaubt, dass der Key eines Kunden auf die Operation eines anderen passt.

Lösung: Scope Keys als tenant_id:user_id:endpoint:key.

7. Zu kurze TTL

Laufen Keys ab, bevor der Client aufgibt, führt ein später Retry zu einer zweiten Ausführung.

Lösung: Wähle eine TTL, die länger ist als jedes vernünftige Retry-Fenster. 24 Stunden sind ein sinnvoller Standard.

Wann was verwenden

  • Read-only Endpoint: nutze GET. Mehr ist nicht nötig.
  • Vollständige Ersetzung: nutze PUT. Vertraglich idempotent.
  • Ressource entfernen: nutze DELETE. Vertraglich idempotent.
  • Erstellen mit client-bekannter ID: nutze PUT oder POST mit Unique-Constraint auf der ID.
  • Erstellen mit server-generierter ID: nutze POST mit Idempotency-Key-Header.
  • Message-Queue-Consumer: Dedup-Tabelle oder Outbox-Pattern, immer.
  • Zahlung, Bestellung, E-Mail: Idempotency-Keys sind Pflicht.

Fazit

Idempotenz ist kein Fortgeschrittenen-Thema, das du erst lernst, wenn du skalierst. Sie ist eine Grundanforderung für jede API mit Retry-Logik und das ist jede API. Wähle die HTTP-Methoden richtig, füge Idempotency-Keys zu deinen POST-Endpoints mit realen Seiteneffekten hinzu, nutze Datenbank-Constraints, wo du kannst, und mach deine Queue-Consumer replay-tolerant.

Die Patterns sind nicht kompliziert. Wichtig ist, sie bewusst anzuwenden, bevor deine erste doppelte Belastung passiert, nicht danach.

Referenzen

Ähnliche Beiträge