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
Metadata
Release files for mobius-idempotence-library 0.2.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 | Provenance | |
|---|---|---|---|---|
| mobius_idempotence_library-0.2.1.tar.gz | 18.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mobius_idempotence_library-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 38.4 kB
Release files / mobius_idempotence_library-0.2.1.tar.gz
| Download URL | mobius_idempotence_library-0.2.1.tar.gz |
|---|---|
| Size | 18.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0f9d360e006b2ce18adfb2a4cbb54a1906dbe63a7f4c6c8335015316ee65fbaf
|
|
BLAKE2b-256 checksum How to use checksums |
6c0b751df8798aa976755111b972c2f0e0329e11a2f8105d6aed225ca2ef3857
|
| 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_library-0.2.1-py3-none-any.whl
| Download URL | mobius_idempotence_library-0.2.1-py3-none-any.whl |
|---|---|
| Size | 20.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b07852ddb6d8714ab7901ae309d644895b982e698440ecd414ef4e8a31fa814b
|
|
BLAKE2b-256 checksum How to use checksums |
13bb1243e43249950eef4bb6607855d46d3d92511de4ffce8484eafb0ca8fe38
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.10.12
|
Security for mobius-idempotence-library 0.2.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.