Skip to main content

⚡ fastidempotent

Idempotency middleware & decorator for FastAPI — prevent duplicate side-effects with pluggable storage backends.

PyPI version Python License: Apache-2.0 Tests


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-Key header 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 Conflict while the first is still processing.

Features

  • 🎯 Decorator & Middleware — choose per-route @idempotent or 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/await throughout, zero blocking I/O
  • 🏷️ Fully Typed — PEP 561 compliant with py.typed marker
  • 🧩 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:

  1. Fork the repo and create a feature branch
  2. Add tests for any new functionality
  3. Ensure pytest, mypy, and ruff all pass
  4. 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

fastidempotent-0.1.0.tar.gz (90.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastidempotent-0.1.0-py3-none-any.whl (31.9 kB view details)

Uploaded Python 3

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

Hashes for fastidempotent-0.1.0.tar.gz
Algorithm Hash digest
SHA256 91a8d6955a89cd3a490f698e6d618ed63647d2a775ae3a87a99605aba140842a
MD5 f5c79394369b0dae1bf535aa42bc463a
BLAKE2b-256 450df92f6e30e81bd787a459f4fbbc4e88107bc04d0ffd4fed897447e04db881

See more details on using hashes here.

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

Hashes for fastidempotent-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7f9a50011401d1c44a7c90cca5a94d642d4a2d70d9e9746d13444193e4782267
MD5 1a23c82a539ce97b2f2714ba598cbb2a
BLAKE2b-256 37cb453e3d7d6247fac92afb25b90bf7d64bc090f6f171163d2e10a336369422

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page