Idempotency Keys

7 min read

Un cliente está en el checkout de tu tienda. Hace clic en “Pagar”.

La conexión tarda un poco, no ve respuesta inmediata, y hace clic de nuevo. O peor: su app reintenta automáticamente tras un timeout, sin que el usuario haga nada.

Resultado: dos cargos idénticos en su tarjeta por la misma orden.

Ya mencionamos la idempotencia de pasada en los posts de Saga, Outbox y Circuit Breaker — siempre como un requisito que “los servicios deben cumplir”.

Este post le da a la idempotencia su propio protagonismo, con una implementación completa usando Idempotency Keys (Claves de Idempotencia), el mecanismo estándar usado por APIs como Stripe y PayPal para evitar exactamente este problema.


¿Qué es una operación idempotente?

Una operación es idempotente si ejecutarla una vez o ejecutarla diez veces produce el mismo resultado final. PUT /users/5 {"name": "Carlos"} es idempotente: no importa cuántas veces lo ejecutes, el usuario 5 termina llamándose Carlos. POST /payments {"amount": 100} no es idempotente por naturaleza: cada llamada crea un nuevo cobro.

Las Idempotency Keys convierten operaciones naturalmente no idempotentes (como crear un pago) en operaciones seguras de reintentar.


¿Cómo funciona?

El cliente genera un identificador único (típicamente un UUID) antes de hacer la solicitud, y lo envía en un header, por ejemplo Idempotency-Key: abc123. El servidor:

  1. Verifica si ya procesó una solicitud con esa misma clave.
  2. Si es la primera vez, procesa la operación normalmente y guarda el resultado asociado a la clave.
  3. Si la clave ya existe, no vuelve a ejecutar la operación — retorna el resultado guardado de la primera ejecución.

La clave la genera el cliente, no el servidor, precisamente porque el cliente es quien necesita poder reintentar con la garantía de que un mismo intento lógico siempre use la misma clave.


El diagrama


Guía paso a paso

Paso 1 — Definir qué operaciones necesitan idempotencia
Cualquier operación de escritura que tenga efectos secundarios reales (cobrar, enviar un correo, crear un recurso) y que pueda ser reintentada por el cliente o por tu propia infraestructura (Retry, timeouts).

Paso 2 — Exigir la clave en el request
El cliente debe generar un UUID por cada intento lógico de operación (no por cada intento de red — el mismo UUID se reutiliza en los reintentos de la misma operación).

Paso 3 — Guardar el resultado asociado a la clave
Necesitás una tabla o store con la clave, el estado (procesando / completado / fallido) y el resultado de la operación.

Paso 4 — Manejar la concurrencia
¿Qué pasa si dos requests con la misma clave llegan al mismo tiempo? Necesitás un lock o una restricción de unicidad a nivel de base de datos para que solo uno gane la carrera.

Paso 5 — Definir un TTL para las claves
Las claves no necesitan vivir para siempre. Un TTL de 24 horas es común — suficiente para cubrir reintentos razonables, sin acumular datos indefinidamente.


El código

Implementamos un servicio de pagos con Idempotency Keys en Java, incluyendo el manejo de la carrera entre solicitudes concurrentes con la misma clave.


import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

// --- Resultado guardado por cada clave de idempotencia ---

@Getter
class IdempotentResult {
    private final String transactionId;
    private final String status;

    public IdempotentResult(String transactionId, String status) {
        this.transactionId = transactionId;
        this.status = status;
    }
}

// --- Store de idempotencia (en producción sería Redis o una tabla dedicada) ---

class IdempotencyStore {
    private final ConcurrentHashMap<String, IdempotentResult> results = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();

    public IdempotentResult get(String key) {
        return results.get(key);
    }

    public void save(String key, IdempotentResult result) {
        results.put(key, result);
    }

    public ReentrantLock getLock(String key) {
        return locks.computeIfAbsent(key, k -> new ReentrantLock());
    }
}

// --- Simulación del procesador de pagos real ---

class PaymentGateway {
    private int callCount = 0;

    public String charge(String orderId, double amount) {
        callCount++;
        String transactionId = "TXN-" + orderId + "-" + callCount;
        System.out.println("  [Gateway] Cobrando $" + amount + " → " + transactionId);
        return transactionId;
    }

    public int getCallCount() {
        return callCount;
    }
}

// --- Servicio de pagos con protección de idempotencia ---

@AllArgsConstructor
class PaymentService {
    private final PaymentGateway gateway;
    private final IdempotencyStore idempotencyStore;

    public IdempotentResult processPayment(String idempotencyKey, String orderId, double amount) {
        // 1. Verificar si esta clave ya fue procesada
        IdempotentResult existing = idempotencyStore.get(idempotencyKey);
        if (existing != null) {
            System.out.println("[Idempotency] Clave " + idempotencyKey + " ya procesada. Retornando resultado guardado.");
            return existing;
        }

        // 2. Proteger contra solicitudes concurrentes con la misma clave
        ReentrantLock lock = idempotencyStore.getLock(idempotencyKey);
        lock.lock();
        try {
            // Doble verificación: otro thread pudo haber terminado mientras esperábamos el lock
            existing = idempotencyStore.get(idempotencyKey);
            if (existing != null) {
                System.out.println("[Idempotency] Clave " + idempotencyKey + " procesada mientras esperábamos el lock.");
                return existing;
            }

            // 3. Primera vez: procesar de verdad
            String transactionId = gateway.charge(orderId, amount);
            IdempotentResult result = new IdempotentResult(transactionId, "COMPLETED");
            idempotencyStore.save(idempotencyKey, result);
            return result;

        } finally {
            lock.unlock();
        }
    }
}

// --- Main ---

public class IdempotencyKeyDemo {

    public static void main(String[] args) {
        PaymentGateway gateway = new PaymentGateway();
        IdempotencyStore store = new IdempotencyStore();
        PaymentService paymentService = new PaymentService(gateway, store);

        String idempotencyKey = "idem-key-f47ac10b"; // generado por el cliente, una vez por intento lógico

        System.out.println("=== Primer clic en 'Pagar' ===");
        IdempotentResult firstResult = paymentService.processPayment(idempotencyKey, "ORD-900", 149.99);
        System.out.println("Resultado: " + firstResult.getTransactionId() + " (" + firstResult.getStatus() + ")");

        System.out.println("\n=== Doble clic accidental / retry automático con la MISMA clave ===");
        IdempotentResult secondResult = paymentService.processPayment(idempotencyKey, "ORD-900", 149.99);
        System.out.println("Resultado: " + secondResult.getTransactionId() + " (" + secondResult.getStatus() + ")");

        System.out.println("\n=== Verificación: el gateway solo fue llamado una vez ===");
        System.out.println("Llamadas reales al gateway de pagos: " + gateway.getCallCount());
    }
}

Output esperado:


=== Primer clic en 'Pagar' ===
  [Gateway] Cobrando $149.99 → TXN-ORD-900-1
Resultado: TXN-ORD-900-1 (COMPLETED)

=== Doble clic accidental / retry automático con la MISMA clave ===
[Idempotency] Clave idem-key-f47ac10b ya procesada. Retornando resultado guardado.
Resultado: TXN-ORD-900-1 (COMPLETED)

=== Verificación: el gateway solo fue llamado una vez ===
Llamadas reales al gateway de pagos: 1

El segundo intento retorna exactamente el mismo transactionId que el primero, sin generar un segundo cobro. El gateway de pagos real solo fue invocado una vez, aunque el servicio recibió dos solicitudes.


Para tener en cuenta en producción

  • Usá una restricción de unicidad en la base de datos, no solo un lock en memoria: el ejemplo usa ReentrantLock, válido solo dentro de una instancia. En producción, con múltiples instancias, necesitás un índice único sobre la columna de idempotency key, que rechace el segundo INSERT concurrente a nivel de base de datos.
  • Cuidado con las claves reutilizadas para payloads distintos: si el cliente reutiliza la misma clave pero con un monto diferente, eso es un error del cliente, no algo que debas resolver silenciosamente. Muchas APIs retornan un error 422 cuando detectan esa inconsistencia.
  • Diferenciá entre “procesando” y “completado”: si una segunda solicitud llega mientras la primera todavía se está procesando (no falló, no terminó), lo correcto es que espere o retorne un estado “en progreso”, no que dispare un segundo procesamiento.
  • Referencia real de la industria: la documentación de Idempotent Requests de Stripe es una excelente referencia de cómo una API de producción implementa exactamente este patrón a gran escala.

Conclusión

La idempotencia no es un detalle de implementación menor — es la diferencia entre un sistema que tolera reintentos con confianza y uno que le cobra dos veces a un cliente por un doble clic.

Ya la mencionamos como requisito en varios patrones anteriores de esta serie; ahora tenés la implementación concreta para cumplir ese requisito de verdad.

Recordá suscribirte aquí para recibir los próximos posts directamente en tu correo.


Referencias:
Stripe — Idempotent Requests: https://stripe.com/docs/api/idempotent_requests
Designing Data-Intensive Applications — Martin Kleppmann

Leave a Reply

Your email address will not be published. Required fields are marked *