Zum Inhalt springen

Idempotenzschlüssel in der Praxis: Wiederholungen sicher gestalten

Wie man Idempotenzschlüssel entwirft und implementiert, damit wiederholte Anfragen nie doppelt abrechnen, doppelt senden oder doppelt Ressourcen anlegen.

5 Min. Lesezeit
Sequenzdiagramm, das zeigt, wie ein Client eine Anfrage mit einem Idempotenzschlüssel wiederholt und eine zwischengespeicherte Antwort erhält

Netzwerke fallen ständig mitten in Anfragen aus. Der Client sendet eine Zahlungsanfrage, der Server verarbeitet sie, belastet die Karte, und dann kommt die Antwort nie an – Timeout, abgebrochene Verbindung, was auch immer. Der Client sieht keine Antwort und versucht es erneut. Jetzt hast du den Kunden doppelt belastet.

Das ist kein hypothetischer Randfall. Es ist das Standardverhalten jedes Retry-Mechanismus, den du je ausgeliefert hast, sofern du nicht explizit dagegen designt hast. Idempotenzschlüssel sind die Standardlösung, aber die meisten Implementierungen, die ich gesehen habe, scheitern an den Details – sie speichern den Schlüssel, ohne die Antwort zu speichern, sie behandeln keine gleichzeitigen Wiederholungen, oder sie lassen den Schlüssel über unterschiedliche Request-Bodys hinweg kollidieren. Das lässt sich beheben.

Warum „Es ist ein POST, also nicht idempotent" die falsche Denkweise ist

Die REST-Semantik besagt, dass GET, PUT und DELETE per Konvention idempotent sind, POST dagegen nicht. Das ist eine Aussage über die Semantik von HTTP-Methoden, nicht darüber, was dein Geschäft tatsächlich braucht. Das Anlegen einer Bestellung ist ein POST, aber wenn der Client mit derselben Absicht erneut anfragt, willst du die gleiche Bestellung, nicht eine zweite.

Die Lösung besteht darin, Idempotenz von der HTTP-Methode zu entkoppeln und sie unter die Kontrolle des Aufrufers zu stellen: Der Client erzeugt pro logischer Operation einen eindeutigen Schlüssel und sendet ihn zusammen mit der Anfrage. Der Server nutzt diesen Schlüssel, um Duplikate zu erkennen, unabhängig vom Verb.

httphttp
POST /api/payments HTTP/1.1
Idempotency-Key: 7c3a9e02-4b1d-4f8e-9c21-6a1e8f0d5b3a
Content-Type: application/json
 
{"amount": 4999, "currency": "usd", "customerId": "cus_123"}

Den Schlüssel zu speichern reicht nicht — speichere auch die Antwort

Ein häufiger Fehler: prüfen, ob der Schlüssel existiert, und wenn ja, einfach eine generische Meldung „bereits verarbeitet" zurückgeben. Das nützt dem Client nichts – er braucht das tatsächliche Ergebnis der ursprünglichen Anfrage, besonders wenn diese eine generierte Ressourcen-ID enthielt.

tstypescript
// ❌ Tells the client nothing useful on retry
async function createPayment(req: PaymentRequest, key: string) {
  const existing = await db.idempotencyKeys.findOne({ key });
  if (existing) {
    return { status: 409, body: { error: "duplicate request" } };
  }
  const payment = await chargeCard(req);
  await db.idempotencyKeys.insertOne({ key, createdAt: new Date() });
  return { status: 201, body: payment };
}
 
// ✅ Returns the original response, verbatim, on retry
async function createPayment(req: PaymentRequest, key: string) {
  const existing = await db.idempotencyKeys.findOne({ key });
  if (existing) {
    return { status: existing.statusCode, body: existing.responseBody };
  }
 
  const payment = await chargeCard(req);
 
  await db.idempotencyKeys.insertOne({
    key,
    statusCode: 201,
    responseBody: payment,
    requestHash: hashRequest(req),
    createdAt: new Date(),
  });
 
  return { status: 201, body: payment };
}

