Design APIs that handle retries gracefully without creating duplicate operations.
March 1, 2026 · 7 min · 1451 words · Rob Washington
Table of Contents
Networks fail. Clients timeout. Users double-click. If your API creates duplicate orders or charges cards twice when this happens, you have a problem. Idempotency is the solution—making operations safe to retry without side effects.
fromfunctoolsimportwrapsimporthashlibimportjsondefidempotent(ttl_seconds=86400):defdecorator(f):@wraps(f)defwrapper(request,*args,**kwargs):key=request.headers.get("Idempotency-Key")ifnotkey:returnf(request,*args,**kwargs)# No key = no protectioncache_key=f"idempotency:{key}"# Check if we've seen this request beforecached=redis.get(cache_key)ifcached:returnjson.loads(cached)# Process the requestresponse=f(request,*args,**kwargs)# Cache the responseredis.setex(cache_key,ttl_seconds,json.dumps(response))returnresponsereturnwrapperreturndecorator@app.post("/orders")@idempotent(ttl_seconds=86400)defcreate_order(request):order=Order.create(request.json)return{"id":order.id,"status":"created"}
Now retries return the cached response instead of creating duplicates.
// Option 1: UUID
constidempotencyKey=crypto.randomUUID();// Option 2: Hash of meaningful data
constidempotencyKey=hash(userId+cartId+timestamp);// Option 3: Client transaction ID
constidempotencyKey=`order_${userId}_${Date.now()}`;
Important: The same key with different request bodies should error, not silently succeed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
defidempotent_wrapper(request):key=request.headers.get("Idempotency-Key")cached=redis.hgetall(f"idempotency:{key}")ifcached:# Verify request body matchesrequest_hash=hash_body(request.json)ifcached["request_hash"]!=request_hash:raiseHTTPException(422,"Idempotency key reused with different request body")returnjson.loads(cached["response"])# ... process and cache
# PUT replaces entire resource - inherently idempotent@app.put("/users/{user_id}")defupdate_user(user_id:int,data:UserUpdate):user=User.get(user_id)user.name=data.nameuser.email=data.emailuser.save()returnuser# DELETE is idempotent - deleting twice = still deleted@app.delete("/users/{user_id}")defdelete_user(user_id:int):user=User.get_or_none(user_id)ifuser:user.delete()return{"status":"deleted"}# Same response whether existed or not
POST creates new resources—not naturally idempotent. Options:
Option 1: Idempotency keys (shown above)
Option 2: Client-generated IDs
1
2
3
4
5
6
7
8
@app.post("/orders")defcreate_order(order_id:str,data:OrderCreate):# Client provides the IDexisting=Order.get_or_none(order_id)ifexisting:returnexisting# Already exists, return itreturnOrder.create(id=order_id,**data.dict())
Option 3: Natural keys
1
2
3
4
5
6
7
8
@app.post("/subscriptions")defsubscribe(user_id:int,plan_id:str):# Only one subscription per user per planexisting=Subscription.get_or_none(user_id=user_id,plan_id=plan_id)ifexisting:returnexistingreturnSubscription.create(user_id=user_id,plan_id=plan_id)
deftest_create_order_idempotent():key="test_key_123"data={"items":[{"sku":"ABC","qty":1}]}# First requestresponse1=client.post("/orders",json=data,headers={"Idempotency-Key":key})assertresponse1.status_code==201order_id=response1.json()["id"]# Retry with same keyresponse2=client.post("/orders",json=data,headers={"Idempotency-Key":key})assertresponse2.status_code==200# Not 201assertresponse2.json()["id"]==order_id# Same order# Verify only one order existsorders=Order.filter(idempotency_key=key)assertlen(orders)==1
The rule: Any operation that creates resources or has side effects needs an idempotency strategy. Networks are unreliable. Retries will happen. Be ready.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.