Metadata-Version: 2.4
Name: mobius-idempotence-library
Version: 0.2.1
Summary: Idempotency-Key middleware for FastAPI, with pluggable Redis / in-memory storage backends.
Project-URL: Homepage, https://github.com/yourorg/mobius-idempotence-library
Project-URL: Repository, https://github.com/yourorg/mobius-idempotence-library
Project-URL: Issues, https://github.com/yourorg/mobius-idempotence-library/issues
Author-email: Your Name <you@example.com>
License: MIT
License-File: LICENSE
Keywords: fastapi,idempotency,middleware,payments,redis
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: fastapi>=0.100
Requires-Dist: starlette>=0.27
Provides-Extra: dev
Requires-Dist: fakeredis[lupa]>=2.20; extra == 'dev'
Requires-Dist: httpx>=0.24; extra == 'dev'
Requires-Dist: mypy>=1.5; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Requires-Dist: redis>=4.5; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: redis
Requires-Dist: redis>=4.5; extra == 'redis'
Provides-Extra: test
Requires-Dist: fakeredis[lupa]>=2.20; extra == 'test'
Requires-Dist: httpx>=0.24; extra == 'test'
Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
Requires-Dist: pytest>=7.4; extra == 'test'
Description-Content-Type: text/markdown

# 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-Key` header 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) and `MemoryBackend` (in-process, for tests
  or single-instance deployments). Implement `StorageBackend` for 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

```bash
pip install mobius-idempotence-library          # in-memory backend only
pip install mobius-idempotence-library[redis]   # + Redis backend
```

## Quick start

```python
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-Key` header
- 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:

```bash
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:

```python
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:

```python
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

```bash
pip install -e ".[dev]"
pytest
```

## License

MIT