Der Client sollte blind erneut anfragen können und genau das zurückbekommen, was er beim ersten Mal bekommen hätte – dieselbe Bestell-ID, denselben Betrag, denselben Status.

Schutz vor der Wiederverwendung von Schlüsseln mit unterschiedlichen Request-Bodys

Wenn ein Client denselben Idempotency-Key mit einem anderen Request-Body sendet, ist das ein Bug auf seiner Seite (oder eine clientseitig erzeugte Kollision), und du solltest das laut und deutlich ablehnen, statt stillschweigend die erste Antwort für eine andere Anfrage zurückzugeben.

tstypescript
function hashRequest(req: PaymentRequest): string {
  return crypto
    .createHash("sha256")
    .update(JSON.stringify(sortKeys(req)))
    .digest("hex");
}
 
async function createPayment(req: PaymentRequest, key: string) {
  const requestHash = hashRequest(req);
  const existing = await db.idempotencyKeys.findOne({ key });
 
  if (existing) {
    if (existing.requestHash !== requestHash) {
      return {
        status: 422,
        body: { error: "idempotency key reused with a different payload" },
      };
    }
    return { status: existing.statusCode, body: existing.responseBody };
  }
 
  // proceed with the actual operation...
}
!

Verlass dich nie darauf, dass der Client Schlüssel korrekt wiederverwendet. Hashe und vergleiche den Request-Body jedes Mal – das ist eine günstige Prüfung, die eine ganze Klasse echter Bugs abfängt.

Die Race Condition, die alle vergessen

Zwei identische Anfragen können nahezu gleichzeitig eintreffen – ein Client, der aggressiv wiederholt, oder ein Proxy, der eine Anfrage dupliziert. Wenn deine „Prüfen, dann Einfügen"-Logik nicht atomar ist, können beide Anfragen die Existenzprüfung passieren, bevor eine von beiden den Schlüssel geschrieben hat, und du verarbeitest die Operation am Ende trotzdem zweimal.

Die Lösung besteht darin, das Einfügen des Schlüssels atomar zu machen und ihn als Lock zu verwenden, nicht nur als Nachschlagetabelle.

tstypescript
// ❌ Race condition: two concurrent requests both pass the check
const existing = await db.idempotencyKeys.findOne({ key });
if (!existing) {
  const payment = await chargeCard(req); // both requests get here
  await db.idempotencyKeys.insertOne({ key, ... });
}
 
// ✅ Atomic insert acts as a distributed lock
async function createPayment(req: PaymentRequest, key: string) {
  const requestHash = hashRequest(req);
 
  try {
    await db.idempotencyKeys.insertOne({
      key,
      requestHash,
      status: "processing",
      createdAt: new Date(),
    });
  } catch (err) {
    if (isDuplicateKeyError(err)) {
      return waitForResultOrReturnConflict(key, requestHash);
    }
    throw err;
  }
 
  const payment = await chargeCard(req);
 
  await db.idempotencyKeys.updateOne(
    { key },
    { $set: { status: "completed", statusCode: 201, responseBody: payment } },
  );
 
  return { status: 201, body: payment };
}

Ein eindeutiger Index auf key in deiner Datenbank sorgt dafür, dass das Einfügen für die unterlegene Anfrage schnell fehlschlägt; diese fragt dann entweder das Ergebnis der gewinnenden Anfrage per Polling ab oder gibt einen 409 zurück, der den Client anweist, nach einer kurzen Verzögerung erneut zu versuchen.

Umgang mit dem Zeitfenster „Wird noch verarbeitet"

Zwischen dem atomaren Einfügen und dem Abschluss der Operation kann ein Retry in einem Zustand landen, in dem der Schlüssel bereits existiert, aber noch keine Antwort vorliegt. Lass den zweiten Aufrufer nicht hängen – entscheide explizit, was passieren soll.

Zustand des vorhandenen SchlüsselsZu sendende Antwort
processing, < 5s alt409 Conflict – „Anfrage läuft, erneut versuchen"
processing, veraltetOperation erneut ausführen – der ursprüngliche Worker ist vermutlich abgestürzt
completedZwischengespeicherte statusCode + responseBody zurückgeben
failedZwischengespeicherten Fehler zurückgeben, oder Retry erlauben, falls transient

Dieser „veraltet"-Zweig ist wichtiger, als man denkt. Wenn der Prozess, der die ursprüngliche Anfrage bearbeitet, nach dem Erwerb des Locks, aber vor Abschluss der Operation abstürzt, bleibt jeder Retry für immer bei 409 hängen – es sei denn, du hast ein Timeout, das den Schlüssel zurückfordert.

tstypescript
const STALE_THRESHOLD_MS = 30_000;
 
function isStale(record: IdempotencyRecord): boolean {
  return (
    record.status === "processing" &&
    Date.now() - record.createdAt.getTime() > STALE_THRESHOLD_MS
  );
}

Schlüsselablauf und Speicherkosten

Idempotenzschlüssel können nicht ewig leben – sonst sammelst du unbegrenzt Zeilen an. Die meisten Systeme lassen Schlüssel nach 24 Stunden ablaufen, was realistische Retry-Fenster (Client-Timeouts, exponentielles Backoff, manuelle Wiederholungen durch den Support) bequem abdeckt, ohne veraltete Einträge zu hinterlassen.

sqlsql
-- Postgres: TTL via a scheduled job, or a partial index + cron cleanup
CREATE TABLE idempotency_keys (
  key TEXT PRIMARY KEY,
  request_hash TEXT NOT NULL,
  status TEXT NOT NULL,
  status_code INT,
  response_body JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX idx_idempotency_created_at ON idempotency_keys (created_at);
 
-- Cleanup job, run hourly
DELETE FROM idempotency_keys WHERE created_at < now() - INTERVAL '24 hours';

Wenn du statt eines relationalen Speichers Redis verwendest, ist das noch einfacher – setze beim Schreiben einfach eine TTL auf den Schlüssel und verzichte komplett auf den Cleanup-Job.

Wo Idempotenzschlüssel eingesetzt werden sollten

Nicht jeder Endpunkt braucht das. Reserviere Idempotenzschlüssel für Operationen, die von Natur aus nicht idempotent sind und bei Duplizierung echte Konsequenzen haben: Zahlungen, Bestellerstellung, das Versenden von Benachrichtigungen, das Provisionieren von Infrastruktur. Mach dir nicht die Mühe, ein PUT /users/:id/profile-Update damit zu umhüllen – es ist bereits von Natur aus idempotent.

tstypescript
// ✅ Middleware applied selectively to mutation-heavy, consequential routes
router.post("/payments", requireIdempotencyKey, createPayment);
router.post("/orders", requireIdempotencyKey, createOrder);
router.post("/notifications/send", requireIdempotencyKey, sendNotification);
 
// Not needed — PUT is naturally idempotent
router.put("/users/:id/profile", updateProfile);

Die wichtigsten Erkenntnisse

  1. Speichere die Antwort, nicht nur den Schlüssel – Retries sollten genau das ursprüngliche Ergebnis zurückgeben, keinen generischen Duplikatfehler.
  2. Hashe den Request-Body und vergleiche ihn bei jedem Retry – wiederverwendete Schlüssel mit unterschiedlichen Request-Bodys sind ein Bug, der einen lauten Fehler braucht, keinen stillen Erfolg.
  3. Mache das Einfügen des Schlüssels atomar und nutze es als Lock – ein „Prüfen, dann Einfügen"-Muster hat bei gleichzeitigen Retries eine Race Condition.
  4. Behandle das Zeitfenster „in Bearbeitung" explizit – entscheide, was passiert, wenn ein Retry eintrifft, während die ursprüngliche Anfrage noch verarbeitet wird, und fordere veraltete Locks zurück.
  5. Lass Schlüssel ablaufen – 24 Stunden sind ein sinnvoller Standardwert, der realistische Retry-Fenster abdeckt, ohne unbegrenztes Speicherwachstum zu verursachen.
  6. Wende Idempotenz selektiv an – reserviere sie für folgenreiche, nicht idempotente Operationen wie Zahlungen und das Anlegen von Ressourcen, nicht für Routen, die ohnehin schon sicher wiederholbar sind.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX