Idempotenz: derselbe Aufruf, dasselbe Ergebnis

Wie `Idempotency-Key` einen Netzabbruch von einer zweiten Buchung trennt, wo er Pflicht ist und was bei Wiedergabe, Kollision und laufender Anfrage passiert.

Für Entwickler

DAS PROBLEM, UM DAS ES GEHT: die Kasse schickt „Reservierung anlegen", das Netz bricht NACH dem Schreiben und VOR der Antwort zusammen, die Kasse wiederholt — und der Gast steht zweimal im Buch. Ohne Idempotenz ist das nicht die Ausnahme, sondern der Normalfall jeder Mobilfunkanbindung.

DIE LÖSUNG IST EINE KOPFZEILE: `Idempotency-Key: <eindeutiger Wert je fachlichem Vorgang>`. Eine UUIDv4 ist die naheliegende Wahl; erlaubt sind bis zu 255 Zeichen. Wichtig ist, dass der Wert zu EINEM Vorgang gehört und bei jeder Wiederholung DESSELBEN Vorgangs unverändert mitgeschickt wird — nicht je Versuch neu erzeugt. Ein Schlüssel je Versuch ist genau so gut wie gar keiner.

WAS DANN PASSIERT: Der erste Aufruf arbeitet und speichert seine Antwort. Kommt derselbe Schlüssel mit DEMSELBEN Rumpf noch einmal, wird NICHT gearbeitet — die gespeicherte Antwort geht unverändert erneut hinaus, mit demselben Status und der zusätzlichen Kopfzeile `Idempotency-Replayed: true`. Kommt er mit einem ANDEREN Rumpf, ist das 409 `idempotency_key_reuse`, und es wurde nichts getan. Läuft die erste Anfrage noch, ist es 409 `idempotency_in_progress` — warten Sie kurz und wiederholen Sie dieselbe Anfrage.

DER FINGERABDRUCK GEHT ÜBER METHODE, PFAD UND DEN ROHEN RUMPF. Zwei Rümpfe, die dasselbe bedeuten, aber anders geschrieben sind (andere Feldreihenfolge, anderer Leerraum), gelten als VERSCHIEDEN. Erzeugen Sie den Rumpf Ihrer Wiederholung deshalb nicht neu, sondern schicken Sie denselben Text.

EIN SCHLÜSSEL GILT 24 STUNDEN. Danach ist er wieder frei. Der Geltungsbereich ist der API-Schlüssel, nicht der Betrieb: zwei Erweiterungen desselben Hauses kollidieren nicht auf demselben Wert „1".

BEI EINIGEN OPERATIONEN IST DIE KOPFZEILE PFLICHT, und ohne sie antwortet die Route mit 400. Das sind: `POST /api/v1/suppliers`, `POST /api/v1/raw-materials`, `POST /api/v1/recipes`, `PUT /api/v1/warehouse-stock`, `POST /api/v1/blocks` und `POST /api/v1/guests/{id}/anonymize`. Bei allen anderen Schreibwegen ist sie freiwillig — und trotzdem richtig.

EIN FACHLICH ABGELEHNTER AUFRUF GIBT DEN SCHLÜSSEL WIEDER FREI. Ein belegter Tisch, ein gesperrter Tag, eine fremde Kennung: es ist nichts entstanden, also blockiert der Schlüssel nicht. Sonst antwortete jede Wiederholung 24 Stunden lang mit „läuft noch" — auch nachdem der Tisch längst frei geworden ist.

DER PRÜFMODUS VERBRAUCHT KEINEN SCHLÜSSEL. Ein Aufruf mit `X-TacticTable-Dry-Run: 1` schreibt nichts und quittiert deshalb auch keinen Idempotenzschlüssel — Sie dürfen denselben Wert danach für den echten Aufruf benutzen. Andernfalls liefe der echte Aufruf in die Wiedergabe der Probe: er glaubte, geschrieben zu haben, und hätte nichts geschrieben.

Schritt für Schritt

  1. Den Schlüssel am fachlichen Vorgang festmachen

    Erzeugen Sie ihn dort, wo der Vorgang entsteht — beim Klick des Kellners, beim Anlegen des Auftrags in Ihrem System — und speichern Sie ihn mit. Nicht in der HTTP-Schicht, die jeden Versuch neu aufbaut.

  2. Bei jedem Versuch denselben Schlüssel und denselben Rumpf schicken

    Serialisieren Sie den Rumpf EINMAL und halten Sie den Text. Ein neu erzeugter JSON-Text mit anderer Feldreihenfolge ist für den Fingerabdruck ein anderer Vorgang.

  3. `Idempotency-Replayed: true` als Erfolg werten

    Diese Antwort ist der Beweis, dass genau einmal gearbeitet wurde. Behandeln Sie sie wie die erste Antwort — der Status ist derselbe, also auch 201 bei einem Anlegen.

  4. Bei `idempotency_in_progress` kurz warten

    Zwei bis fünf Sekunden, dann dieselbe Anfrage noch einmal. Erzeugen Sie KEINEN neuen Schlüssel — damit legen Sie genau die zweite Zeile an, die Sie vermeiden wollten.

  5. Bei `idempotency_key_reuse` den Fehler bei sich suchen

    Derselbe Schlüssel, ein anderer Rumpf: Ihr System hat zwei verschiedene Vorgänge unter einer Kennung geführt. Die API hat NICHTS getan — das ist die gute Nachricht.

Codebeispiele

