Idempotent API: Stopping Double-Charging Nightmares

MaxWhiz Expert 5h ago Updated Jul 27, 2026 363 views 9 likes 2 min read

TCP timeouts are basically the "coin flip" of financial backend disasters. You send a transaction, the network hiccups, you retry because you're anxious, and suddenly your user has been charged twice. Congratulations, you've just created a customer support ticket.

The only way to stop this madness is a strict idempotency strategy. If you aren't using idempotency keys, you're essentially gambling with your database.

The "Don't Double Charge" Logic

The flow is simple: the client sends a unique key. The server checks if that key is already in the system. If it is, the server either tells the client to stop spamming (409 Conflict) or just hands back the previous result. If it's new, the server locks it, does the work, and caches the result.

Database Level Safety

Don't trust your application logic alone. Put a UNIQUE constraint on your database table so that even if your Redis cache decides to take a nap, the DB will still reject the duplicate.

CREATE TABLE journal_entries (
 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
 idempotency_key VARCHAR(255) UNIQUE NOT NULL,
 amount DECIMAL(18, 4) NOT NULL,
 created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

Practical Tutorial: FastAPI + Redis Implementation

Here is a real-world pattern for handling this. I use set(nx=True) in Redis to create an atomic "lock" so two simultaneous requests can't both sneak through.

from fastapi import FastAPI, Header, HTTPException, status
import redis

app = FastAPI()
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

@app.post("/api/v1/journal", status_code=201)
def post_transaction(payload: dict, idempotency_key: str = Header(...)):
 # 1. Atomic Check-and-Set in Redis
 lock_acquired = r.set(f"idemp:{idempotency_key}", "IN_PROGRESS", nx=True, ex=3600)
 
 if not lock_acquired:
 status_val = r.get(f"idemp:{idempotency_key}")
 if status_val == "IN_PROGRESS":
 raise HTTPException(
 status_code=status.HTTP_409_CONFLICT,
 detail="Transaction in progress. Please wait."
 )
 # Return cached response if done
 return {"status": "success", "cached": True, "data": status_val}
 
 try:
 # 2. Process transaction inside a database transaction block
 # (DB commit logic goes here)
 result_data = {"transaction_id": "tx_abc123", "processed": True}
 
 # 3. Update status to completed and store response
 r.set(f"idemp:{idempotency_key}", str(result_data), ex=86400)
 return {"status": "success", "cached": False, "data": result_data}
 except Exception as e:
 r.delete(f"idemp:{idempotency_key}")
 raise HTTPException(
 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
 detail="Transaction failed."
 )

The Gotchas

  • Expiration Times: Notice I set the "IN_PROGRESS" lock to 1 hour and the final result to 24 hours. If you set these too short, a slow retry could trigger a duplicate. Too long, and you're wasting Redis memory.
  • Error Handling: If the DB transaction fails, you must delete the Redis key. Otherwise, that idempotency key is "bricked" and the user can never retry that specific transaction.

For a deep dive into the testing side of this (because you can't just "hope" it works), I've been using pytest and httpx to hammer the endpoint with concurrent requests to make sure the locks actually hold.

https://github.com/Borino88/secure-fintech-ledger
APIbackendpythonAI ProgrammingAI Coding

All Replies (4)

A
AlexHacker Expert 13h ago
I usually just hash the order ID and timestamp for my keys to avoid duplicates.
0 Reply
Q
Quinn48 Advanced 13h ago
Don't forget to set a TTL on those keys so your database doesn't bloat forever.
0 Reply
C
CameronCat Intermediate 13h ago
Had this happen with a Stripe integration once; idempotency keys are a total lifesaver.
0 Reply
A
AveryDreamer Novice 13h ago
@CameronCat Stripe's docs make it pretty easy to set up, but it's a nightmare if you forget them!
0 Reply

Write a Reply

Markdown supported