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 EntwicklerDAS 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
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.
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.
`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.
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.
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 -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" }'{ "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"}{ "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"}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')}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 ergebnisimport 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()); }}Siehe auch
- Prüfmodus und optimistisches Sperren`X-TacticTable-Dry-Run: 1` rechnet alles durch, ohne etwas zu hinterlassen — und `ETag`/`If-Match` verhindert, dass Ihr Schreibvorgang eine fremde Änderung überfährt.
- Die Form jedes FehlersEin Körper, ein Aufbau, ein geschlossener Katalog von Kennungen — und warum in `message` nie eine rohe Datenbankmeldung steht.
- Reservierungen lesen, anlegen und ändernDas Reservierungsbuch: Liste mit zwanzig Filtern, Einzelabruf mit ETag, Anlegen durch dieselbe Torwache wie das Widget und Teiländerung ohne Datenverlust.