Idempotente APIs: Anfragen sicher wiederholen
Baue idempotente API-Endpunkte mit Idempotency-Keys, Datenbank-Constraints und Zustandsautomaten — für Zahlungen, Bestellungen und Webhooks.

Netzwerkanfragen schlagen fehl. Clients laufen in Timeouts, Verbindungen brechen ab, Load Balancer setzen sich zurück und Server starten mitten in einer Anfrage neu. Wenn ein Client keine Antwort erhält, kann er nicht wissen, ob die Anfrage verarbeitet wurde – der Server könnte den Vorgang abgeschlossen haben, aber die Antwort ging verloren. Ohne Idempotenz erzeugt ein erneuter Versuch dieser Anfrage ein Duplikat: eine doppelte Abbuchung, eine doppelte Bestellung oder eine zusätzlich gesendete Nachricht.
Idempotente APIs garantieren, dass dieselbe Anfrage mehrmals auszuführen dasselbe Ergebnis liefert wie einmaliges Ausführen. GET und DELETE sind natürlicherweise idempotent. POST und PATCH sind es nicht – sie brauchen ein explizites Design, um für Wiederholungen sicher zu werden.
Das Idempotency-Key-Muster
Der Client sendet mit jeder Anfrage einen eindeutigen Schlüssel. Der Server speichert das Ergebnis unter diesem Identifikator und gibt bei Wiederholungen das zwischengespeicherte Ergebnis zurück.
// ❌ Non-idempotent payment endpoint
app.post("/api/payments", async (req, res) => {
const { userId, amount, currency } = req.body;
// If client retries after timeout: double charge
const charge = await stripe.charges.create({
amount,
currency,
customer: userId,
});
await db.createPayment({
userId,
amount,
stripeChargeId: charge.id,
});
res.status(201).json({ paymentId: charge.id });
});// ✅ Idempotent payment endpoint
app.post("/api/payments", async (req, res) => {
const idempotencyKey = req.headers[
"idempotency-key"
] as string;
if (!idempotencyKey) {
res.status(400).json({
error: "Idempotency-Key header is required",
});
return;
}
// Check for existing result
const existing = await db.getIdempotencyRecord(
idempotencyKey
);
if (existing) {
if (existing.status === "processing") {
// Request is still being processed
res.status(409).json({
error: "Request is still being processed",
retryAfter: 2,
});
return;
}
// Return cached response
res.status(existing.statusCode).json(
existing.responseBody
);
return;
}
// Lock the idempotency key to prevent races
const locked = await db.createIdempotencyRecord({
key: idempotencyKey,
status: "processing",
requestBody: req.body,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 86_400_000),
});
if (!locked) {
// Another request with same key is in flight
res.status(409).json({
error: "Duplicate request in progress",
});
return;
}
try {
const { userId, amount, currency } = req.body;
const charge = await stripe.charges.create({
amount,
currency,
customer: userId,
idempotencyKey, // Stripe supports this natively
});
const payment = await db.createPayment({
userId,
amount,
stripeChargeId: charge.id,
});
const response = { paymentId: payment.id };
// Cache the successful response
await db.updateIdempotencyRecord(idempotencyKey, {
status: "completed",
statusCode: 201,
responseBody: response,
});
res.status(201).json(response);
} catch (error) {
// Cache the error response
await db.updateIdempotencyRecord(idempotencyKey, {
status: "failed",
statusCode: 500,
responseBody: {
error: "Payment processing failed",
},
});
res.status(500).json({
error: "Payment processing failed",
});
}
});Idempotenz auf Datenbankebene
Für einfachere Fälle können Datenbank-Constraints Idempotenz erzwingen, ohne einen expliziten Schlüsselspeicher.
// ❌ Race condition: two requests create duplicate orders
app.post("/api/orders", async (req, res) => {
const order = await db.query(
"INSERT INTO orders (user_id, product_id, quantity) VALUES ($1, $2, $3) RETURNING *",
[req.body.userId, req.body.productId, req.body.quantity]
);
res.status(201).json(order.rows[0]);
});// ✅ Unique constraint prevents duplicates
// Migration: CREATE UNIQUE INDEX
// idx_orders_idempotency
// ON orders (idempotency_key)
app.post("/api/orders", async (req, res) => {
const { userId, productId, quantity } = req.body;
const idempotencyKey = req.headers[
"idempotency-key"
] as string;
try {
const result = await db.query(
`INSERT INTO orders
(idempotency_key, user_id, product_id, quantity)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key)
DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key
RETURNING *`,
[idempotencyKey, userId, productId, quantity]
);
// Whether this was an insert or no-op,
// result is the same order
res.status(201).json(result.rows[0]);
} catch (error) {
res.status(500).json({
error: "Failed to create order",
});
}
});Idempotency-Middleware
Ziehe die Idempotenz-Logik in wiederverwendbare Middleware aus, die über Endpunkte hinweg funktioniert.
interface IdempotencyRecord {
key: string;
status: "processing" | "completed" | "failed";
statusCode: number;
responseBody: unknown;
requestFingerprint: string;
createdAt: Date;
expiresAt: Date;
}
function idempotent(ttlMs: number = 86_400_000) {
return async (
req: Request,
res: Response,
next: NextFunction
) => {
const key = req.headers["idempotency-key"] as string;
if (!key) {
next(); // Non-idempotent by default
return;
}
// Fingerprint request to detect mismatched retries
const fingerprint = createFingerprint(
req.method,
req.path,
req.body
);
const existing = await cache.get<IdempotencyRecord>(
`idempotency:${key}`
);
if (existing) {
// Verify the request matches the original
if (existing.requestFingerprint !== fingerprint) {
res.status(422).json({
error:
"Idempotency key reused with " +
"different request parameters",
});
return;
}
if (existing.status === "processing") {
res.status(409).json({
error: "Request still processing",
retryAfter: 2,
});
return;
}
res
.status(existing.statusCode)
.json(existing.responseBody);
return;
}
// Reserve the key
await cache.set(
`idempotency:${key}`,
{
key,
status: "processing",
requestFingerprint: fingerprint,
createdAt: new Date(),
expiresAt: new Date(Date.now() + ttlMs),
} as IdempotencyRecord,
ttlMs
);
// Intercept the response to cache it
const originalJson = res.json.bind(res);
res.json = function (body: unknown) {
cache.set(
`idempotency:${key}`,
{
key,
status:
res.statusCode < 500
? "completed"
: "failed",
statusCode: res.statusCode,
responseBody: body,
requestFingerprint: fingerprint,
createdAt: new Date(),
expiresAt: new Date(Date.now() + ttlMs),
} as IdempotencyRecord,
ttlMs
);
return originalJson(body);
};
next();
};
}
function createFingerprint(
method: string,
path: string,
body: unknown
): string {
const crypto = require("node:crypto");
return crypto
.createHash("sha256")
.update(
JSON.stringify({ method, path, body })
)
.digest("hex");
}
// Usage
app.post(
"/api/payments",
idempotent(24 * 60 * 60 * 1000),
paymentHandler
);
app.post(
"/api/orders",
idempotent(1 * 60 * 60 * 1000),
orderHandler
);Zustandsautomaten-basierte Idempotenz
Für komplexe Vorgänge mit mehreren Schritten stellt ein Zustandsautomat sicher, dass jeder Schritt selbst über Wiederholungen hinweg genau einmal ausgeführt wird.
type OrderState =
| "created"
| "payment_pending"
| "payment_confirmed"
| "fulfillment_pending"
| "shipped"
| "failed";
const validTransitions: Record<
OrderState,
OrderState[]
> = {
created: ["payment_pending", "failed"],
payment_pending: ["payment_confirmed", "failed"],
payment_confirmed: ["fulfillment_pending", "failed"],
fulfillment_pending: ["shipped", "failed"],
shipped: [],
failed: [],
};
async function transitionOrder(
orderId: string,
targetState: OrderState,
action: () => Promise<void>
): Promise<boolean> {
// Atomic state transition with optimistic locking
const order = await db.query(
`SELECT id, state, version FROM orders
WHERE id = $1 FOR UPDATE`,
[orderId]
);
const currentState = order.rows[0]
.state as OrderState;
const version = order.rows[0].version;
// Already in target state? Idempotent success
if (currentState === targetState) {
return true;
}
// Validate transition
if (
!validTransitions[currentState]?.includes(
targetState
)
) {
throw new Error(
`Invalid transition: ${currentState} → ${targetState}`
);
}
// Execute the action
await action();
// Update state with version check
const result = await db.query(
`UPDATE orders
SET state = $1, version = version + 1
WHERE id = $2 AND version = $3`,
[targetState, orderId, version]
);
if (result.rowCount === 0) {
throw new Error(
"Concurrent modification detected"
);
}
return true;
}
// Usage: retry-safe order processing
async function processOrder(orderId: string) {
await transitionOrder(
orderId,
"payment_pending",
async () => {
// Reserve inventory
}
);
await transitionOrder(
orderId,
"payment_confirmed",
async () => {
// Charge payment
}
);
await transitionOrder(
orderId,
"fulfillment_pending",
async () => {
// Queue for shipping
}
);
}Wesentliche Erkenntnisse
Vom Client gesendete Idempotency-Keys ermöglichen es dem Server, Wiederholungen zu erkennen und zwischengespeicherte Antworten zurückzugeben, anstatt Seiteneffekte erneut auszuführen – das ist essenziell für Zahlungsverarbeitung, Bestellerstellung und jede Operation, bei der Duplikate realen Schaden anrichten. Datenbank-Constraints mit ON CONFLICT-Klauseln bieten einfachere Idempotenz für Operationen, die als Upserts ausgedrückt werden können, und vermeiden einen separaten Idempotency-Key-Speicher, wenn der natürliche Schlüssel als Deduplizierungsmechanismus dient. Wiederverwendbare Middleware, die Antworten abfängt und per Idempotency-Key zwischenspeichert, sollte Request-Fingerprints überprüfen, um die Wiederverwendung desselben Schlüssels mit anderen Parametern abzulehnen, und 409 für noch laufende Anfragen zurückgeben, um parallele Doppelverarbeitung zu verhindern. Zustandsautomaten mit optimistischem Locking stellen sicher, dass Mehrschrittvorgänge idempotent sind, indem jeder Zustandsübergang atomar gemacht und geprüft wird, ob der Zielzustand bereits erreicht wurde – der erneute Versuch eines bereits abgeschlossenen Übergangs ist ein erfolgreicher No-Op.


