Exercise 1 — The double charge
- If you generate
idempotency_keywithuuid4()on every POST, each attempt is "a different intent": UNIQUE doesn't save you (different keys) and you get two payments. The bug is semantic: the key must come from the CLIENT (the intent belongs to the client, not the server). - Today your API would answer 500 (uncaught IntegrityError) or 201 twice depending on the case: both wrong. Correct: 200 with the original response (idempotent) — the retry is normal, not an error.
Exercise 2 — The real key
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 required")
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="The key belongs to another intent")
return Response(saved.body, status=saved.status)
except IdempotencyKey.DoesNotExist:
response = view(request, *args, **kwargs) # process ONCE
IdempotencyKey.objects.create(
pk=key, request_hash=body_hash,
status=response.status_code, body=response.data)
return response
return wrappedFine point: between the not-found GET and the INSERT there is a window — the key's PK (UNIQUE) makes the second commit of the same key fail, and it must re-read and return the stored one (the upsert pattern with on_conflict). 3. Same POST ×2 with the same key: identical response, 1 Payment.
Exercise 3 — Idempotent by design
- Cancelling twice: the first 204; the second
cancel()returns False → also 204 (the state is already the requested one). Idempotent and honest. - Absolute PUT ×2 = same state. PATCH
{"price": "+2"}×2 = +4: relative = not idempotent. Rule: absolute operations in PUT/PATCH if you want safe retries.
Exercise 4 — Webhook
Payment.objects.filter(gateway_event_id=payload["id"]).exists()→ respond 200 without creating. The 4th attempt, the same. 2. Always 200 (or 2xx): if you answer 4xx/5xx to a duplicate, the gateway retries again (its policy) and floods you; a successfully-processed duplicate is 200 and that's it. 5xx only for real failures (your DB down), which are the ones that SHOULD be retried.
Exercise 5 — Versioning
path("api/v1/", include(...))andapi/v2/with the v2 serializer (starts_atwith tz). They coexist: old clients untouched.Deprecation: true+Sunset: <HTTP-date>on v1 responses. Metric: a counter per (version, endpoint) labeled by client_id — when v1 < 1% of traffic for 30 days and no paying customers are unmigrated, it goes away.
Professor's summary
- The client names the intent: Idempotency-Key + body hash + stored response, all inside the effect's transaction.
- Safe verbs (PUT/DELETE/GET) are idempotent by design if you don't hide semantic secrets (+2).
- At-least-once webhooks: duplicate = 200 and silence; 5xx only for real failures.
- Backward compatibility: adding is safe, removing/changing is a version. Deprecation is announced and measured, never improvised.
Next: Lesson 15 — Validation and contracts with OpenAPI.