Zum Inhalt springen

Idempotenz im API-Design: Sichere Wiederholungen

Netzwerkausfälle bedeuten Wiederholungen – idempotentes API-Design sorgt dafür, dass dieselbe Anfrage zweimal keine doppelten Buchungen erzeugt.

4 Min. Lesezeit
Sequenzdiagramm, das zeigt, wie eine idempotente Anfrage nach einem Timeout sicher wiederholt wird

Der Client sendet eine Zahlungsanfrage. Der Server verarbeitet sie und sendet eine Antwort – doch die Antwort geht durch ein Netzwerk-Timeout verloren. Der Client wiederholt die Anfrage. Ohne Idempotenz wird die Zahlung zweimal verarbeitet. Der Kunde wird doppelt belastet. Idempotenz stellt sicher, dass die mehrfache Verarbeitung derselben Anfrage denselben Effekt hat wie eine einmalige Verarbeitung. In verteilten Systemen, in denen Netzwerkausfälle und Wiederholungen unvermeidlich sind, ist das kein nettes Extra, sondern eine Notwendigkeit.

Natürlich idempotente und nicht idempotente Methoden

Manche HTTP-Methoden sind von Natur aus idempotent. Andere benötigen ein explizites Design, um bei Wiederholungen sicher zu sein.

tstypescript
// GET — naturally idempotent (read-only)
app.get("/api/orders/:id", async (req, res) => {
  const order = await getOrder(req.params.id);
  res.json(order); // Same result no matter how many times you call it
});
 
// PUT — naturally idempotent (replace entire resource)
app.put("/api/orders/:id", async (req, res) => {
  // Replaces the order completely — calling twice produces same state
  const order = await replaceOrder(req.params.id, req.body);
  res.json(order);
});
 
// DELETE — naturally idempotent
app.delete("/api/orders/:id", async (req, res) => {
  await deleteOrder(req.params.id);
  // Second call: order already deleted, same end state
  res.status(204).send();
});
 
// POST — NOT naturally idempotent
app.post("/api/orders", async (req, res) => {
  // ❌ Calling twice creates two orders!
  const order = await createOrder(req.body);
  res.status(201).json(order);
});

Das Idempotenzschlüssel-Muster

Clients senden mit jeder Anfrage einen eindeutigen Schlüssel mit. Der Server speichert den Schlüssel zusammen mit dem Ergebnis – bei einer Wiederholung wird das gespeicherte Ergebnis zurückgegeben, ohne die Anfrage erneut zu verarbeiten.

tstypescript
// Client sends: POST /api/payments
// Header: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
 
interface IdempotencyRecord {
  key: string;
  statusCode: number;
  body: unknown;
  createdAt: Date;
  expiresAt: Date;
}
 
async function idempotencyMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const key = req.headers["idempotency-key"] as string;
 
  if (!key) {
    // Only require for mutating methods
    if (req.method === "POST") {
      res.status(400).json({ error: "Idempotency-Key header is required for POST requests" });
      return;
    }
    return next();
  }
 
  // Check for existing result
  const existing = await db.query<IdempotencyRecord>(
    "SELECT * FROM idempotency_keys WHERE key = $1 AND expires_at > NOW()",
    [key]
  );
 
  if (existing.rows.length > 0) {
    const record = existing.rows[0];
    res.status(record.statusCode).json(record.body);
    return;
  }
 
  // Capture the response to store it
  const originalJson = res.json.bind(res);
  res.json = (body: unknown) => {
    // Store the result for future retries
    db.query(
      `INSERT INTO idempotency_keys (key, status_code, body, created_at, expires_at)
       VALUES ($1, $2, $3, NOW(), NOW() + INTERVAL '24 hours')
       ON CONFLICT (key) DO NOTHING`,
      [key, res.statusCode, JSON.stringify(body)]
    );
    return originalJson(body);
  };
 
  next();
}
 
app.use("/api/payments", idempotencyMiddleware);

Idempotenz auf Datenbankebene

Verwende bei kritischen Vorgängen wie Zahlungen Datenbank-Constraints, um Duplikate zu verhindern, selbst wenn die Prüfung auf Anwendungsebene einmal versagt.

tstypescript
// ❌ Race condition: two concurrent retries both pass the check
async function processPayment(idempotencyKey: string, amount: number) {
  const existing = await db.query("SELECT * FROM payments WHERE idempotency_key = $1", [idempotencyKey]);
  if (existing.rows.length > 0) return existing.rows[0];
 
  // RACE: Both requests reach here before either inserts
  const payment = await chargeCustomer(amount);
  await db.query("INSERT INTO payments (idempotency_key, amount) VALUES ($1, $2)", [idempotencyKey, amount]);
  return payment;
}
 
// ✅ Database constraint prevents duplicates
async function processPayment(idempotencyKey: string, amount: number) {
  return await db.transaction(async (tx) => {
    // Advisory lock on the idempotency key
    await tx.query(
      "SELECT pg_advisory_xact_lock(hashtext($1))",
      [idempotencyKey]
    );
 
    const existing = await tx.query(
      "SELECT * FROM payments WHERE idempotency_key = $1",
      [idempotencyKey]
    );
 
    if (existing.rows.length > 0) {
      return existing.rows[0]; // Return stored result
    }
 
    const payment = await chargeCustomer(amount);
    await tx.query(
      `INSERT INTO payments (idempotency_key, amount, status, created_at)
       VALUES ($1, $2, $3, NOW())`,
      [idempotencyKey, amount, payment.status]
    );
 
    return payment;
  });
}

Implementierung auf Client-Seite

Clients sind dafür verantwortlich, Idempotenzschlüssel korrekt zu erzeugen und wiederzuverwenden.

tstypescript
// ❌ Generating a new key on every retry — defeats the purpose
async function createPayment(amount: number): Promise<PaymentResult> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await fetch("/api/payments", {
        method: "POST",
        headers: {
          "Idempotency-Key": crypto.randomUUID(), // New key each time!
        },
        body: JSON.stringify({ amount }),
      }).then(r => r.json());
    } catch {
      // Retry with a different key — creates duplicate payments
    }
  }
  throw new Error("Payment failed after 3 attempts");
}
 
// ✅ Generating the key once and reusing on retries
async function createPayment(amount: number): Promise<PaymentResult> {
  const idempotencyKey = crypto.randomUUID(); // Generated once
 
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch("/api/payments", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey, // Same key on every retry
        },
        body: JSON.stringify({ amount }),
      });
 
      if (response.ok) return response.json();
 
      // Don't retry client errors (4xx) — they won't succeed on retry
      if (response.status >= 400 && response.status < 500) {
        throw new Error(`Client error: ${response.status}`);
      }
    } catch (error) {
      if (attempt === 2) throw error;
      await new Promise(resolve =>
        setTimeout(resolve, Math.pow(2, attempt) * 1000)
      );
    }
  }
 
  throw new Error("Payment failed after 3 attempts");
}

Bereinigung und Ablauf

Idempotenz-Datensätze sollten ablaufen. Ein Zeitfenster von 24 Stunden ist üblich – lang genug für Wiederholungen, aber kurz genug, um ein unbegrenztes Wachstum des Speichers zu vermeiden.

sqlsql
-- Idempotency key table with automatic expiration
CREATE TABLE idempotency_keys (
  key VARCHAR(255) PRIMARY KEY,
  status_code INTEGER NOT NULL,
  body JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
 
-- Index for cleanup queries
CREATE INDEX idx_idempotency_keys_expires ON idempotency_keys (expires_at);
 
-- Periodic cleanup (run via cron or scheduled job)
DELETE FROM idempotency_keys WHERE expires_at < NOW();

Die wichtigsten Punkte

  1. Idempotenz verhindert doppelte Verarbeitung — unverzichtbar für jeden Vorgang, bei dem Wiederholungen möglich sind
  2. Verwende Idempotenzschlüssel für POST-Anfragen — der Client erzeugt einen eindeutigen Schlüssel, der Server speichert das Ergebnis
  3. Datenbank-Constraints sind die letzte Verteidigungslinie — nutze Advisory Locks oder Unique-Constraints, um Race Conditions zu verhindern
  4. Clients müssen bei Wiederholungen denselben Schlüssel wiederverwenden — ein neuer Schlüssel pro Versuch hebelt das gesamte Muster aus
  5. Schlüssel sollten ablaufen — 24 Stunden sind in der Regel ausreichend; abgelaufene Datensätze regelmäßig bereinigen
  6. GET, PUT und DELETE sind von Natur aus idempotent — konzentriere das Idempotenz-Design auf die Endpunkte POST und PATCH
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX