Idempotency Keys¶
zeython.idempotency replays a mutating request's first response instead
of running it again, for a client that must safely retry a POST/PUT/
PATCH/DELETE it can't tell succeeded or not — a dropped connection, a
timeout, a proxy that gave up waiting. Without this, a naive retry can
double-charge a card, double-send an email, or create a duplicate row;
with it, the retry gets back exactly what the first attempt produced,
without running the handler a second time.
Opt-in per request, the same way Stripe's API works: a request without an
Idempotency-Key header is never touched.
Setup¶
# main.py
from zeython import Application, IdempotencyServiceProvider
app = Application()
app.register(IdempotencyServiceProvider(app))
curl -X POST http://localhost:8000/orders \
-H 'Idempotency-Key: 8f14e45f-ceea-467e-9578-...' \
-H 'Content-Type: application/json' \
-d '{"amount": 2000}'
Send the same key again — from a genuine retry, or by hand to see it
work — and the response comes back identical, with an added
Idempotency-Replayed: true header, and the handler never runs a second
time:
curl -X POST http://localhost:8000/orders \
-H 'Idempotency-Key: 8f14e45f-ceea-467e-9578-...' \
-H 'Content-Type: application/json' \
-d '{"amount": 2000}'
# same body, same status code, + Idempotency-Replayed: true
Generating the key is the client's job, not the server's — usually a UUID minted once per logical operation (e.g. once when a "place order" button is clicked, reused across every retry of that same click, but never reused for the next order).
Which requests this covers¶
By default: POST, PUT, PATCH, DELETE — the methods that aren't
already naturally idempotent. GET/HEAD/OPTIONS are never touched,
even with the header set, since retrying a GET is always safe on its
own. Narrow it further if you only want this on specific operations
(payments, say) rather than every mutating request:
Records are scoped per method, path, and caller — whatever
Authorization/Cookie header the request carries is folded into the
cache key, so the same key value used against two different endpoints,
or reused (accidentally or on purpose) by two different callers against
the same endpoint, never collides. Without this, a checkout endpoint
whose body doesn't vary by user (the order comes from the caller's own
session, not the request body) would let a second caller who guessed or
reused the first caller's Idempotency-Key receive the first caller's
cached response verbatim — this middleware runs before routing/auth
ever sees the request, so scoping has to happen here, not in the
handler. Two anonymous requests (no Authorization/Cookie header at
all) still share one bucket, the same as before — there's no credential
to tell them apart by.
A replayed key with a different body¶
If a repeated Idempotency-Key shows up with a different request
body than the first time, that's almost always a client bug — reusing a
key across two logically different operations — not a legitimate retry.
This raises ConflictException (409) instead of silently returning a
response for the wrong request:
{"error": "Idempotency-Key '8f14e45f-...' was already used with a different request body.", "status": 409}
A 5xx response is never cached¶
Only a 2xx/4xx response gets stored — a deterministic, completed
outcome, exactly what an idempotency key is meant to protect. A 5xx
means the operation didn't genuinely complete (a downstream timeout, a
dropped DB connection), so it's never cached: a retry with the same key
reprocesses the request from scratch instead of replaying the same
failure until ttl expires, which would defeat the entire point of
retrying. Once a retry succeeds, that response is what gets cached and
replayed by any further retry.
Concurrent retries¶
A key that arrives again while the first request with that key is
still being processed — a client that retries eagerly, before the
first attempt has even finished — waits for it to finish, then replays
its result, rather than running the operation a second time in parallel.
This is correct within one worker process. Across multiple processes or
machines, only the stored result is shared (see below); two processes
racing on a brand-new key can both start processing it before either
finishes — the same in-process-only limitation
RateLimiter and
Cache already document for their default backends, not
something new here.
The default store is process-local¶
IdempotencyServiceProvider uses its own
InMemoryCache by default — correct for a single process,
and a real limitation once you run multiple workers or machines: a retry
that lands on a different process than the one that handled the
original request won't find the stored response, and will process the
request again. Share a RedisCache instead for a store every
process/machine sees:
from zeython import Cache, IdempotencyServiceProvider, RedisCache
cache = RedisCache(app.config.get("redis.url"))
app.register(IdempotencyServiceProvider(app, cache=cache))
Configuration¶
IdempotencyServiceProvider(
app,
cache=None, # default: a fresh InMemoryCache()
methods=["POST", "PUT", "PATCH", "DELETE"], # default
ttl=86400.0, # default: 24 hours
header="Idempotency-Key", # default
)
ttl is how long a stored response stays replayable — after it expires,
the same key is treated as brand new and the request runs again. 24 hours
matches the retention window Stripe's own API documents; shorten it if
your operations are retried on a much tighter loop, or lengthen it for
something a client might reasonably retry a day later.
Scope limits¶
- The request body is buffered in memory to compute its hash (for the
conflict check above) and to replay it to your handler — the same
trade-off
ETagServiceProvidermakes on the response side. Fine for a typical JSON request body; not a fit in front of a large streamed upload. - There's no automatic cleanup beyond
ttl— a very high-cardinality key space (a fresh UUID per request, correctly used) naturally self-limits since old entries simply expire; this isn't a request-deduplication cache meant to hold every request ever made indefinitely.
API reference¶
See zeython.idempotency for the full API.