Diseño de idempotencia - Lograr APIs y procesamientos seguros

12 min de lectura | 2024.12.28

¿Qué es la idempotencia?

La idempotencia es la propiedad de que una operación produce el mismo resultado sin importar cuántas veces se ejecute. Es un concepto importante para garantizar la fiabilidad en sistemas distribuidos y diseño de APIs.

Definición matemática: f(f(x)) = f(x) Aplicar la misma función múltiples veces produce el mismo resultado que aplicarla una vez

¿Por qué es importante la idempotencia?

En las comunicaciones de red, pueden ocurrir los siguientes problemas.

sequenceDiagram
    participant C as Cliente
    participant S as Servidor

    C->>S: Solicitud
    Note over C,S: Timeout<br/>(La respuesta no llegó)
    Note over C: ¿Se procesó la solicitud?<br/>¿Debería reintentar?

Si la operación es idempotente, se puede reintentar de forma segura.

Métodos HTTP e idempotencia

MétodoIdempotenteDescripción
GETObtención de recursos, sin efectos secundarios
HEADIgual que GET (sin cuerpo)
PUTReemplazo completo del recurso
DELETEEliminación del recurso
POSTCreación de recursos, con efectos secundarios
PATCHActualización parcial (depende de la implementación)

PUT vs POST

MétodoResultado
PUT /users/123Sin importar cuántas veces se ejecute, el usuario 123 queda en el mismo estado
POST /usersCada ejecución puede crear un nuevo usuario

Clave de idempotencia (Idempotency Key)

Es una técnica para lograr idempotencia incluso en solicitudes POST.

POST /payments
Idempotency-Key: pay_abc123xyz
Content-Type: application/json

{
  "amount": 5000,
  "currency": "JPY"
}

Ejemplo de implementación

async function processPayment(req, res) {
  const idempotencyKey = req.headers['idempotency-key'];

  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Idempotency-Key required' });
  }

  // Verificar resultado de procesamiento existente
  const existing = await redis.get(`idempotency:${idempotencyKey}`);
  if (existing) {
    return res.status(200).json(JSON.parse(existing));
  }

  // Adquirir bloqueo (prevenir ejecución simultánea)
  const lock = await acquireLock(idempotencyKey);
  if (!lock) {
    return res.status(409).json({ error: 'Request in progress' });
  }

  try {
    // Ejecutar el procesamiento
    const result = await executePayment(req.body);

    // Guardar el resultado (válido por 24 horas)
    await redis.setex(
      `idempotency:${idempotencyKey}`,
      86400,
      JSON.stringify(result)
    );

    return res.status(201).json(result);
  } finally {
    await releaseLock(idempotencyKey);
  }
}

Patrones de implementación de idempotencia

1. Verificación de duplicados mediante identificador único

async function createOrder(orderData, requestId) {
  // Verificar existencia
  const existing = await db.orders.findByRequestId(requestId);
  if (existing) {
    return existing; // Devolver el mismo resultado
  }

  // Nueva creación
  const order = await db.orders.create({
    ...orderData,
    requestId // Guardar el identificador único
  });

  return order;
}

2. Verificación de estado

async function cancelOrder(orderId) {
  const order = await db.orders.findById(orderId);

  // Si ya está cancelado, no hacer nada
  if (order.status === 'cancelled') {
    return order; // Idempotente
  }

  // Verificar si se puede cancelar
  if (order.status === 'shipped') {
    throw new Error('Cannot cancel shipped order');
  }

  return await db.orders.update(orderId, { status: 'cancelled' });
}

3. Bloqueo optimista

async function updateInventory(productId, quantity, version) {
  const result = await db.query(
    `UPDATE inventory
     SET quantity = quantity - $1, version = version + 1
     WHERE product_id = $2 AND version = $3`,
    [quantity, productId, version]
  );

  if (result.rowCount === 0) {
    throw new Error('Concurrent modification detected');
  }
}

Hacer idempotentes las operaciones no idempotentes

Operaciones de incremento

// No idempotente
UPDATE balance SET amount = amount + 100 WHERE user_id = 123

// Hacer idempotente (establecer valor absoluto)
UPDATE balance SET amount = 5100 WHERE user_id = 123

// O, gestionar con ID de transacción
INSERT INTO transactions (id, user_id, amount)
VALUES ('tx_abc', 123, 100)
ON CONFLICT (id) DO NOTHING;

UPDATE balance
SET amount = (SELECT SUM(amount) FROM transactions WHERE user_id = 123)
WHERE user_id = 123;

Envío de correo electrónico

async function sendWelcomeEmail(userId, requestId) {
  // Verificar si ya se envió
  const sent = await db.emailLogs.findOne({
    userId,
    type: 'welcome',
    requestId
  });

  if (sent) {
    return { status: 'already_sent' };
  }

  // Enviar
  await emailService.send(/* ... */);

  // Registrar en el log
  await db.emailLogs.create({
    userId,
    type: 'welcome',
    requestId,
    sentAt: new Date()
  });

  return { status: 'sent' };
}

Estrategias de reintento

Backoff exponencial

async function retryWithBackoff(fn, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === maxRetries - 1) throw error;

      // Backoff exponencial + jitter
      const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
      await sleep(delay);
    }
  }
}

Errores que permiten reintento

ReintentoTipo
✓ PosibleErrores 5xx (error del servidor), timeout, errores de red temporales
✗ No posibleErrores 4xx (error del cliente), errores de lógica de negocio, errores de autenticación

Resumen

La idempotencia es un principio de diseño importante para garantizar la fiabilidad de sistemas distribuidos y APIs. Utiliza patrones como la introducción de claves de idempotencia, verificación de estado y bloqueo optimista para diseñar operaciones que puedan reintentarse de forma segura.

← Volver a la lista