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 ConflictconRetry-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 conpath. - 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
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