Módulo 3 · Diseño de APIs

Lección 14 — Idempotencia y versionado

Compatibilidad hacia atrás y la Idempotency-Key que evita cobrar dos veces.

Publicada
En esta lección
  1. Ejercicio 1 — El cobro doble
  2. Ejercicio 2 — La clave real
  3. Ejercicio 3 — Idempotentes por diseño
  4. Ejercicio 4 — Webhook
  5. Ejercicio 5 — Versionado
  6. Resumen del profesor

Ejercicio 1 — El cobro doble

  1. Si generas idempotency_key con uuid4() en cada POST, cada intento es "una intención distinta": la UNIQUE no te salva (claves distintas) y tienes dos pagos. El bug es semántico: la clave debe venir del CLIENTE (la intención es del cliente, no del servidor).
  2. Hoy tu API respondería 500 (IntegrityError sin capturar) o 201 dos veces según el caso: ambos mal. Correcto: 200 con la respuesta original (idempotente) — el reintento es normal, no un error.

Ejercicio 2 — La clave real

python
def with_idempotency(view):
    def wrapped(request, *args, **kwargs):
        key = request.headers.get("Idempotency-Key")
        if not key:
            return problem_response(status=400, title="Idempotency-Key requerida")
        body_hash = hashlib.sha256(request.body).hexdigest()
        with transaction.atomic():
            try:
                saved = IdempotencyKey.objects.get(pk=key)
                if saved.request_hash != body_hash:
                    return problem_response(status=422, title="La clave es de otra intención")
                return Response(saved.body, status=saved.status)
            except IdempotencyKey.DoesNotExist:
                response = view(request, *args, **kwargs)   # procesa UNA vez
                IdempotencyKey.objects.create(
                    pk=key, request_hash=body_hash,
                    status=response.status_code, body=response.data)
                return response
    return wrapped

Punto fino: entre el GET-que-no-existe y el INSERT hay ventana — la PK de la clave (UNIQUE) hace que el segundo commit del mismo key falle y deba releer y devolver la guardada (patrón upsert con on_conflict). 3. Mismo POST ×2 con misma key: respuesta idéntica, 1 Payment.

Ejercicio 3 — Idempotentes por diseño

  1. Cancelar dos veces: la primera 204; la segunda cancel() devuelve False → también 204 (el estado ya es el pedido). Idempotente y honesto.
  2. PUT absoluto ×2 = mismo estado. PATCH {"price": "+2"} ×2 = +4: relativo = no idempotente. Regla: operaciones absolutas en PUT/PATCH si quieres reintentos seguros.

Ejercicio 4 — Webhook

  1. Payment.objects.filter(gateway_event_id=payload["id"]).exists() → respond 200 sin crear. El 4º intento igual. 2. Siempre 200 (o 2xx): si respondes 4xx/5xx a un duplicado, la pasarela reintenta de nuevo (su política) y te inunda; un duplicado procesado-con-éxito es 200 y punto. El 5xx solo para fallos reales (tu DB caída), que son los que DEBEN reintentarse.

Ejercicio 5 — Versionado

  1. path("api/v1/", include(...)) y api/v2/ con el serializer v2 (starts_at con tz). Conviven: clientes viejos intactos.
  2. Deprecation: true + Sunset: <fecha HTTP-date> en respuestas v1. Métrica: contador por (versión, endpoint) etiquetado por client_id — cuando v1 < 1% del tráfico por 30 días y no hay clientes de pago sin migrar, se apaga.

Resumen del profesor

  • La intención la nombra el cliente: Idempotency-Key + hash del cuerpo + respuesta guardada, todo en la transacción del efecto.
  • Los verbos seguros (PUT/DELETE/GET) son idempotentes por diseño si no guardas secretos semánticos (+2).
  • Webhooks at-least-once: duplicado = 200 y silencio; 5xx solo para fallos reales.
  • Compatibilidad hacia atrás: añadir es seguro, quitar/cambiar es versión. La deprecación se anuncia y se mide, no se improvisa.

Después: Lección 15 — Validación y contratos con OpenAPI.