Zum Inhalt springen

Idempotente APIs: Anfragen sicher wiederholen

Baue idempotente API-Endpunkte mit Idempotency-Keys, Datenbank-Constraints und Zustandsautomaten — für Zahlungen, Bestellungen und Webhooks.

4 Min. Lesezeit
Sequenzdiagramm, das einen Client zeigt, der eine fehlgeschlagene Zahlungsanfrage mit einem Idempotency-Key wiederholt und dieselbe erfolgreiche Antwort erhält

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.

tstypescript
// ❌ 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 });
});
tstypescript
// ✅ 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.

tstypescript
// ❌ 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]);
});
tstypescript
// ✅ 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.

tstypescript
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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX