Los dos artículos anteriores trataron la idempotencia del lado evento: el patrón Outbox garantiza que un mensaje se publica al menos una vez, y el patrón Inbox garantiza que solo se consume una vez. Queda un tercer lugar donde aparece el mismo problema, más arriba: la propia API HTTP.

Cuando un cliente lanza un POST /api/payments y su conexión se cae antes de recibir la respuesta, no sabe si el pago se creó o no. Si reintenta, arriesga pagar dos veces. Si no reintenta, arriesga no pagar nada. El patrón Idempotency Key, popularizado por Stripe y adoptado desde por la mayoría de APIs de pago, resuelve ese dilema poniendo el control del retry en manos del cliente.

Es el cuarto artículo de la serie sobre patrones de arquitectura distribuida, y la contraparte del lado API pública de los patrones Outbox e Inbox.

El problema: un POST no es reproducible

Un endpoint típico:

@api_view(["POST"])
def crear_pago(request):
    pago = Pago.objects.create(
        monto=request.data["monto"],
        cliente_id=request.user.id,
    )
    stripe.Charge.create(amount=pago.monto, ...)
    return Response(PagoSerializer(pago).data, status=201)

Cuatro escenarios hacen que un cliente envíe la misma petición dos veces:

  1. La petición tuvo éxito en el servidor, pero la red se cortó antes de que llegara la respuesta. El cliente reintenta, convencido de que no pasó nada.
  2. El usuario hace doble clic en “Pagar”. Dos peticiones salen simultáneamente.
  3. Un móvil en zona inestable reintenta automáticamente las peticiones que no recibieron respuesta en 30 segundos.
  4. Un load balancer o un proxy frontal reintenta una petición que considera con timeout.

En todos esos casos, llegan dos peticiones idénticas al servidor. Sin protección, se crean dos pagos, se emiten dos charges de Stripe, el cliente es cobrado dos veces. El patrón Outbox del lado servidor no protege contra esto: el problema está más arriba, antes incluso de que el evento se escriba.

El principio: el cliente proporciona la clave

La idea central invierte la responsabilidad: no es trabajo del servidor adivinar si una petición es un duplicado, es trabajo del cliente señalarlo. Antes de enviar una petición, el cliente genera un UUID y lo pasa como header:

POST /api/payments
Idempotency-Key: 7f3a1b9e-2c4d-4e8f-9a1b-3c5d7e9f1a2b
Content-Type: application/json

{"monto": 4990, "divisa": "EUR"}

Si la petición falla (timeout, error de red, caída del servidor), el cliente reintenta con la misma clave. El servidor reconoce la clave, ve que ya ha procesado esa petición, y devuelve la respuesta almacenada sin re-ejecutar la operación.

Si el cliente crashea antes de anotar la clave que usó, tendrá que generar una nueva en el siguiente intento, y la petición se tratará como nueva. Es un compromiso: protección contra duplicados de red, no contra pérdidas totales de estado del lado cliente. La mayoría de SDKs oficiales (Stripe, Square, AWS) gestionan esa persistencia por sus usuarios.

El almacenamiento del lado servidor

Un esquema mínimo:

from django.db import models


