mobius-idempotence-library
Idempotency-Key middleware for FastAPI, with pluggable storage backends.
Protects POST/PATCH-style endpoints (payments, order creation, anything
non-idempotent by nature) against duplicate side effects when a client
retries a request -- due to a timeout, a dropped connection, or an
at-least-once delivery mechanism upstream.
Features
- Idempotency-Key enforcement following the shape of the common
Idempotency-Keyheader convention used by Stripe and similar APIs. - Request fingerprinting: a key reused with a different request body is
rejected (
422), not silently replayed. - In-flight collision detection: a second concurrent request with the
same key while the first is still processing gets
409, not a race. - Pluggable backends: ships with
RedisBackend(distributed, atomic via single-round-trip Lua scripts) andMemoryBackend(in-process, for tests or single-instance deployments). ImplementStorageBackendfor anything else (Postgres, DynamoDB, ...). - Fail-open or fail-closed, your choice, when the backend is unreachable.
- Fully async, doesn't block the event loop.
Install
pip install mobius-idempotence-library # in-memory backend only
pip install mobius-idempotence-library[redis] # + Redis backend
Quick start
from fastapi import FastAPI
from pydantic import BaseModel
from mobius_idempotence import IdempotencyConfig, IdempotencyManager, idempotent
from mobius_idempotence.backends.redis import RedisBackend
app = FastAPI()
backend = RedisBackend("redis://localhost:6379/0")
manager = IdempotencyManager(backend, IdempotencyConfig(fail_open=False))
class PaymentRequest(BaseModel):
amount: float
currency: str
@app.post("/payments", status_code=201)
@idempotent(manager, status_code=201)
async def create_payment(payload: PaymentRequest):
return await charge_the_card(payload) # your business logic, nothing else
@app.on_event("shutdown")
async def shutdown():
await manager.aclose()
That's the entire integration. @idempotent(manager) handles:
- validating the
Idempotency-Keyheader - fingerprinting the request body, so a key reused with a different body is rejected (
422) - detecting an in-flight duplicate request (
409) - replaying the cached response verbatim on retry, with no re-run of your handler
- releasing the lock if your handler raises, so the client can safely retry with the same key
No Depends(...), no manual cache lookup, no manual save/release calls in the handler.
Callers must send the same Idempotency-Key header (and the same request
body) on retry:
curl -X POST http://localhost:8000/payments \
-H "Idempotency-Key: 3f29c1a2-..." \
-H "Content-Type: application/json" \
-d '{"amount": 10.5, "currency": "USD"}'
What gets cached: the handler's return value (dict, list, Pydantic
model, or dataclass), encoded the same way FastAPI itself would encode it.
If you need to return a raw Response / StreamingResponse, or want custom
caching logic, use the lower-level IdempotencyGuard dependency instead --
see examples/payments_app_manual.py.
Writing your own backend
Subclass mobius_idempotence.StorageBackend and implement five async
methods: check_or_lock, save_response, release, ping, aclose. See
src/mobius_idempotence/backends/memory.py for the simplest possible
reference implementation.
Configuration
IdempotencyConfig is a plain dataclass -- wire it up to whatever config
system your app already uses (env vars, Pydantic Settings, a YAML file):
| Field | Default | Meaning |
|---|---|---|
lock_ttl |
60 |
Seconds an in-progress lock lives before being considered abandoned |
response_ttl |
86400 |
Seconds a finished response stays cached and replayable |
key_prefix |
"idemp:" |
Storage key namespace |
fail_open |
True |
If the backend is unreachable: process anyway (True) or 503 (False) |
max_key_len |
255 |
Reject longer Idempotency-Key headers |
Logging
The library never calls logging.basicConfig() or attaches its own
handlers -- like any well-behaved library, it just emits log records and
leaves handler/format/output configuration to your app. If you see nothing,
that's almost always because your app hasn't configured logging at all, or
has the root level above DEBUG/INFO.
Logger names:
mobius_idempotence-- the core decision log (see below)mobius_idempotence.redis-- Redis connection-level errors only
What gets logged, at what level:
| Level | Event |
|---|---|
DEBUG |
New lock acquired (processing as a new request) |
DEBUG |
Cache hit -- replaying a previously finished response |
DEBUG |
Response successfully cached / lock successfully released |
INFO |
409 -- an identical request is already in progress |
WARNING |
422 -- key reused with a different request body |
WARNING |
save_response/release was a no-op (stale token -- lock expired or another caller took over) |
WARNING |
Backend unreachable, fail_open=True -- proceeding without idempotency protection |
ERROR |
Backend unreachable, fail_open=False -- request rejected with 503 |
Turn it on without touching your app's global logging config:
import logging
logging.getLogger("mobius_idempotence").setLevel(logging.DEBUG)
logging.getLogger("mobius_idempotence").addHandler(logging.StreamHandler())
Or, if your app already calls logging.basicConfig(...) / dictConfig(...),
just make sure the level for this logger (or the root logger) is DEBUG or
INFO -- no extra wiring needed:
logging.basicConfig(level=logging.INFO) # mobius_idempotence INFO/WARNING/ERROR now visible
Every log line includes the idempotency key so you can grep/filter a
single request's lifecycle across a busy log stream.
Testing
pip install -e ".[dev]"
pytest
License
MIT
Release files for mobius-idempotence-lib 0.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mobius_idempotence_lib-0.0.1.tar.gz | 18.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mobius_idempotence_lib-0.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 38.3 kB
Release files / mobius_idempotence_lib-0.0.1.tar.gz
| Download URL | mobius_idempotence_lib-0.0.1.tar.gz |
|---|---|
| Size | 18.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
949049e60e16dbbd0bcb0bb25477113b522beb083db6966be7d0ba676bca6642
|
|
BLAKE2b-256 checksum How to use checksums |
7d049885c6ded438c378eaadfe5b3bf5b7b239ee9dea6749c9555c9dea32aee0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.10.12
|
Release files / mobius_idempotence_lib-0.0.1-py3-none-any.whl
| Download URL | mobius_idempotence_lib-0.0.1-py3-none-any.whl |
|---|---|
| Size | 20.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6e64063c0002f3646414359d7e3e130c5c536b141247c32a5242f5ed9428eefc
|
|
BLAKE2b-256 checksum How to use checksums |
f18cc9f8aab1f741c7ab1e2d536320379b8395d169bca53c59b1e68d8249839d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.10.12
|