DEV Community

AutoSMO
AutoSMO

Posted on

Idempotency in order APIs: how one retry quietly becomes two paid orders

AI disclosure: this article was written with the assistance of AI, then reviewed and edited before publishing.

If you've ever wired a client to an API that places orders — payments, shipping, ad spend, anything that moves money — you've met this bug, or you will:

The request times out. Your client retries. The first request had actually succeeded. Now the customer has two orders and one complaint.

It's not a rare edge case. On any system with real traffic, timeouts are constant, and a naive "retry on failure" turns a network hiccup into a duplicate charge. Here's why it happens and how to make it stop.

The trap

A timeout is not a failure. It's the absence of an answer. The order may have gone through, may have failed, or may be processing right now — your client has no idea. So the instinct to retry is exactly wrong for any endpoint that isn't idempotent:

# DON'T do this on a create/charge endpoint
def place_order(payload):
    for attempt in range(3):
        try:
            return api.post("/orders", json=payload, timeout=10)
        except Timeout:
            continue   # <-- each retry may create ANOTHER order
Enter fullscreen mode Exit fullscreen mode

A POST /orders has no natural idempotency: the server can't tell your retry apart from a genuine second order. So it happily creates both.

Fix 1 — idempotency keys (if the API supports it)

Stripe popularised this and it's the clean solution: the client generates a unique key per logical operation and sends it with every attempt. The server stores the result against that key and returns the same result for repeats instead of doing the work twice.

key = str(uuid4())               # one key per logical order
def place_order(payload):
    for attempt in range(3):
        try:
            return api.post("/orders", json=payload,
                            headers={"Idempotency-Key": key}, timeout=10)
        except Timeout:
            continue   # safe now: same key = same order, never a second one
Enter fullscreen mode Exit fullscreen mode

The important detail: the key is generated once, outside the retry loop. Generate it inside and you're back to square one.

Fix 2 — reconcile, don't blind-retry (if it doesn't)

Plenty of APIs have no idempotency key. Then the rule is: a timeout goes to a reconciliation step, not a retry. Before sending again, ask the API what actually happened:

def place_order_safe(payload, client_ref):
    try:
        return api.post("/orders", json={**payload, "ref": client_ref}, timeout=10)
    except Timeout:
        # don't resend — check first
        existing = api.get("/orders", params={"ref": client_ref})
        if existing:
            return existing        # it did go through
        return api.post("/orders", json={**payload, "ref": client_ref})
Enter fullscreen mode Exit fullscreen mode

Attach your own client_ref to every order, and a timeout becomes a lookup instead of a gamble. If the API can filter by your reference, you can always tell "already created" from "never created."

The rule of thumb

  • GET / DELETE by id — naturally idempotent, retry freely.
  • PUT — usually idempotent, retry is fine.
  • POST that creates or charges — never blind-retry. Use an idempotency key, or reconcile by your own reference first.

Timeouts are not the exception on a busy system; they're the weather. Design the create path assuming every request might be sent twice — because eventually, one will be.


What's your approach — idempotency keys, client-side dedup, or something else? Curious how others handle the reconcile step when the API gives you nothing to key on.

Top comments (0)