Saltar al contenido

APIs idempotentes: reintentar solicitudes de forma segura

Construye endpoints idempotentes que manejen reintentos con claves de idempotencia, restricciones y máquinas de estado: pagos, órdenes y webhooks.

5 min de lectura
Diagrama de secuencia que muestra un cliente reintentando una solicitud de pago fallida con una clave de idempotencia y recibiendo la misma respuesta exitosa

Las solicitudes de red fallan. Los clientes agotan el tiempo de espera, las conexiones se interrumpen, los balanceadores de carga se reinician y los servidores se reinician a mitad de una solicitud. Cuando un cliente no recibe una respuesta, no tiene forma de saber si la solicitud fue procesada: el servidor podría haber completado la operación, pero la respuesta se perdió. Sin idempotencia, reintentar esa solicitud crea un duplicado: un doble cobro, una orden duplicada o un mensaje adicional enviado.

Las APIs idempotentes garantizan que ejecutar la misma solicitud varias veces produce el mismo resultado que ejecutarla una sola vez. GET y DELETE son naturalmente idempotentes. POST y PATCH no lo son: necesitan un diseño explícito para ser seguros ante reintentos.

El patrón de clave de idempotencia

El cliente envía una clave única con cada solicitud. El servidor almacena el resultado bajo este identificador y devuelve el resultado en caché en los reintentos.

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",
    });
  }
});

Idempotencia a nivel de base de datos

Para casos más simples, las restricciones de la base de datos pueden aplicar idempotencia sin un almacén de claves explícito.

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",
    });
  }
});

Middleware de idempotencia

Extrae la lógica de idempotencia en un middleware reutilizable que funcione en todos los endpoints.

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
);

Idempotencia basada en máquinas de estado

Para operaciones complejas con varios pasos, una máquina de estado garantiza que cada paso se ejecute exactamente una vez, incluso entre reintentos.

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
    }
  );
}

Puntos clave

Las claves de idempotencia enviadas por el cliente permiten al servidor detectar reintentos y devolver respuestas en caché en lugar de volver a ejecutar efectos secundarios; esto es esencial para el procesamiento de pagos, la creación de órdenes y cualquier operación en la que los duplicados causen daños reales. Las restricciones a nivel de base de datos que usan cláusulas ON CONFLICT ofrecen una idempotencia más simple para operaciones que pueden expresarse como upserts, evitando la necesidad de un almacén de claves de idempotencia separado cuando la clave natural sirve como mecanismo de deduplicación. El middleware reutilizable que intercepta respuestas y las almacena en caché por clave de idempotencia debe verificar las huellas dactilares de las solicitudes para rechazar el reuso de la misma clave con diferentes parámetros, y devolver 409 para solicitudes aún en curso, evitando el procesamiento duplicado en paralelo. Las máquinas de estado con bloqueo optimista garantizan que las operaciones de varios pasos sean idempotentes haciendo que cada transición de estado sea atómica y verificando si el estado objetivo ya se alcanzó: reintentar una transición que ya se completó es un éxito sin efectos adicionales.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX