Idempotency, retries and rate limiting
Networks fail after the server has done the work. Idempotency keys make a retried write safe, and honest rate limits make clients behave.
Idempotency keys
POST /payments HTTP/1.1
Idempotency-Key: 8f14e45f-ceea-467a-9f6f-3f2b1c9d4a77
Content-Type: application/json
{"amount":2500,"currency":"GBP","reference":"INV-1041"}import json, hashlib
from flask import Flask, request, jsonify
from redis import Redis
app = Flask(__name__)
r = Redis()
TTL = 24 * 3600
def fingerprint(payload: dict) -> str:
body = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(body.encode()).hexdigest()
@app.post("/payments")
def create_payment():
key = request.headers.get("Idempotency-Key")
if not key:
return jsonify(error="idempotency_key_required"), 400
payload = request.get_json(force=True)
fp = fingerprint(payload)
stored = r.get("idem:" + key)
if stored:
rec = json.loads(stored)
if rec["fingerprint"] != fp:
# same key, different body: refuse rather than guess
return jsonify(error="idempotency_key_reuse"), 422
return jsonify(rec["body"]), rec["status"]
# Reserve the key before doing the work so a concurrent retry waits or fails,
# rather than both requests charging the card.
if not r.set("idem:" + key, json.dumps({"state": "in_progress"}), nx=True, ex=TTL):
return jsonify(error="request_in_progress"), 409
result, status = charge(payload)
r.set("idem:" + key, json.dumps(
{"fingerprint": fp, "body": result, "status": status}), ex=TTL)
return jsonify(result), status- The key must be generated by the client and reused only for the same logical operation.
- Store the key with the request fingerprint and the response, so a retry returns the original result rather than performing the work again.
- Reject a reused key with a different body; silently accepting it hides a client bug and produces wrong results.
- Reserve the key before doing the work. A check-then-act sequence still lets two concurrent retries through.
Retry strategy and rate limiting
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30import random, time, requests
def request_with_retry(method, url, *, max_attempts=5, **kw):
for attempt in range(1, max_attempts + 1):
resp = requests.request(method, url, timeout=10, **kw)
if resp.status_code < 500 and resp.status_code != 429:
return resp # success or a permanent client error
if attempt == max_attempts:
return resp
retry_after = resp.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
delay = int(retry_after)
else:
# exponential backoff with jitter, capped
delay = min(30, (2 ** attempt)) + random.uniform(0, 0.5)
time.sleep(delay)
return resp| Status | Retry? | Reason |
|---|---|---|
408, 429 | Yes, with backoff | Transient or throttled |
500, 502, 503, 504 | Yes, with backoff | Server side, may be temporary |
400, 422 | No | The request itself is wrong |
401, 403 | No, refresh then maybe once | Credential or permission problem |
404 | No | The resource is not there |
409 | Sometimes | A concurrent conflict, may resolve |
| Timeout with no response | Only with an idempotency key | The outcome is unknown |
💡
Always add jitter to a backoff. Without it, every client that was throttled at the same moment retries at the same moment, and a rate limit turns into a synchronised stampede against a service that is already struggling.
FAQ
How long should an idempotency key be honoured?
Long enough to cover the client's retry window, which is usually 24 hours. After that, delete it; an unbounded store of keys is a slow memory leak.
Where should the rate limit be enforced?
At the edge, close to the client, but also trust nothing about your own clients. Per-key limits at the gateway plus a coarser per-tenant limit at the service covers both accidental loops and deliberate abuse.
Related
Caching with ETag and Cache-Control Long-running operations, webhooks and bulk endpoints
Last refreshed 2026-09-18.