Filter concepts by levelShowing all levels.

Django · Section 53

API Idempotency

Level
advanced
Read
24 min
Concepts
2

HTTP already guarantees that `PUT` and `DELETE` are idempotent — that is a property of the method, granted by the specification and free. `POST` is not, which is a problem precisely where it matters: creating a payment, an order, or a call to an external provider. Application-level idempotency is the answer, and it is code you write. The client generates a key per logical operation, sends it as `Idempotency-Key`, and reuses it on every retry; the server records that key with a unique constraint *inside the same transaction as the work*, and on a replay returns the stored response — same status, same body — rather than doing the work again. Letting the constraint refuse the duplicate is the point: `exists()` then `create()` is a check-then-act race that produces double charges only under the retry storms you cannot reproduce locally. The same reasoning covers the other half of the section, where the retrying party is a machine. Webhook providers, queues, and file pipelines all guarantee at-least-once delivery, so duplicates are normal traffic rather than incidents; the consumer deduplicates on the producer's own identifier — the event id, the message id, the object key and etag — inside one transaction, acknowledges only after that commits, and answers a duplicate with 2xx, because any other code tells the provider to keep retrying work that is already done.

What is true here

  1. HTTP-level idempotency is a property of the method; application-level idempotency is a property of your handler and must be built.
  2. The key is generated by the client before the first attempt and reused on every retry — a server-generated key protects nothing.
  3. Record the key with a unique constraint in the same transaction as the work, and let the constraint refuse duplicates.
  4. At-least-once delivery means webhook and queue duplicates are expected; deduplicate on the producer's identifier at every hop.
  5. Answer a duplicate with 2xx — a 4xx or 5xx tells the provider to keep retrying something already completed.

What you will be able to do

  • Explain the difference between method idempotency and application-level idempotency, and when each applies
  • Implement an `Idempotency-Key` flow whose duplicate check cannot race
  • Decide what identifies a unit of work when the producer is a webhook, a queue, or a file drop
  • Order the record, the work, the commit and the acknowledgement so no crash can lose or duplicate an effect
The section's own flow, made concrete
duplicatenewsuccessexceptionor crashretriedsafely

Request (or redelivery)

a POST retry, a webhook, a requeued task

Identify the operation

Idempotency-Key header · provider event id · message id · object key + etag

INSERT the identifier

inside the same transaction as the work — the unique constraint IS the check

IntegrityError — seen before

not an error condition; the expected duplicate path

Execute once

charge, create, apply — in the same transaction

Store status + body, commit

work and record commit together or not at all

Return the stored response

same status, same body — the client cannot tell it retried

Return the fresh response

then acknowledge — never before the commit

Rollback — nothing persisted

no ack, so it is redelivered and handled cleanly

  • Request (or redelivery) — a POST retry, a webhook, a requeued task
    • leads to Identify the operation
  • Identify the operation — Idempotency-Key header · provider event id · message id · object key + etag
    • leads to INSERT the identifier
  • INSERT the identifier — inside the same transaction as the work — the unique constraint IS the check
    • leads to IntegrityError — seen before (duplicate)
    • leads to Execute once (new)
  • IntegrityError — seen before — not an error condition; the expected duplicate path
    • leads to Return the stored response
  • Execute once — charge, create, apply — in the same transaction
    • leads to Store status + body, commit (success)
    • on error, leads to Rollback — nothing persisted (exception or crash)
  • Store status + body, commit — work and record commit together or not at all
    • leads to Return the fresh response
  • Return the stored response — same status, same body — the client cannot tell it retried
  • Return the fresh response — then acknowledge — never before the commit
  • Rollback — nothing persisted — no ack, so it is redelivered and handled cleanly
    • leads to Request (or redelivery) (retried safely)

Client-initiated retries

Idempotency keys for payments, order creation, and external integrations — and what HTTP does and does not give you.

Idempotency keys, and HTTP versus application-level idempotency

coreadvanced

HTTP already says `PUT` and `DELETE` are idempotent — send them twice and the server ends up in the same state. `POST` is not, which is a problem exactly where it matters most: creating a payment, an order, or a call to an external provider. The fix is application-level idempotency. The client generates a unique key per logical operation and sends it as an `Idempotency-Key` header. The server records the key before doing the work; if the same key arrives again, it returns the stored result instead of doing the work a second time. The distinction the roadmap asks for is exactly this: HTTP-level idempotency is a property of the *method* and is free; application-level idempotency is a property of *your handler* and must be built.

Think of it as

The situation this exists for is not a client bug — it is the fundamental uncertainty of a network. A client sends `POST /payments/`, the request succeeds, and the response is lost on the way back. The client now cannot tell "it worked and I did not hear" from "it never arrived", and both retrying and not retrying are wrong: one double-charges, the other loses the payment. The idempotency key resolves it by making the *operation* identifiable rather than the request. Two properties then matter. The key must be generated by the client, before the first attempt, and reused across retries — a server-generated key cannot help, because the client that never got a response has nothing to reuse. And the record must be written in the same transaction as the work, or the two can disagree: the payment commits and the key write fails, so the retry charges again. That is why "check, then do, then record" is the wrong order and a unique constraint on the key is the right mechanism — you let the database refuse the duplicate rather than asking it whether one exists.

python
class IdempotencyRecord(models.Model):
    account = models.ForeignKey(Account, on_delete=models.CASCADE)
    key = models.CharField(max_length=64)
    request_fingerprint = models.CharField(max_length=64)
    status_code = models.PositiveSmallIntegerField(null=True)
    response_body = models.JSONField(null=True)

    class Meta:
        constraints = [models.UniqueConstraint(fields=["account", "key"], name="uniq_idem")]

What we're doing: Make payment creation safe to retry, letting the database refuse duplicates rather than checking for them.

payments/views.pypython
class PaymentCreateView(APIView):
    def post(self, request):
        key = request.headers.get("Idempotency-Key")
        if not key:
            raise ValidationError({"detail": "Idempotency-Key header is required."})

        fingerprint = sha256(request.body).hexdigest()

        try:
            with transaction.atomic():
                record = IdempotencyRecord.objects.create(
                    account=request.user.account, key=key, request_fingerprint=fingerprint)
                payment = charge_and_record(request.user.account, request.data)
                record.status_code = 201
                record.response_body = PaymentSerializer(payment).data
                record.save(update_fields=["status_code", "response_body"])
        except IntegrityError:
            record = IdempotencyRecord.objects.get(
                account=request.user.account, key=key)
            if record.request_fingerprint != fingerprint:
                raise Conflict("This Idempotency-Key was used with a different body.")
            if record.status_code is None:
                raise Conflict("The original request is still in progress.")
            return Response(record.response_body, status=record.status_code)

        return Response(record.response_body, status=201)
4–5
Requiring the header rather than defaulting to "no key" — an unkeyed payment request is a client bug, and failing loudly is better than silently accepting one that cannot be retried safely.
10–16
One transaction covers the key row, the charge, and the stored response. Either all three commit or none do, which is what stops the key and the charge from disagreeing.
17–19
The `IntegrityError` from the unique constraint *is* the duplicate check. Reading first and inserting after leaves a window where two concurrent retries both find nothing.
20–21
Same key, different body is a client bug, not a replay — returning the first result would silently discard the second request.
22–23
A key row with no stored status means the original attempt is still running. Answering 409 tells the client to wait rather than issuing a second charge.

Why this works: Letting the unique constraint decide removes the read-then-write race entirely: two simultaneous retries race to `INSERT`, exactly one wins, and the loser is routed to the replay path by the database rather than by a check that could have been stale.

Checking for the key before doing the work, in a separate step

Wrong

python
if IdempotencyRecord.objects.filter(key=key).exists():
    return Response(...)                       # replay
payment = charge(...)                          # two retries can both reach here
IdempotencyRecord.objects.create(key=key)

Better

python
with transaction.atomic():
    IdempotencyRecord.objects.create(account=account, key=key)   # unique constraint
    payment = charge(...)

What you see: Duplicate charges appear only under retry storms — a mobile client on a flaky connection firing two retries within milliseconds — so the bug is unreproducible locally and shows up in a customer complaint about being billed twice.

Why: Between the `exists()` and the `create()` there is a window in which another request can pass the same check. This is the classic check-then-act race, and no amount of ordering fixes it in application code; the guarantee has to come from the database. A unique constraint makes the second `INSERT` fail, which is a deterministic signal rather than a probabilistic one.

The lost response, and how a key resolves it
Client
API
IdempotencyRecord
Payment provider
  1. 1. POST /payments/ · Idempotency-Key: 8f14e45f
  2. 2. INSERT key (unique constraint)inside the same transaction as the work
  3. 3. charge £40.00
  4. 4. charged · txn_9931
  5. 5. store 201 + response body, commit
  6. 6. 201 Createdthe response is lost — connection reset
  7. 7. retry: same body, SAME key 8f14e45f
  8. 8. INSERT fails on the unique constraintthe database refuses the duplicate — no read-then-check race
  9. 9. the stored 201 and body
  10. 10. 201 Created — identical response, one charge
  1. Client → API: POST /payments/ · Idempotency-Key: 8f14e45f
  2. API → IdempotencyRecord: INSERT key (unique constraint) (inside the same transaction as the work)
  3. API → Payment provider: charge £40.00
  4. Payment provider → API: charged · txn_9931
  5. API → IdempotencyRecord: store 201 + response body, commit
  6. API → Client: 201 Created (the response is lost — connection reset)
  7. Client → API: retry: same body, SAME key 8f14e45f
  8. API → IdempotencyRecord: INSERT fails on the unique constraint (the database refuses the duplicate — no read-then-check race)
  9. IdempotencyRecord → API: the stored 201 and body
  10. API → Client: 201 Created — identical response, one charge

Two kinds of idempotency, often confused

Two kinds of idempotency, often confused
AspectHTTP-levelApplication-level
Property ofthe methodyour handler
Guaranteed byRFC 9110code you write
Applies to`GET`, `PUT`, `DELETE`, …`POST` — creation, charges, sends
Who relies on itproxies, caches, client libraries retryingyour own clients, retrying deliberately
Mechanismthe method's definitiona client-supplied key + a unique constraint
Costfreea table, a transaction, and a purge job

Together

http
POST /payments/ HTTP/1.1
Idempotency-Key: 8f14e45f-ea8f-4c1b-9a2d-3b1c7f0a2e55
Content-Type: application/json

{"order_id": 57, "amount": "40.00"}

Remember: HTTP gives you idempotency for `PUT` and `DELETE` for free; `POST` needs you to build it. The client generates a key per logical operation and reuses it on every retry; the server records the key with a unique constraint, in the same transaction as the work, and returns the stored response on a replay. Let the constraint refuse duplicates rather than checking first — `exists()` then `create()` is a race. Same key with a different body is a client bug (409/422), and keys need a retention window and a purge.

See also: at least once delivery and consumer idempotency · http semantics and status codes · idempotency and conditional updates · atomic and nested blocks

Advertisement

Machine-driven redelivery

Webhooks, queue workers, and file processing, where at-least-once delivery makes duplicates routine.

At-least-once delivery: webhooks, queue workers, file processing

coreadvanced

Webhook providers, message queues, and file pipelines all guarantee *at-least-once* delivery, which means duplicates are normal operation, not a fault. A provider that does not get a 2xx retries; a queue that does not see an acknowledgement redelivers; a file dropped twice into a bucket fires the handler twice. The consumer is therefore where idempotency has to live. The pattern is the same in all three cases: derive a stable identity for the unit of work — the provider's event id, the message id, a hash of the file — record it with a unique constraint before doing the work, and treat "already recorded" as success rather than as an error.

Think of it as

The producer cannot give you exactly-once delivery, because the acknowledgement can be lost just like any other message; the honest choice is at-least-once plus a consumer that tolerates repeats. So stop thinking about preventing duplicates and start thinking about making the second run a no-op — "exactly-once *effect*" rather than exactly-once delivery. That reframes the design question to "what identifies this unit of work?", and the answer must come from the producer, not from you: the Stripe event id, the SQS message id, the object key plus etag. A timestamp or a payload hash is a weaker substitute, because two genuinely distinct events can be identical. The second half is where the record is written. If you do the work and record afterwards, a crash in between guarantees a duplicate on redelivery; if you record first and the work fails, the retry is refused and the event is lost. Both are wrong, and the resolution is the same as for idempotency keys — one transaction covering both — with one addition specific to this setting: acknowledge only after that transaction commits, so a crash before the commit produces a redelivery you can still handle rather than a silent loss.

python
class ProcessedEvent(models.Model):
    source = models.CharField(max_length=32)
    external_id = models.CharField(max_length=128)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["source", "external_id"], name="uniq_event"),
        ]

What we're doing: A webhook endpoint that survives redelivery: verify, deduplicate, enqueue, and answer fast.

billing/webhooks.py + billing/tasks.pypython
class StripeWebhookView(APIView):
    authentication_classes = []
    permission_classes = [AllowAny]

    def post(self, request):
        event = verify_signature(request.body, request.headers["Stripe-Signature"])

        try:
            with transaction.atomic():
                ProcessedEvent.objects.create(source="stripe", external_id=event["id"])
        except IntegrityError:
            return Response(status=200)          # already handled — tell Stripe to stop

        handle_stripe_event.delay(event["id"], event["type"])
        return Response(status=200)              # answer fast; the work happens elsewhere


