Idempotency keys para POST repetibles

Avanzado
Django
Django
Actualizado: 19/04/2026

El problema de los POST repetidos

Imagina una app de banca movil que envia POST /api/transferencias/ con { destino, importe }. La conexion 4G se cae justo despues de enviar el body, antes de que llegue la respuesta. La app no sabe si la transferencia se proceso o no. Reintentar es arriesgado (puede cobrar dos veces) y no reintentar es arriesgado (puede no procesar nunca).

Esta situacion ocurre todos los dias en:

  • Pagos (cobrar dos veces es ilegal en muchas jurisdicciones).
  • Pedidos (duplicar un pedido = duplicar logistica + clientes furiosos).
  • Emails / SMS (enviar dos veces el mismo OTP = soporte abrumado).
  • Reservas (reservar dos asientos para una persona).

La solucion estandar es la cabecera HTTP Idempotency-Key: el cliente la genera (UUID v4), la envia con cada POST critico y el servidor la usa para detectar reintentos. Es lo que hacen Stripe, AWS, GitHub, Square, PayPal...

El contrato

POST /api/transferencias/ HTTP/1.1
Idempotency-Key: 7c5e8c4a-2f9b-4f4d-8b9b-1e3d5a7c8e0f
Content-Type: application/json

{ "destino": "ES12...", "importe": "120.00" }
  • Si es la primera vez que el servidor ve esa key: procesa la peticion normalmente y guarda la respuesta indexada por la key.
  • Si es un reintento (misma key, misma ruta): devuelve la respuesta original sin volver a ejecutar la logica.
  • Si es la misma key pero distinto body: error 409 Conflict (intento de abuso).
  • Si la operacion sigue en curso: error 409 Conflict con Retry-After.

Modelo de almacenamiento

# idempotency/models.py
import uuid
from django.db import models

class IdempotencyKey(models.Model):
    key            = models.CharField(max_length=64, primary_key=True)
    user_id        = models.PositiveIntegerField(null=True)  # acotar por usuario
    method         = models.CharField(max_length=8)
    path           = models.CharField(max_length=255)
    body_hash      = models.CharField(max_length=64)         # sha256 hex
    status_code    = models.PositiveSmallIntegerField()
    response_body  = models.JSONField()
    created_at     = models.DateTimeField(auto_now_add=True)

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

Para volumenes altos puedes usar Redis (con TTL nativo) en vez de Postgres. Aqui usamos modelo Django para mantener el ejemplo simple y aprovechar transacciones.

Mixin para DRF ViewSets

# idempotency/mixins.py
import hashlib, json
from django.db import transaction
from rest_framework.response import Response
from rest_framework import status as st
from idempotency.models import IdempotencyKey

class IdempotencyMixin:
    """Solo se aplica a POST. Usa la cabecera Idempotency-Key."""

    idempotency_actions = {"create"}  # otros: 'pagar', 'reembolsar'

    def initial(self, request, *args, **kwargs):
        super().initial(request, *args, **kwargs)
        self._idempotency_key = request.headers.get("Idempotency-Key")

    def create(self, request, *args, **kwargs):
        if not self._idempotency_key:
            # Compatibilidad: sin cabecera, comportamiento normal
            return super().create(request, *args, **kwargs)
        return self._idempotent(super().create, request, *args, **kwargs)

    def _idempotent(self, fn, request, *args, **kwargs):
        body_hash = hashlib.sha256(request.body).hexdigest()
        user_id   = request.user.id if request.user.is_authenticated else None

        with transaction.atomic():
            existing = (IdempotencyKey.objects
                        .select_for_update()
                        .filter(key=self._idempotency_key, user_id=user_id)
                        .first())

            if existing:
                # Mismo body? -> devolver cacheada
                if existing.body_hash != body_hash:
                    return Response(
                        {"error": "idempotency key reused with different body"},
                        status=st.HTTP_409_CONFLICT,
                    )
                return Response(existing.response_body, status=existing.status_code)

            # Primera vez: procesar y persistir
            response = fn(request, *args, **kwargs)
            IdempotencyKey.objects.create(
                key           = self._idempotency_key,
                user_id       = user_id,
                method        = request.method,
                path          = request.path,
                body_hash     = body_hash,
                status_code   = response.status_code,
                response_body = response.data,
            )
            return response

