Idempotent API: Stopping Double-Charging Nightmares
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