Module 3 · API design

Lesson 14 — Idempotency and versioning

Backwards compatibility and the Idempotency-Key that avoids charging twice.

Published
In this lesson
  1. Exercise 1 — The double charge
  2. Exercise 2 — The real key
  3. Exercise 3 — Idempotent by design
  4. Exercise 4 — Webhook
  5. Exercise 5 — Versioning
  6. Professor's summary

Exercise 1 — The double charge

  1. If you generate idempotency_key with uuid4() 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).
  2. 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

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 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 wrapped

Fine 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

  1. Cancelling twice: the first 204; the second cancel() returns False → also 204 (the state is already the requested one). Idempotent and honest.
  2. 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

  1. 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

  1. path("api/v1/", include(...)) and api/v2/ with the v2 serializer (starts_at with tz). They coexist: old clients untouched.
  2. 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.