Idempotencia

Fundamentos Security Notes Jan 9, 2026 JAVASCRIPT
http rest fiabilidad seguridad lógica-reintento sistemas-distribuidos

Definición

La idempotencia es un concepto matemático y de ciencias de la computación que significa que realizar una operación múltiples veces tiene el mismo efecto que realizarla una vez. En el contexto de APIs REST, un método HTTP idempotente puede ser llamado repetidamente con los mismos parámetros, y el resultado será idéntico después de la primera petición exitosa - no ocurren efectos secundarios adicionales en llamadas subsecuentes.

Piensa en la idempotencia como un interruptor de luz. Accionar el interruptor a “encendido” múltiples veces no hace que la luz esté “más encendida” - permanece encendida después del primer accionamiento. De manera similar, enviar la misma petición DELETE cinco veces no elimina el recurso cinco veces - se elimina una vez, y las peticiones subsecuentes simplemente confirman que ya no existe.

Esta propiedad es crucial para construir sistemas distribuidos confiables. Las redes son poco confiables - las peticiones pueden fallar, agotar el tiempo de espera o duplicarse. Con operaciones idempotentes, los clientes pueden reintentar peticiones fallidas de manera segura sin preocuparse por consecuencias no deseadas. En REST, GET, PUT, DELETE, HEAD, OPTIONS y TRACE son idempotentes por especificación. POST y PATCH generalmente NO son idempotentes (aunque PATCH puede diseñarse para ser idempotente en algunos casos).

Ejemplo

Procesamiento de Pagos: Cuando Stripe procesa un pago, usan claves de idempotencia. Si tu servidor falla después de enviar POST /v1/charges pero antes de recibir la respuesta, puedes reintentar con la misma clave de idempotencia. Stripe reconoce la petición duplicada y devuelve el cargo original en lugar de crear uno nuevo. Esto previene cobrar dos veces a los clientes.

Carga de Archivo: Subir un archivo con PUT /files/photo.jpg es idempotente. Si la red agota el tiempo de espera, puedes reintentar la petición PUT de manera segura. La primera carga exitosa almacena el archivo; PUTs idénticos subsecuentes simplemente lo sobrescriben con los mismos datos - el resultado es el mismo.

Eliminación de Recurso: DELETE /api/users/123 es idempotente. La primera petición elimina al usuario 123 y devuelve 204 No Content. La segunda petición encuentra al usuario ya eliminado y devuelve 404 Not Found o 204 No Content (ambos son válidos). El estado del servidor es el mismo - el usuario 123 no existe.

Actualización con PUT: PUT /api/products/456 { "name": "Widget", "price": 9.99 } es idempotente. Enviar esta petición 10 veces resulta en que el producto tenga nombre “Widget” y precio 9.99 - el mismo estado que después de la primera petición.

Incremento de Contador (NO Idempotente): POST /api/counter/increment NO es idempotente. Cada petición aumenta el contador en 1. Enviarlo 5 veces hace que el contador sea 5, no 1. Por esto POST generalmente no es idempotente - crea o modifica estado de formas que se acumulan.

Transferencia Bancaria (NO Idempotente): POST /api/transfers { "from": "acct1", "to": "acct2", "amount": 100 } NO es idempotente. Reintentar esta petición transferiría $100 nuevamente. Los bancos resuelven esto con tokens de idempotencia o IDs de transacción para hacer las transferencias reintentables de manera segura.

Analogía

El Interruptor de Luz: Accionar un interruptor de luz a “encendido” es idempotente. Ya sea que lo acciones una vez o diez veces, la luz está encendida. El estado después del primer accionamiento es el mismo que después de accionamientos subsecuentes. Accionarlo a “apagado” también es idempotente. Pero presionar un timbre NO es idempotente - cada presión hace sonar el timbre nuevamente.

Configurar un Termostato: Configurar tu termostato a 72°F es idempotente. Hacerlo una vez o 100 veces resulta en la misma configuración de temperatura. Pero presionar “aumentar temperatura en 1 grado” NO es idempotente - cada presión agrega otro grado.

Ingresar un Código Postal: Cuando compras algo en línea, ingresar tu código postal “10001” múltiples veces no crea múltiples direcciones ni cambia el resultado - es idempotente. Pero hacer clic en “Agregar al Carrito” NO es idempotente - cada clic agrega otro ítem.

Reemplazar un Archivo: Guardar un documento con “Guardar Como” al mismo nombre de archivo es idempotente. El archivo es reemplazado con el mismo contenido. Pero usar “Insertar Página Duplicada” NO es idempotente - cada clic duplica la página nuevamente.

Freno de Mano: Accionar el freno de mano de tu auto es idempotente. Jalarlo una vez o cinco veces resulta en el mismo estado - el freno está accionado. Pero tocar el claxon NO es idempotente - cada presión toca nuevamente.

Ejemplo de Código

// ===== MÉTODOS HTTP IDEMPOTENTES =====

// GET - Idempotente (solo lectura, sin efectos secundarios)
// Múltiples llamadas devuelven los mismos datos, sin cambios de estado
fetch('/api/users/123', { method: 'GET' });
fetch('/api/users/123', { method: 'GET' }); // Mismo resultado
fetch('/api/users/123', { method: 'GET' }); // Mismo resultado

// PUT - Idempotente (reemplaza recurso)
// Múltiples PUTs idénticos resultan en el mismo estado final
await fetch('/api/products/456', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Widget',
    price: 9.99,
    stock: 100
  })
});
// Reintentar con mismos datos - el recurso termina en estado idéntico
await fetch('/api/products/456', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Widget',
    price: 9.99,
    stock: 100
  })
}); // Producto tiene los mismos valores - idempotente

// DELETE - Idempotente (elimina recurso)
await fetch('/api/users/123', { method: 'DELETE' }); // 204 No Content
await fetch('/api/users/123', { method: 'DELETE' }); // 404 Not Found (ya eliminado)
// El resultado es el mismo: el usuario 123 no existe

// ===== MÉTODO NO IDEMPOTENTE: POST =====
// Cada POST crea un nuevo recurso
const response1 = await fetch('/api/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ product_id: 456, quantity: 1 })
});
// Devuelve: { "id": 789, "product_id": 456, "quantity": 1 }

const response2 = await fetch('/api/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ product_id: 456, quantity: 1 })
});
// Devuelve: { "id": 790, "product_id": 456, "quantity": 1 }
// ¡ID diferente! NO idempotente - creó dos pedidos

// ===== HACER POST IDEMPOTENTE CON CLAVES DE IDEMPOTENCIA =====
// Patrón de clave de idempotencia estilo Stripe
const idempotencyKey = crypto.randomUUID();

async function createChargeWithRetry() {
  const maxRetries = 3;
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch('/api/charges', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey // Misma clave en reintentos
        },
        body: JSON.stringify({
          amount: 5000,
          currency: 'usd',
          customer: 'cus_123'
        })
      });

      if (response.ok) {
        return await response.json();
      }
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      // Reintentar con misma clave de idempotencia
      await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
    }
  }
}

// Implementación de clave de idempotencia del lado del servidor
const idempotencyCache = new Map();

app.post('/api/charges', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];

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

  // Verificar si ya procesamos esta petición
  if (idempotencyCache.has(idempotencyKey)) {
    const cachedResponse = idempotencyCache.get(idempotencyKey);
    return res.status(200).json(cachedResponse);
  }

  try {
    // Procesar el cargo
    const charge = await processCharge(req.body);

    // Almacenar en caché la respuesta para futuros reintentos
    idempotencyCache.set(idempotencyKey, charge);

    // Configurar expiración de caché (ej. 24 horas)
    setTimeout(() => idempotencyCache.delete(idempotencyKey), 86400000);

    res.status(201).json(charge);
  } catch (error) {
    res.status(500).json({ error: 'Falló el procesamiento del pago' });
  }
});

// ===== PATCH PUEDE SER IDEMPOTENTE (si se diseña cuidadosamente) =====
// PATCH idempotente: Establece valores absolutos
await fetch('/api/users/123', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: '[email protected]' // Valor absoluto
  })
}); // El email del usuario se convierte en '[email protected]'

await fetch('/api/users/123', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: '[email protected]'
  })
}); // El email del usuario sigue siendo '[email protected]' - IDEMPOTENTE

// PATCH no idempotente: Cambios incrementales
await fetch('/api/products/456', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    stock: { $inc: -1 } // Decrementar en 1
  })
}); // El stock disminuye en 1

await fetch('/api/products/456', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    stock: { $inc: -1 }
  })
}); // El stock disminuye en 1 NUEVAMENTE - NO idempotente

Diagrama

graph TB
    subgraph "Métodos Idempotentes"
        GET[GET /api/users/123]
        PUT[PUT /api/users/123]
        DELETE[DELETE /api/users/123]

        GET -->|Petición 1| R1[Devuelve datos del usuario]
        GET -->|Petición 2| R2[Devuelve mismos datos]
        GET -->|Petición 3| R3[Devuelve mismos datos]

        PUT -->|Petición 1| P1[Actualiza usuario]
        PUT -->|Petición 2| P2[Usuario en mismo estado]
        PUT -->|Petición 3| P3[Usuario en mismo estado]

        DELETE -->|Petición 1| D1[Elimina usuario, 204]
        DELETE -->|Petición 2| D2[Ya eliminado, 404]
        DELETE -->|Petición 3| D3[Ya eliminado, 404]
    end

    subgraph "Método No Idempotente: POST"
        POST[POST /api/orders]

        POST -->|Petición 1| O1[Crea pedido #789]
        POST -->|Petición 2| O2[Crea pedido #790]
        POST -->|Petición 3| O3[Crea pedido #791]
    end

    subgraph "POST con Clave de Idempotencia"
        POSTI[POST /api/charges
Idempotency-Key: abc123] POSTI -->|Petición 1| C1[Crea cargo #1000] POSTI -->|Petición 2
Misma Clave| C2[Devuelve cargo #1000
desde caché] POSTI -->|Petición 3
Misma Clave| C3[Devuelve cargo #1000
desde caché] end style GET fill:#90EE90 style PUT fill:#90EE90 style DELETE fill:#90EE90 style POST fill:#FFB6C1 style POSTI fill:#FFD700

Notas de Seguridad

SECURITY NOTES

CRÍTICO - …

Configuración y Validación:

  • Implementar claves de idempotencia para todas las transacciones financieras y operaciones que cambien estado para prevenir cargos duplicados, transferencias o corrupción de datos.
  • Usar valores criptográficamente seguros aleatorios (UUIDs) para claves de idempotencia.
  • Almacenar mapeos de claves de idempotencia del lado del servidor con expiración (24-72 horas) para prevenir ataques de replay más allá de una ventana razonable.
  • Validar que las claves de idempotencia sean únicas por cliente o usuario para prevenir que el reintento de un usuario afecte la transacción de otro usuario.
  • Implementar limitación de tasa en la creación de claves de idempotencia para prevenir abuso.
  • No permitir a los clientes reusar claves de idempotencia entre diferentes operaciones - vincular claves a tipos de operación específicos.

Monitoreo y Protección:

  • Registrar todos los intentos de reintento con claves de idempotencia para rastros de auditoría.
  • Manejar colisiones de claves de idempotencia elegantemente con mensajes de error apropiados.
  • No exponer estado interno o detalles de error en respuestas de idempotencia.
  • Para sistemas distribuidos usar bloqueos distribuidos o restricciones únicas en bases de datos para prevenir condiciones de carrera.
  • Considerar estrategias de invalidación de caché para prevenir respuestas de idempotencia obsoletas.
  • Implementar timeouts y limpieza para claves de idempotencia abandonadas.
  • Nunca confiar en IDs de transacción proporcionados por el cliente sin validación del lado del servidor.

Mejores Prácticas

  1. Diseñar PUT y DELETE para ser idempotentes: Seguir estrictamente la semántica HTTP
  2. Implementar claves de idempotencia para POST: Usar cabeceras como Idempotency-Key para operaciones de creación
  3. Usar UUIDs para claves de idempotencia: Identificadores generados por el cliente, criptográficamente aleatorios
  4. Almacenar en caché respuestas de idempotencia: Guardar resultados por 24-72 horas para manejar reintentos
  5. Devolver mismo código de estado en reintentos: 201 en primera petición, 200 o 201 en peticiones subsecuentes con misma clave
  6. Configurar expiración en respuestas almacenadas: Limpiar mapeos de idempotencia antiguos para ahorrar almacenamiento
  7. Documentar comportamiento de reintento: Declarar claramente qué endpoints son idempotentes en la documentación de la API
  8. Usar ETags para actualizaciones condicionales: Prevenir sobrescribir datos más nuevos con actualizaciones obsoletas
  9. Implementar lógica de reintento con backoff exponencial: Estrategias de reintento del lado del cliente para fallos transitorios
  10. Probar escenarios de reintento: Validar que los reintentos no causen recursos duplicados o corrupción de estado

Errores Comunes

Tratar POST como idempotente: Reintentar peticiones POST sin claves de idempotencia puede crear recursos duplicados.

Usar PATCH no idempotente: Actualizaciones incrementales como {"quantity": {"$inc": 1}} no son seguras de reintentar.

No almacenar en caché respuestas de idempotencia: Fuerza el reprocesamiento de peticiones duplicadas, desperdiciando recursos.

Expiración de caché corta: Expirar claves de idempotencia demasiado rápido (ej. 5 minutos) previene reintentos seguros para clientes lentos.

Ignorar DELETE 404 como error: Un 404 en reintento de DELETE no significa fallo - el recurso está eliminado como se pretendía.

IDs generados por cliente sin validación: Confiar en IDs de cliente para idempotencia sin verificaciones de unicidad del lado del servidor.

Reusar claves de idempotencia: Usar la misma clave para diferentes operaciones o usuarios causa colisiones.

Sin lógica de reintento: Clientes renunciando después del primer fallo en lugar de reintentar operaciones idempotentes.

Asumir que GET siempre es seguro: Peticiones GET que desencadenan efectos secundarios (como /api/users/123/send-email) violan la idempotencia.

No manejar timeouts: El timeout no significa fallo - reintentar con misma clave de idempotencia para verificar resultado.

Estándares y RFCs

Términos Relacionados