curl — anlegen mit Idempotenzschlüssel
curl
curl -sS -X POST https://tactictable.com/api/v1/reservations \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 6b2f0d18-4f0a-4f6b-9a91-0c4a2d8e7f51" \  -d '{    "guestName": "Familie Berger",    "phone": "+4366412345678",    "partySize": 4,    "date": "2026-09-20",    "time": "19:30"  }'
Schicken Sie denselben Befehl ein zweites Mal: Sie bekommen dieselbe Antwort mit demselben `id` und der Kopfzeile `Idempotency-Replayed: true` — und es steht weiterhin genau eine Reservierung im Buch.
Antwort 409 — derselbe Schlüssel, ein anderer Rumpf
JSON
{  "error": "idempotency_key_reuse",  "message": "Dieser Idempotency-Key wurde bereits fuer eine ANDERE Anfrage verwendet. Verwenden Sie je Vorgang einen eigenen Schluessel.",  "docs": "https://tactictable.com/dokumentation/api/fehler/idempotency_key_reuse",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Es wurde NICHTS ausgeführt. Der Aufruf ist folgenlos — prüfen Sie, warum Ihr System zwei Vorgänge unter derselben Kennung führt.
Antwort 409 — die erste Anfrage läuft noch
JSON
{  "error": "idempotency_in_progress",  "message": "Eine Anfrage mit diesem Idempotency-Key laeuft gerade noch. Bitte kurz warten und dieselbe Anfrage wiederholen.",  "docs": "https://tactictable.com/dokumentation/api/fehler/idempotency_in_progress",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Typisch, wenn zwei Kassen-Terminals denselben Vorgang gleichzeitig abschicken. Warten und wiederholen — mit demselben Schlüssel.
TypeScript — ein Vorgang, ein Schlüssel, mehrere Versuche
TypeScript
import { randomUUID } from 'node:crypto' interface Vorgang {    /** Wird EINMAL erzeugt und mit dem Vorgang gespeichert. */    idempotenzSchluessel: string    /** Der Rumpf als TEXT — nicht als Objekt, das jedes Mal neu serialisiert wird. */    rumpf: string} export function neuerVorgang(daten: Record<string, unknown>): Vorgang {    return { idempotenzSchluessel: randomUUID(), rumpf: JSON.stringify(daten) }} export async function lege(vorgang: Vorgang, token: string): Promise<unknown> {    for (let versuch = 1; versuch <= 3; versuch++) {        const antwort = await fetch('https://tactictable.com/api/v1/reservations', {            method: 'POST',            headers: {                Authorization: 'Bearer ' + token,                'Content-Type': 'application/json',                'Idempotency-Key': vorgang.idempotenzSchluessel,            },            body: vorgang.rumpf,        })         const rumpf = await antwort.json()         if (antwort.ok) {            // Auch eine Wiedergabe ist ein Erfolg: sie beweist, dass genau            // einmal gearbeitet wurde.            const wiedergabe = antwort.headers.get('idempotency-replayed') === 'true'            return { ...(rumpf as object), wiedergabe }        }         if (rumpf.error === 'idempotency_in_progress' && versuch < 3) {            await new Promise((weiter) => setTimeout(weiter, 3000))            continue        }         throw new Error(rumpf.error + ': ' + rumpf.message)    }    throw new Error('unerreichbar')}
Der Schlüssel wird EINMAL erzeugt, nicht in der Schleife. Genau das ist der Unterschied zwischen einer Anbindung, die Netzabbrüche übersteht, und einer, die bei jedem Abbruch eine zweite Reservierung anlegt.
Python — Lieferant anlegen (Schlüssel ist hier Pflicht)
Python
import jsonimport uuid import requests  def lieferant_anlegen(token: str, daten: dict) -> dict:    # Ein Schluessel je fachlichem Vorgang, nicht je Versuch.    schluessel = str(uuid.uuid4())    rumpf = json.dumps(daten)     antwort = requests.post(        "https://tactictable.com/api/v1/suppliers",        data=rumpf,        headers={            "Authorization": "Bearer " + token,            "Content-Type": "application/json",            "Idempotency-Key": schluessel,        },        timeout=30,    )     ergebnis = antwort.json()    if antwort.status_code not in (200, 201):        raise RuntimeError("{}: {}".format(ergebnis["error"], ergebnis["message"]))     if antwort.headers.get("Idempotency-Replayed") == "true":        print("bereits angelegt — Antwort aus dem Speicher")     return ergebnis
`data=rumpf` statt `json=daten`: so geht genau der Text hinaus, den Sie gespeichert haben. `json=` serialisiert bei jedem Aufruf neu — meist gleich, aber „meist" reicht für einen Fingerabdruck nicht.
Java — derselbe Rumpf, derselbe Schlüssel
Java
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.util.UUID; public final class Anlegen {    public static HttpResponse<String> reservierungAnlegen(            HttpClient klient, String token, String rumpf, UUID vorgang) throws Exception {         HttpRequest anfrage = HttpRequest.newBuilder()                .uri(URI.create("https://tactictable.com/api/v1/reservations"))                .header("Authorization", "Bearer " + token)                .header("Content-Type", "application/json")                .header("Idempotency-Key", vorgang.toString())                .POST(HttpRequest.BodyPublishers.ofString(rumpf))                .build();         return klient.send(anfrage, HttpResponse.BodyHandlers.ofString());    }}
`vorgang` kommt von aussen herein und wird nicht in der Methode erzeugt. Eine Methode, die ihren eigenen Schlüssel würfelt, ist bei jedem Aufruf ein neuer Vorgang — und damit nutzlos.