@shared_task(acks_late=True, max_retries=5)
def handle_stripe_event(event_id, event_type):
    with transaction.atomic():
        record = (ProcessedEvent.objects
                  .select_for_update()
                  .get(source="stripe", external_id=event_id))
        if record.completed_at is not None:
            return                                # the task itself was redelivered

        apply_event(event_id, event_type)
        record.completed_at = timezone.now()
        record.save(update_fields=["completed_at"])
6
Signature verification comes first and unconditionally. A webhook endpoint is unauthenticated by necessity, so the signature is the only thing distinguishing a real event from anyone who knows the URL.
9–12
The insert *is* the duplicate check. Returning 200 for a duplicate is deliberate: any other status tells the provider to keep retrying work that is already done.
14–15
Enqueue and answer. Providers time out in seconds and retry on timeout, so doing the work inline turns one slow event into a redelivery storm.
18
`acks_late=True` acknowledges only after the task returns, so a worker crash means redelivery rather than a lost event — which is safe precisely because the task is idempotent.
21–25
The task deduplicates again, because at-least-once applies to the queue too. `select_for_update()` serialises two workers that both picked up the same redelivered message.

Why this works: Deduplicating at both hops — the HTTP delivery and the queue delivery — is not redundant: each hop has its own at-least-once guarantee, so each needs its own guard. The endpoint stays fast, and a crash anywhere produces a retry that lands on a no-op.

Doing the work first and recording the event id afterwards

Wrong

python
apply_event(event)                                        # charge, email, ledger write
ProcessedEvent.objects.create(external_id=event["id"])    # crash here = duplicate later

Better

python
with transaction.atomic():
    ProcessedEvent.objects.create(external_id=event["id"])
    apply_event(event)

What you see: A worker restart during a deploy leaves a handful of events applied but unrecorded. The provider redelivers them, they are applied again, and the ledger shows duplicate entries with timestamps a few minutes apart.

Why: Two writes that must agree have to share a transaction. Recording after the work leaves a window in which the effect exists and the record does not, and a redelivery lands squarely in it. Putting the insert inside the same transaction makes both commit together — and putting it *first* also means a concurrent duplicate is refused before any work starts.

One unit of work, and every path a redelivery can take
INSERT external_idsucceedsIntegrityError— seen beforereturn 2xx:already donesuccessexceptionor crashack AFTERcommitno ack →redelivered, safely

Delivered (possibly again)

start

Claimed — identifier inserted

Duplicate — constraint refused it

Doing the work, same transaction

Committed — work + record together

Acknowledged · 2xx returned

end

Rolled back — nothing persisted

  • Delivered (possibly again) (start)
    • → Claimed — identifier inserted when INSERT external_id succeeds
    • → Duplicate — constraint refused it when IntegrityError — seen before
  • Claimed — identifier inserted
    • → Doing the work, same transaction
  • Duplicate — constraint refused it
    • → Acknowledged · 2xx returned when return 2xx: already done
  • Doing the work, same transaction
    • → Committed — work + record together when success
    • → Rolled back — nothing persisted when exception or crash
  • Committed — work + record together
    • → Acknowledged · 2xx returned when ack AFTER commit
  • Acknowledged · 2xx returned (end)
  • Rolled back — nothing persisted
    • → Delivered (possibly again) when no ack → redelivered, safely

Three sources, one pattern

Three sources, one pattern
SourceIdentity to deduplicate onWhy it is redelivered
Webhook (Stripe, GitHub, …)the provider's event idno 2xx received in time, or a timeout
Queue worker (Celery, SQS)the message/task idno acknowledgement before the visibility timeout
File processingobject key + etag, or a content hashthe same file re-uploaded, or the notification retried
External API callbackthe callback's own referencethe caller retried after a lost response

Together

python
ProcessedEvent.objects.create(source="stripe", external_id=event["id"])
# IntegrityError here means "already handled" -> return 200 and stop

Remember: Webhooks, queues, and file pipelines all deliver at least once, so duplicates are normal traffic and the consumer must absorb them. Take the identity from the producer — event id, message id, object key + etag, never a timestamp — insert it with a unique constraint inside the same transaction as the work, and acknowledge only after that commits. Answer a duplicate with 2xx: any other code tells the provider to retry work already done. And deduplicate at every hop, because each hop has its own at-least-once guarantee.

See also: idempotency keys and http semantics · idempotency and conditional updates · on commit and transaction timing · row level locking

Advertisement