Alejandría

Busca en Alejandría

Técnicas

Idempotencia en APIs y consumidores

Haz que repetir una operación produzca el mismo efecto que ejecutarla una vez.

01

En pocas palabras

Técnica que permite reintentar peticiones y reprocesar mensajes sin efectos duplicados, mediante claves de idempotencia, deduplicación y operaciones naturalmente idempotentes.

Qué problema resuelve

Reintentos por timeouts, doble clic o entregas duplicadas del broker que generan cobros, transferencias o registros repetidos.

Cuándo utilizarlo

Cualquier operación con efectos económicos o externos. Ejemplo: un endpoint de pagos que el cliente móvil reintenta al perder cobertura.

02

De la idea a la práctica

Funcionamiento, ejemplos y decisiones para pasar al siguiente nivel.

Idempotencia en HTTP

RFC 9110 define como idempotentes GET, HEAD, OPTIONS, PUT y DELETE; POST y PATCH no lo son por defecto. Que un método lo sea por especificación no lo hace por implementación: un PUT que incrementa un contador rompe la promesa.

Clave de idempotencia

El cliente genera un identificador único por intención de operación (no por intento) y lo envía en cada reintento:

POST /v1/pagos HTTP/1.1
Idempotency-Key: 6f1b2c9e-4c1a-4b7e-9d1e-0a6c2f6a1b10
Content-Type: application/json

{"creditoId":"42","monto":250000,"moneda":"COP"}

El servidor:

  1. Busca la clave. Si no existe, la registra como EN_PROCESO junto con un hash del cuerpo.
  2. Ejecuta la operación y guarda el código y la respuesta.
  3. Ante una repetición con la misma clave y el mismo cuerpo, devuelve la respuesta guardada.
  4. Con la misma clave y un cuerpo distinto, responde 422; si la primera sigue en curso, 409.
CREATE TABLE idempotency_keys (
  key           text PRIMARY KEY,
  request_hash  text NOT NULL,
  status        text NOT NULL,          -- EN_PROCESO | COMPLETADA
  response_code int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now()
);
-- La restricción de unicidad resuelve la carrera entre dos reintentos simultáneos:
INSERT INTO idempotency_keys (key, request_hash, status)
VALUES ($1, $2, 'EN_PROCESO')
ON CONFLICT (key) DO NOTHING;

Registrar la clave y aplicar el efecto en la misma transacción evita estados a medias. Define una retención (Stripe, por ejemplo, conserva las claves al menos 24 horas).

Consumidores de mensajes

Kafka, Pub/Sub o SQS entregan al menos una vez. Opciones para el consumidor:

Estrategia Cómo
Deduplicación por id Tabla processed_messages(message_id) en la transacción del efecto
Upsert natural INSERT … ON CONFLICT (pago_id) DO NOTHING
Versión / secuencia Ignorar eventos con versión menor o igual a la ya aplicada
Operación absoluta SET saldo = 1000 en lugar de saldo = saldo - 50

Antipatrones frecuentes

  • Generar la clave en el servidor: el cliente no puede reutilizarla al reintentar.
  • Guardar la clave en caché sin persistencia: un reinicio vuelve a permitir duplicados.
  • Comprobar «¿existe?» y luego insertar en dos pasos sin unicidad: condición de carrera.
  • Reintentar con nueva clave tras un timeout: equivale a no tener idempotencia.

Idempotencia con Spring Boot

@PostMapping("/v1/pagos")
ResponseEntity<PagoDto> crear(@RequestHeader("Idempotency-Key") @NotBlank String clave,
                              @Valid @RequestBody PagoRequest req) {
    String hash = Hashes.sha256(req);
    return idempotencia.ejecutar(clave, hash, () -> {
        Pago pago = pagos.registrar(req);              // misma transacción que el registro de la clave
        return ResponseEntity.status(201).body(PagoDto.de(pago));
    });
}
@Transactional
public <T> ResponseEntity<T> ejecutar(String clave, String hash, Supplier<ResponseEntity<T>> accion) {
    int insertadas = jdbc.update("""
        INSERT INTO idempotency_keys (key, request_hash, status) VALUES (?, ?, 'EN_PROCESO')
        ON CONFLICT (key) DO NOTHING""", clave, hash);
    if (insertadas == 0) {
        var previa = repo.buscar(clave);
        if (!previa.hash().equals(hash)) throw new ConflictoIdempotencia(422, "Cuerpo distinto para la misma clave");
        if (previa.enProceso()) throw new ConflictoIdempotencia(409, "Petición original en curso");
        return previa.respuestaGuardada();
    }
    var respuesta = accion.get();
    repo.completar(clave, respuesta);
    return respuesta;
}

Si la acción llama a un tercero (pasarela de pago), la transacción local no basta: propaga la misma clave al proveedor si la soporta, o guarda el estado intermedio y reconcilia.

Clientes que reintentan correctamente

async function pagar(datos: Pago) {
  const clave = crypto.randomUUID(); // una por intención de pago, no por intento
  for (let intento = 0; intento < 4; intento++) {
    try {
      const res = await fetch('/v1/pagos', {
        method: 'POST',
        headers: {
          'Idempotency-Key': clave,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(datos),
      });
      if (res.status !== 409 && res.status < 500) return res;
    } catch {
      /* error de red: reintentar con la misma clave */
    }
    await esperar(Math.random() * 2 ** intento * 300); // backoff con jitter
  }
  throw new Error('No se pudo confirmar el pago');
}

En apps móviles, persiste la clave junto al pago pendiente: si la app se cierra y se reabre, debe reintentar con la misma clave.

Probar la idempotencia

Prueba Resultado esperado
Misma petición dos veces seguidas Un solo pago; la segunda devuelve la misma respuesta
Dos peticiones simultáneas con la misma clave Un pago; la otra recibe 409 o la respuesta guardada
Misma clave con importe distinto 422
Fallo tras registrar la clave y antes de completar El reintento no duplica (transacción revertida o estado reconciliado)
Mensaje duplicado en el consumidor Efecto aplicado una sola vez

Criterios de implementación

Exige Idempotency-Key en todos los POST con efectos económicos y documéntalo en el contrato de la API. Propaga la clave hacia proveedores externos que la soporten. Combínalo con reintentos con backoff (patrones de resiliencia) y prueba la concurrencia con dos peticiones simultáneas con la misma clave.

Continúa en las fuentes

Documentación oficial para verificar detalles y explorar más.