Uso:

# transferencias/views.py
from rest_framework.viewsets import ModelViewSet
from idempotency.mixins import IdempotencyMixin

class TransferenciaViewSet(IdempotencyMixin, ModelViewSet):
    queryset = Transferencia.objects.all()
    serializer_class = TransferenciaSerializer

Razon del select_for_update

Sin el lock, dos reintentos casi simultaneos pueden ambos pasar el filter().first(), no encontrar nada y crear ambos la transferencia. Race condition clasica.

select_for_update() dentro de transaction.atomic() toma un row lock; el segundo proceso espera a que el primero confirme (commit) y entonces ve la fila ya creada. Es Postgres haciendo el trabajo de serializacion por ti.

Limpieza periodica

Un job nocturno borra keys antiguas para no llenar la BBDD.

# idempotency/tasks.py
from celery import shared_task
from datetime import timedelta
from django.utils import timezone
from idempotency.models import IdempotencyKey

@shared_task
def limpiar_idempotency_keys_viejas():
    limite = timezone.now() - timedelta(days=1)
    IdempotencyKey.objects.filter(created_at__lt=limite).delete()

Stripe usa 24 horas de TTL; AWS S3 usa hasta 7 dias segun la API. La eleccion depende del trade-off entre coste de almacenamiento y la duracion maxima razonable de reintentos del cliente.

Diagrama del flujo

sequenceDiagram
    participant C as Cliente
    participant API as DRF
    participant DB as Postgres
    C->>API: POST /transferencias (Idempotency-Key: K)
    API->>DB: SELECT FOR UPDATE WHERE key=K
    alt no existe
        DB-->>API: vacio
        API->>API: ejecutar transferencia
        API->>DB: INSERT key=K + response
        API-->>C: 201 Created
    else ya existe (reintento)
        DB-->>API: row existente
        API->>API: comprobar body_hash
        API-->>C: 201 Created (response cacheada)
    end

Documentar en el schema OpenAPI

drf-spectacular permite anadir cabeceras esperadas a operaciones especificas:

from drf_spectacular.utils import extend_schema, OpenApiParameter

@extend_schema(parameters=[
    OpenApiParameter(
        name        = "Idempotency-Key",
        location    = OpenApiParameter.HEADER,
        type        = str,
        required    = True,
        description = "UUID v4 generado por el cliente. Reintenta sin duplicar.",
    )
])
def create(self, request, *args, **kwargs):
    return super().create(request, *args, **kwargs)

Errores comunes

  • Indexar por path con la key. Si dos endpoints distintos comparten key por accidente, choca. Recomendado: indexar (user_id, key) y descartar mismatches con path.
  • No comparar el body. Permite que un atacante envie la misma key con un body modificado pensando que el servidor lo va a aceptar. Compara siempre body_hash.
  • Confiar en la key del cliente para autorizar. La key NO autentica. Sigue exigiendo JWT/auth.
  • TTL infinito. Tabla crece sin parar y el lock se vuelve caro. Borrar tras 24-72h cubre todos los casos realistas.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Django es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Django

Explora más contenido relacionado con Django y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Entender por que los POST no son idempotentes y los problemas que causa. Disenar la cabecera Idempotency-Key (UUID v4 generado por el cliente). Implementar un middleware o mixin que registre la key en BBDD/Redis con la respuesta. Replicar la respuesta original ante reintentos en una ventana de 24h. Validar que el body coincide para evitar abuso. Usar select_for_update para evitar race conditions.

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje