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: 30
import 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
StatusRetry?Reason
408, 429Yes, with backoffTransient or throttled
500, 502, 503, 504Yes, with backoffServer side, may be temporary
400, 422NoThe request itself is wrong
401, 403No, refresh then maybe onceCredential or permission problem
404NoThe resource is not there
409SometimesA concurrent conflict, may resolve
Timeout with no responseOnly with an idempotency keyThe 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.

Caching with ETag and Cache-Control Long-running operations, webhooks and bulk endpoints

Last refreshed 2026-09-18.