DEV Community

Sir Max
Sir Max

Posted on

3 Ways to Make Your API Requests Idempotent (With Working Code)

3 Ways to Make Your API Requests Idempotent (With Working Code)

We lost a customer payment last year because of a retry. A third-party gateway timed out on our first charge attempt, so our client retried — and the customer got billed twice. It wasn't a huge amount, but the refund process, the support ticket, and the customer's trust took weeks to repair.

The root cause wasn't the gateway. It was us: our endpoint wasn't idempotent. We assumed every request happens exactly once. In a world of flaky networks, that assumption is wrong.

Here's what I learned, and the three patterns I now reach for whenever a request can be retried.

Why "retry" and "exactly once" don't mix

Networks fail in ways that make it impossible to know whether a request reached the server:

  • The server processed the request, but the response got lost on the way back.
  • The server is still processing when the client's timeout fires.
  • A load balancer retried the request internally without telling you.

When the client retries, the server may process the same logical operation twice. For a GET, that's usually harmless. For a POST /payments, it's a double charge.

The general fix is to make operations idempotent: performing the same operation multiple times has the same effect as performing it once.

Pattern 1: Idempotency keys

The most common pattern is an idempotency key — a client-generated unique identifier that the server uses to deduplicate retries.

import uuid

# In-memory store for this example. Use Redis or Postgres in production.
_store = {}

def process_payment(payment_id, amount, idempotency_key):
    if idempotency_key in _store:
        return _store[idempotency_key]  # replay the cached result
    # ... actually charge the card here ...
    result = {"payment_id": payment_id, "status": "succeeded"}
    _store[idempotency_key] = result
    return result
Enter fullscreen mode Exit fullscreen mode

The client generates the key once per logical operation and reuses it on every retry:

key = uuid.uuid4().hex  # generated once, kept across retries
process_payment("pmt_123", 49.99, key)  # first attempt
process_payment("pmt_123", 49.99, key)  # retry — returns the cached result
Enter fullscreen mode Exit fullscreen mode

The server stores the key-to-response mapping. On a duplicate, it returns the stored result instead of re-executing the work.

A few details I got wrong the first time:

  • The key must be generated by the client, not the server. The whole point is that the server can't distinguish a new request from a retry without it.
  • Guard against races. Two retries can arrive at the same time. A naive "check then insert" has a gap where both threads see the key as missing. Use an atomic insert (or a lock) so only one side effect happens.
  • Return the same response, not just a success flag. If the first attempt returned a 201 with a payment ID, the retry should return that same 201 and ID — not a 409 conflict or an empty 200.

Pattern 2: Make the operation naturally idempotent

Some operations don't need a key if you design the semantics to be idempotent from the start.

PUT is the classic example. A PUT /users/42 that sets the full representation is idempotent by definition — sending it twice sets the same state twice.

For "create" operations, derive a stable identifier from the request itself instead of generating a random ID on every call:

import hashlib

def create_user(email, name):
    user_id = "user_" + hashlib.sha256(email.encode()).hexdigest()[:16]
    # INSERT ... ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
    return {"id": user_id, "email": email, "name": name}
Enter fullscreen mode Exit fullscreen mode

Because the ID is a deterministic hash of the email, sending the same request twice produces the same user — no duplicate rows. This is a form of upsert, and it works well for records with a natural unique key.

The tradeoff: you need a natural key (email, SKU, external ID). If you don't have one, fall back to idempotency keys.

Pattern 3: Optimistic concurrency

Idempotency keys protect against retries. But what about two different requests that both want to modify the same record? That's where optimistic concurrency helps — and it pairs well with the first two patterns.

Send a version (or updated_at timestamp) with the request, and reject the update if the version doesn't match:

def update_article(article_id, body, expected_version):
    row = db.get(article_id)
    if row["version"] != expected_version:
        return {"error": "conflict", "current_version": row["version"]}, 409
    db.update(article_id, body=body, version=row["version"] + 1)
    return {"version": row["version"] + 1}, 200
Enter fullscreen mode Exit fullscreen mode

This doesn't make a single request idempotent on its own, but it prevents a common source of duplicate side effects: two writers clobbering each other. Combined with idempotency keys, it covers most of the failure modes I've actually hit in production.

The pitfalls that will bite you

A few things I learned the hard way:

  1. Expire your idempotency records. Don't store keys forever. Keep them for 24 hours — that covers virtually all realistic retry windows — then delete them. Otherwise you've built a slow memory leak.
  2. Scope keys per endpoint and per user. The same key on a different endpoint should not collide. Namespace it: user123:payments:abc....
  3. Handle the "stale key" case. If a client reuses a key for a different payload, that's a bug. Detect it and return a 422 instead of silently returning the old result.
  4. Test with real concurrency. Fire 50 simultaneous requests with the same key and confirm only one side effect happens. A unit test that calls the function twice sequentially will miss the race.

What I'd tell my past self

The whole idempotency-key idea felt like over-engineering until the double charge happened. Now I treat it like input validation: something you add by default to any endpoint that has a side effect.

If your API mutates state and a client might retry, add an idempotency key. It's maybe 30 lines of code. The alternative is a support ticket titled "you charged me twice."

Top comments (0)