class IdempotencyKey(models.Model):
    key = models.CharField(max_length=128, primary_key=True)
    user_id = models.BigIntegerField()
    request_hash = models.CharField(max_length=64)
    response_status = models.IntegerField(null=True)
    response_body = models.JSONField(null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    completed_at = models.DateTimeField(null=True)

    class Meta:
        indexes = [
            models.Index(fields=["created_at"]),
        ]

Algunas elecciones estructurantes.

La clave primaria es la combinación de la clave e implícitamente del usuario (vía user_id). Dos clientes distintos que por casualidad eligieran el mismo UUID quedan aislados. En la práctica, preferimos almacenar la clave como f"{user_id}:{key}" para hacer el aislamiento explícito y aprovechar un único índice PK.

request_hash es un SHA-256 del cuerpo de la petición. Sirve para detectar un caso peligroso: un cliente que reutiliza una clave para una petición diferente. Si el hash no coincide con el almacenado, devolvemos un 422 Unprocessable Entity en lugar de la respuesta anterior.

response_status y response_body almacenan lo que devolvimos en la primera llamada. En el replay, los reproducimos tal cual.

completed_at distingue tres estados: NULL = en procesamiento, no NULL = completado. Esa distinción es crucial para gestionar peticiones concurrentes.

El middleware de idempotencia

El flujo completo se ve así:

import hashlib, json
from django.db import IntegrityError, transaction
from rest_framework.response import Response


def idempotency_middleware(view_func):
    def wrapper(request, *args, **kwargs):
        key = request.headers.get("Idempotency-Key")
        if not key or request.method != "POST":
            return view_func(request, *args, **kwargs)

        body_hash = hashlib.sha256(request.body).hexdigest()
        composite_key = f"{request.user.id}:{key}"

        try:
            with transaction.atomic():
                IdempotencyKey.objects.create(
                    key=composite_key,
                    user_id=request.user.id,
                    request_hash=body_hash,
                )
        except IntegrityError:
            existing = IdempotencyKey.objects.get(key=composite_key)
            if existing.request_hash != body_hash:
                return Response(
                    {"error": "Idempotency-Key reused with different body"},
                    status=422,
                )
            if existing.completed_at is None:
                return Response(
                    {"error": "Request already in flight"},
                    status=409,
                )
            return Response(existing.response_body, status=existing.response_status)

        response = view_func(request, *args, **kwargs)

        IdempotencyKey.objects.filter(key=composite_key).update(
            response_status=response.status_code,
            response_body=response.data,
            completed_at=timezone.now(),
        )
        return response
    return wrapper

El patrón descansa enteramente sobre la restricción de unicidad de la PK, exactamente como el patrón Inbox: dos peticiones simultáneas con la misma clave provocan un IntegrityError en la segunda, lo que elimina cualquier condición de carrera entre SELECT e INSERT. La base es la árbitra, no el código aplicativo.

El caso del retry concurrente

El 409 Conflict cuando completed_at IS NULL merece una explicación. Imagina: un cliente lanza una petición con la clave K, que tarda 5 segundos en procesarse (llamada a Stripe, generación de PDF). Durante esos 5 segundos, la red se corta, el cliente reintenta con la misma clave K.

Sin la rama completed_at IS NULL, el servidor encontraría la fila de idempotencia existente, no tendría respuesta que reproducir (ya que la primera petición no terminó), y probablemente devolvería un estado incoherente. El 409 le dice al cliente: “tu primera petición sigue en curso, espera y reintenta en unos segundos”.

Es el equivalente HTTP del select_for_update(skip_locked=True) que vimos en el relay Outbox: evitamos que dos peticiones concurrentes hagan el mismo trabajo en paralelo.

La trampa del body modificado

El request_hash no es una formalidad. Sin él, un bug del cliente que reutilizara la misma clave para una petición diferente vería siempre la primera respuesta y nunca sabría que su segunda petición no se procesó.

El escenario típico: un script que bucla sobre 500 pagos con una clave fija por error. Sin hash, los 499 siguientes devuelven silenciosamente la respuesta del primero. Ningún pago creado. El error pasa desapercibido hasta la facturación mensual.

Con el hash, la segunda llamada recibe un 422 inmediato. El bug sale a la superficie, el script falla, el operador puede intervenir antes de que el daño se propague.

TTL y retención

Una clave de idempotencia no está pensada para vivir eternamente. La duración de vida típica es 24 horas, a veces 7 días para operaciones financieras pesadas. Más allá, consideramos que el cliente ha abandonado el retry, y liberamos la clave.

La purga se hace vía un job Celery periódico:

@shared_task
def purge_idempotency_keys():
    cutoff = timezone.now() - timedelta(hours=24)
    IdempotencyKey.objects.filter(created_at__lt=cutoff).delete()

El índice sobre created_at mantiene esa consulta rápida. En PostgreSQL, podemos ir más lejos con un particionado por día si el volumen justifica la complejidad.

Idempotency Keys vs Inbox: dos capas, misma idea

Ambos patrones descansan sobre la misma primitiva (una restricción de unicidad más una transacción atómica), pero se aplican a capas diferentes.

La Idempotency Key protege la frontera HTTP: entre cliente y API. La clave la genera el cliente, se transporta en un header, y la almacena el servidor junto con la respuesta.

La Inbox protege la frontera broker: entre Kafka y el consumidor. El identificador lo genera el productor, se transporta en el payload del evento, y lo almacena el consumidor sin respuesta que reproducir (los eventos no tienen respuesta).

Un sistema distribuido moderno combina a menudo ambos. La API pública expone un endpoint con Idempotency-Key que crea un pedido y publica un evento vía el Outbox. Los consumidores aguas abajo usan el Inbox para no procesar dos veces el mismo evento. Cada frontera tiene su propia protección, y todo el sistema se vuelve resistente a los duplicados en cada capa.

Cuándo no usar Idempotency Key

HTTP ya define algunos métodos como idempotentes. GET, PUT, DELETE deben producir el mismo estado al repetirse: un DELETE /resource/1 repetido mil veces debe acabar con el recurso ausente. Añadir una clave de idempotencia en esos endpoints es redundante.

El patrón es realmente útil en:

  • los POST que crean un recurso con efecto de negocio (pago, pedido, transferencia, envío)
  • los POST que disparan una acción externa costosa (envío de email, generación de PDF, llamada a partner)
  • los endpoints públicos expuestos a clientes cuya lógica de retry no controlamos

A la inversa, para un endpoint interno entre microservicios donde el control de reintentos es compartido, o para una API de solo lectura, el coste de implementación y de almacenamiento no se justifica.

Conclusión

La Idempotency Key es el patrón menos glamuroso de los tres, pero probablemente el más visible cuando falta. Un cliente que paga dos veces no perdona un retry mal gestionado. Un cliente que ve 409 Conflict o 422 Idempotency-Key reused entiende que algo pasó y puede reaccionar.

Es también el patrón que cierra la serie. Con Saga, Outbox, Inbox e Idempotency Keys, hemos cubierto las cuatro fronteras donde un sistema distribuido puede perder coherencia: entre etapas de un workflow, entre una base y un broker, entre un broker y un consumidor, y entre un cliente HTTP y el servidor. Cada uno protege un lugar concreto. Ninguno basta por sí solo.