⚡ fastidempotent
Idempotency middleware & decorator for FastAPI — prevent duplicate side-effects with pluggable storage backends.
Why?
HTTP is inherently unreliable. Clients retry, networks hiccup, and load-balancers replay requests. Without idempotency, a single POST /payments can charge a customer twice.
fastidempotent solves this at the framework level:
- Client sends an
Idempotency-Keyheader with a unique token. - On the first request, the response is executed and cached.
- On any replay, the cached response is returned without re-executing the handler.
- Concurrent duplicates are rejected with
409 Conflictwhile the first is still processing.
Features
- 🎯 Decorator & Middleware — choose per-route
@idempotentor app-wide middleware - 🔌 Pluggable Backends — in-memory, Redis, PostgreSQL, MySQL, SQLite
- ⏱️ Configurable TTL — auto-expire idempotency records after a duration
- 🔒 Concurrency-safe — distributed locking prevents race conditions
- 📦 Async-native — built on
async/awaitthroughout, zero blocking I/O - 🏷️ Fully Typed — PEP 561 compliant with
py.typedmarker - 🧩 Optional Dependencies — install only the backend drivers you need
Installation
# Core (in-memory backend only)
uv add fastidempotent
# With a specific backend
uv add fastidempotent[redis]
uv add fastidempotent[postgres]
uv add fastidempotent[mysql]
uv add fastidempotent[sqlite]
# All backends
uv add fastidempotent[all]
Quick Start
Decorator Approach (per-route)
from fastapi import FastAPI, Request
from fastidempotent import idempotent, MemoryBackend
app = FastAPI()
backend = MemoryBackend(ttl=3600)
@app.post("/payments")
@idempotent(backend=backend)
async def create_payment(request: Request, amount: float):
# this will only execute ONCE per idempotency key
return {"status": "charged", "amount": amount}
Middleware Approach (app-wide)
from fastapi import FastAPI
from fastidempotent import IdempotencyMiddleware, RedisBackend
app = FastAPI()
app.add_middleware(
IdempotencyMiddleware,
backend=RedisBackend(url="redis://localhost:6379"),
ttl=3600,
methods=["POST", "PUT", "PATCH"],
)
@app.post("/orders")
async def create_order(item: str, qty: int):
return {"item": item, "qty": qty, "status": "created"}
Making Idempotent Requests
# first req — executes the handler, caches the resp
curl -X POST http://localhost:8000/payments \
-H "Idempotency-Key: unique-key-123" \
-H "Content-Type: application/json" \
-d '{"amount": 99.99}'
# replay — returns cached resp, handler is NOT called again
curl -X POST http://localhost:8000/payments \
-H "Idempotency-Key: unique-key-123" \
-H "Content-Type: application/json" \
-d '{"amount": 99.99}'
Backend Configuration
In-Memory (default)
Best for dev and testing. Data is lost on restart.
from fastidempotent import MemoryBackend
backend = MemoryBackend(ttl=3600) # records expire after 1hr
Redis
Recommended for prod. Supports distributed deployments.
from fastidempotent import RedisBackend
backend = RedisBackend(
url="redis://localhost:6379/0",
key_prefix="idempotent:",
ttl=3600,
)
PostgreSQL
Uses SQLAlchemy async with asyncpg.
from fastidempotent import PostgresBackend
backend = PostgresBackend(
url="postgresql+asyncpg://user:pass@localhost/mydb",
table_name="idempotency_keys",
ttl=3600,
)
MySQL
Uses SQLAlchemy async with asyncmy.
from fastidempotent import MySQLBackend
backend = MySQLBackend(
url="mysql+asyncmy://user:pass@localhost/mydb",
table_name="idempotency_keys",
ttl=3600,
)
SQLite
Uses SQLAlchemy async with aiosqlite. Great for single-process deployments.
from fastidempotent import SQLiteBackend
backend = SQLiteBackend(
url="sqlite+aiosqlite:///./idempotency.db",
ttl=3600,
)
Configuration
Use env vars or pass settings directly:
from fastidempotent import IdempotencyConfig
config = IdempotencyConfig(
ttl=3600, # record TTL in secs
header_name="Idempotency-Key", # custom header name
enforce_on=["POST", "PUT", "PATCH"], # which methods require a key
optional=False, # if True, missing key skips idempotency
fingerprint_body=True, # include req body in fingerprint
)
Or via .env / environment variables:
IDEMPOTENCY_TTL=3600
IDEMPOTENCY_HEADER_NAME=Idempotency-Key
IDEMPOTENCY_ENFORCE_ON='["POST","PUT","PATCH"]'
IDEMPOTENCY_OPTIONAL=false
IDEMPOTENCY_FINGERPRINT_BODY=true
API Reference
Decorator
| Parameter | Type | Default | Description |
|---|---|---|---|
backend |
BaseBackend |
required | Storage backend instance |
config |
IdempotencyConfig |
None |
Optional config override |
Middleware
| Parameter | Type | Default | Description |
|---|---|---|---|
backend |
BaseBackend |
required | Storage backend instance |
ttl |
int |
3600 |
TTL for cached responses (secs) |
methods |
list[str] |
["POST"] |
HTTP methods to enforce |
header |
str |
"Idempotency-Key" |
Header to read the key from |
optional |
bool |
False |
Skip idempotency if header missing |
Response Headers
| Header | Description |
|---|---|
Idempotency-Key |
Echo of the key used |
X-Idempotent-Replayed |
"true" if this is a cached replay |
How It Works
Client fastidempotent Your Handler
│ │ │
│── POST with Key ──────────▶│ │
│ │── Check backend for key ─────▶│
│ │ │
│ [Key not found] │
│ │── Lock key (status=PENDING) ──│
│ │── Forward to handler ────────▶│
│ │◀── Response ─────────────────│
│ │── Cache response ────────────▶│
│◀── Response ──────────────│ │
│ │ │
│── POST with SAME Key ────▶│ │
│ │── Check backend for key ─────▶│
│ [Key found, status=COMPLETE] │
│◀── Cached Response ──────│ (handler NOT called) │
Development
# clone and install
git clone https://github.com/Swish78/fastidempotent.git
cd fastidempotent
uv sync
# run tests
uv run pytest
# type checking
uv run mypy src/
# linting
uv run ruff check src/ tests/
uv run ruff format src/ tests/
Publishing
# build sdist + wheel
uv build
# publish to PyPI (needs PYPI_TOKEN or UV_PUBLISH_TOKEN)
uv publish
Contributing
Contributions are welcome! Please:
- Fork the repo and create a feature branch
- Add tests for any new functionality
- Ensure
pytest,mypy, andruffall pass - Open a pull request with a clear description
License
Apache-2.0 — see LICENSE for details.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastidempotent-0.1.0.tar.gz.
File metadata
- Download URL: fastidempotent-0.1.0.tar.gz
- Upload date:
- Size: 90.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
91a8d6955a89cd3a490f698e6d618ed63647d2a775ae3a87a99605aba140842a
|
|
| MD5 |
f5c79394369b0dae1bf535aa42bc463a
|
|
| BLAKE2b-256 |
450df92f6e30e81bd787a459f4fbbc4e88107bc04d0ffd4fed897447e04db881
|
File details
Details for the file fastidempotent-0.1.0-py3-none-any.whl.
File metadata
- Download URL: fastidempotent-0.1.0-py3-none-any.whl
- Upload date:
- Size: 31.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7f9a50011401d1c44a7c90cca5a94d642d4a2d70d9e9746d13444193e4782267
|
|
| MD5 |
1a23c82a539ce97b2f2714ba598cbb2a
|
|
| BLAKE2b-256 |
37cb453e3d7d6247fac92afb25b90bf7d64bc090f6f171163d2e10a336369422
|