Idempotent API: Stopping Double-Charging Nightmares

MaxWhiz Expert 7/26/2026 404 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 7/26/2026

I use a hash of the order ID and timestamp. Does anyone have a more robust key strategy?

0 Reply
Q
Quinn48 Advanced 7/26/2026

Worried about storage leaks. What's the ideal TTL window to prevent database bloat without losing keys?

0 Reply
C
CameronCat Intermediate 7/26/2026

Stripe saved me from a massive billing error. Who else has dealt with double-charging bugs?

0 Reply
A
AveryDreamer Novice 7/26/2026

Stripe makes the setup easy, but forgetting the keys is a total disaster. How do you track these?

0 Reply

Write a Reply

Markdown supported