Retries are unavoidable in a distributed system. A mobile client loses signal mid-request, a load balancer times out and resends, a user gets an ambiguous spinner and taps buy a second time thinking the first tap didn’t register. None of that is a bug. The bug is what the server does with the second copy of a request that already ran once.
The common framing of this problem is “payments need idempotency keys, because double-charging is bad.” That’s true, but it undersells the shape of the fix. Strip the payments context away and what’s left is a cache: a key, a lookup before doing work, a write after doing it, and a TTL. The only thing that makes it different from an ordinary cache is where the key comes from and what a hit means.
Same mechanism, different vocabulary
| Ordinary cache | Idempotency key | |
|---|---|---|
| Key origin | Derived from the request by the server | Generated by the client, opaque to the server |
| Hit means | ”Recomputing would give the same answer, skip it" | "This exact request already happened, return what happened” |
| Miss means | Compute it, store it | Do the real work, then store the result |
| Invalidation | TTL or explicit eviction | TTL only, and it has to outlast every plausible retry window |
The one structural difference is who defines “the same request.” A normal cache key can be rebuilt from the request body on the server, because two requests with the same body are interchangeable. An idempotency key can’t be built that way, because the whole point is telling a genuine retry apart from a second, deliberate request that happens to look identical. Only the client knows which one it’s sending, so the client has to supply the key.
That reframing is what tells you where else this applies. It’s not “does this endpoint move money.” It’s “does this endpoint have a side effect, and can the client’s own network conditions cause it to send the same request twice.” Any POST that creates a row, decrements a counter, sends an email, or charges a card qualifies. A GET doesn’t need this because a GET is already safe to repeat by definition; a POST /orders isn’t, unless something makes it act like one.
What it looks like in a real checkout path
flash-sale-system’s /pay endpoint takes a client-generated UUIDv4 as an Idempotency-Key header. The server namespaces it by the authenticated user before touching Redis:
# backend/services/payment_service.py
idem_key = f"idem:pay:{user}:{request.idempotency_key}"
cached = await self.r.get(idem_key)
if cached:
data = json.loads(cached)
return ProcessPaymentResponse(
status=data["status"], quantity=data["quantity"], message="idempotency cache hit"
)
The user prefix matters for a reason that has nothing to do with retries: without it, a key generated by one user could be replayed by anyone who obtained it, since Redis has no notion of who’s asking. Namespacing by user means a leaked idempotency key only ever replays inside the account it belongs to.
On a miss, the server does the real work, the same four steps every time, and only writes the cache entry at the end:
- Confirm the reservation is still held
- Run the payment
- Write to the outbox table
- Cache the response
await self.r.setex(idem_key, 86400, json.dumps(response_data))
86400 seconds is 24 hours. That number is the entire idempotency guarantee: long enough to cover a browser reconnecting or a mobile client resuming from the background, short enough that Redis doesn’t accumulate stale keys forever. Pick it too short and a slow retry after the TTL expires reruns the whole request as if it were new. Pick it too long and it’s just a memory leak with a delay.
What a key like this doesn’t cover
An idempotency key stops a retried request from re-running. It does nothing for a second, deliberate request, because from the client’s perspective that’s a new key. If a user taps buy twice on purpose, both taps are legitimate new requests as far as the server is concerned, and the fix for that is a different problem (disabling the button, confirming intent) not a cache.
The same lookup that serves the idempotency check also answers “did this already happen,” which is a separate use for the same key:
# GetPaymentStatus
idem_key = f"idem:pay:{request.user_id}:{request.idempotency_key}"
cached = await self.r.get(idem_key)
if cached:
data = json.loads(cached)
return GetPaymentStatusResponse(status=data["status"], quantity=data["quantity"])
return GetPaymentStatusResponse(status="not_found", quantity=0)
A frontend that isn’t sure whether its own request succeeded can poll this instead of guessing, which removes one more reason for a user to hit buy a second time in the first place.
Not to be confused with rate limiting
/reserve sits behind a separate Redis check, an INCR plus a first-hit EXPIRE, that looks similar but solves an unrelated problem:
RATE_LIMIT_DURATION = 60
RATE_LIMIT_MAX = 5000
async def check_rate_limit(request: Request):
client_ip = request.client.host if request.client else "unknown"
key = f"rate_limit:{client_ip}"
try:
current = await r_async.incr(key)
if current == 1:
await r_async.expire(key, RATE_LIMIT_DURATION)
if current > RATE_LIMIT_MAX:
raise HTTPException(status_code=429, detail="Too many requests. Slow down!")
except redis.ConnectionError:
print("Rate limit check skipped (Redis down)")
Idempotency keys and rate limits both use Redis and both sit in front of a write, which is where the confusion usually comes from. But they answer different questions and fail in opposite directions on purpose:
| Key | Question it answers | Fails |
|---|---|---|
idem:pay:{user}:{key} | Has this exact request already happened? | Closed, retry gets the cached result |
rate_limit:{ip} | Has this client sent too many requests? | Open, if Redis is down the limiter gets out of the way |
The rate limiter’s except redis.ConnectionError branch is a deliberate choice: if Redis itself is unreachable, let requests through instead of blocking every buyer during a sale. A missed rate-limit window during an outage is recoverable. Refusing every legitimate request during that same outage isn’t. An idempotency check can’t make the same trade, because failing open on it means paying twice.
The gap it doesn’t close
None of this replaces a uniqueness constraint at the database layer, it just covers what that layer doesn’t. The outbox table has no unique constraint tying a row back to an idempotency key. A crash between the outbox write and the Redis setex would leave no cached response to catch a third retry, and the outbox itself wouldn’t reject a duplicate insert. For this system, the Redis key is the only thing standing between that gap and a duplicate charge, which is a reasonable bet for a 24-hour cache to make and a bad one for a permanent record to depend on.