A function-level idempotency guard that prevents duplicate side effects
Project description
Idempotency
A function-level idempotency guard that prevents duplicate side effects caused by retries, race conditions, or replayed events.
๐ The Problem
You write a function that charges a credit card, sends an email, or creates a database record. Then:
- The request times out
- The caller retries
- The function runs again
- The user gets charged twice
The issue is, most systems rely on:
- API idempotency headers (external, caller-dependent)
- Manual guards scattered everywhere (inconsistent, error-prone)
- Hope (not a strategy)
APIs claim to be idempotent, but they often aren't. This library makes idempotency automatic and declarative at the function level.
๐ข What This Is (and Isn't)
This library provides a missing middle layer between business logic and infrastructure guarantees. It makes idempotency a first-class concept with a clean, declarative API.
| This Library | Not This |
|---|---|
| Execution deduplication | Result caching |
| Side-effect protection | Performance optimization |
| "Same inputs โ same effect, at most once" | "Don't recompute expensive functions" |
Use this for: Payment processing, webhook handlers, job queues, API endpoints with side effects
Don't use this for: Speeding up pure functions (use functools.lru_cache instead)
๐ฅ Installation
pip install idempotency
Optional dependencies:
# For Redis support
pip install idempotency[redis]
# For development
pip install idempotency[dev]
๐ Quick Start
from idempotency import idempotent
@idempotent(ttl=300)
def create_invoice(user_id: int, amount: float) -> dict:
charge_card(user_id, amount) # MUST NOT run twice
send_email(user_id)
return {"invoice_id": 123, "amount": amount}
# First call - executes
result = create_invoice(user_id=1, amount=100.0)
# Second call with same args - returns stored result, no side effects
result = create_invoice(user_id=1, amount=100.0) # No charge, no email!
# Different args - executes again
result = create_invoice(user_id=2, amount=200.0)
๐ How It Works
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. Compute key from function name + arguments โ
โ 2. Check store: not found / in_progress / completed โ
โ 3. Acquire lock (atomic, prevents race conditions) โ
โ 4. Execute function โ
โ 5. Store result + status โ
โ 6. Release lock โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The library tracks execution state (in_progress, completed, failed), not just results. This prevents race conditions and handles crashes gracefully.
๐ชฃ Storage Backends
Choose the right store for your deployment:
| Store | Persistent | Multi-Process | Multi-Server | Use Case |
|---|---|---|---|---|
| MemoryStore | โ | โ | โ | Single-process apps, testing |
| FileStore | โ | โ | โ | Gunicorn workers, Celery tasks |
| RedisStore | โ | โ | โ | Distributed systems, microservices |
MemoryStore (Default)
from idempotency import idempotent
@idempotent(ttl=300) # Uses MemoryStore by default
def my_function():
...
FileStore
from idempotency import idempotent
from idempotency.stores import FileStore
store = FileStore("/tmp/idempotency")
@idempotent(store=store, ttl=300)
def my_function():
...
Features:
- JSON file persistence
- Cross-process locking with
fcntl - Works on Linux and macOS
RedisStore
import redis
from idempotency import idempotent
from idempotency.stores import RedisStore
redis_client = redis.Redis(host="localhost", port=6379)
store = RedisStore(redis_client, prefix="myapp:")
@idempotent(store=store, ttl=300)
def my_function():
...
Features:
- Atomic lock acquisition with Redis SET NX
- Built-in TTL support
- Safe for distributed systems
๐ ๏ธ Configuration Options
TTL (Time-to-Live)
How long to remember that an operation completed:
@idempotent(ttl=300) # 5 minutes
def create_invoice(user_id, amount):
...
After TTL expires, the operation can run again. This prevents permanent locks and handles legitimate retries.
Custom Key Function
By default, the key is generated from function name + all arguments. You can customize this:
@idempotent(
ttl=300,
key=lambda user_id, amount: f"invoice:{user_id}"
)
def create_invoice(user_id, amount):
"""Only one invoice per user, regardless of amount."""
...
Duplicate Behavior
Control what happens when a duplicate call is detected:
# Return stored result (default)
@idempotent(on_duplicate="return")
def create_invoice(user_id, amount):
...
# Raise an error
@idempotent(on_duplicate="raise")
def critical_operation(operation_id):
...
# Wait for first execution to complete
@idempotent(on_duplicate="wait")
def long_running_task(task_id):
...
Failure Handling
Control whether failures are idempotent:
# Allow retry on failure (default)
@idempotent(on_failure="unlock")
def flaky_api_call():
...
# Failures are also idempotent (no retry)
@idempotent(on_failure="lock")
def critical_operation():
...
๐ป Real-World Examples
Webhook Handler
from idempotency import idempotent
from idempotency.stores import RedisStore
store = RedisStore(redis_client, prefix="webhooks:")
@idempotent(store=store, ttl=3600, on_duplicate="return")
def handle_stripe_webhook(event_id: str, payload: dict):
"""Process Stripe webhook - may be delivered multiple times."""
if payload["type"] == "payment_intent.succeeded":
charge_id = payload["data"]["object"]["id"]
update_order_status(charge_id, "paid")
send_confirmation_email(charge_id)
return {"status": "processed"}
Background Job
from idempotency import idempotent
from idempotency.stores import FileStore
store = FileStore("/var/lib/myapp/idempotency")
@idempotent(store=store, ttl=86400) # 24 hours
def process_daily_report(date: str):
"""Generate daily report - should only run once per day."""
data = fetch_analytics(date)
report = generate_pdf(data)
upload_to_s3(report, f"reports/{date}.pdf")
notify_team(f"Report for {date} is ready")
return {"report_url": f"s3://reports/{date}.pdf"}
API Endpoint with Retries
from idempotency import idempotent
from idempotency.stores import RedisStore
store = RedisStore(redis_client)
@idempotent(
store=store,
ttl=300,
key=lambda user_id, **kwargs: f"order:{user_id}:{kwargs.get('idempotency_key')}"
)
def create_order(user_id: int, items: list, idempotency_key: str):
"""Create order with client-provided idempotency key."""
order = db.create_order(user_id=user_id, items=items)
charge_payment(order.total)
send_confirmation(order.id)
return {"order_id": order.id, "total": order.total}
๐บ Error Handling
The decorator preserves exception types and re-raises them:
@idempotent(ttl=300)
def risky_operation():
raise ValueError("Something went wrong")
try:
risky_operation()
except ValueError as e:
print(f"Caught: {e}") # Original exception type preserved
By default, failures unlock the operation so it can be retried. Use on_failure="lock" to make failures idempotent too.
โ๏ธ Testing
The library includes utilities for testing:
from idempotency.stores import MemoryStore
def test_my_function():
store = MemoryStore()
@idempotent(store=store, ttl=300)
def my_function(x):
return x * 2
# First call
assert my_function(5) == 10
# Second call (cached)
assert my_function(5) == 10
# Clear store between tests
store.clear()
๐ฅ๏ธ Development
# Clone the repo
git clone https://github.com/devlarabar/idempotency.git
cd idempotency
# Install dev dependencies
poetry install --with dev
# Run tests
pytest
# Run linter
ruff check .
# Run type checker
mypy idempotency
๐ค Contributing
Contributions welcome! Please:
- Write tests for new features
- Follow existing code style (ruff + mypy)
- Update documentation
- Add examples for new functionality
Project details
Release history Release notifications | RSS feed
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 idempotency-0.1.0.tar.gz.
File metadata
- Download URL: idempotency-0.1.0.tar.gz
- Upload date:
- Size: 20.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7973bb06f475b8d633055e6f6ec31043867d77a7e15823beec298f18fae71a1f
|
|
| MD5 |
a299031579bf19a93461ef053e4bd311
|
|
| BLAKE2b-256 |
a76e6d329b3f8b121e3d5be3449abf8a582213ab88d4d504d141aec8e067a2cf
|
Provenance
The following attestation bundles were made for idempotency-0.1.0.tar.gz:
Publisher:
publish.yaml on devlarabar/idempotency
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
idempotency-0.1.0.tar.gz -
Subject digest:
7973bb06f475b8d633055e6f6ec31043867d77a7e15823beec298f18fae71a1f - Sigstore transparency entry: 938710548
- Sigstore integration time:
-
Permalink:
devlarabar/idempotency@88849e6aae518d5d91eb55bd88dcdd44798255ed -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/devlarabar
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yaml@88849e6aae518d5d91eb55bd88dcdd44798255ed -
Trigger Event:
release
-
Statement type:
File details
Details for the file idempotency-0.1.0-py3-none-any.whl.
File metadata
- Download URL: idempotency-0.1.0-py3-none-any.whl
- Upload date:
- Size: 16.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8fd8a429898999b4db36f77980a85a6967001f9e9ecfab5dbe224e4317e723f1
|
|
| MD5 |
0ca764252398a1225fc9f37e2888cfa3
|
|
| BLAKE2b-256 |
00326ef91a8544865a3ffa734a46c60df7559d4c59dc93061e59084074baaa35
|
Provenance
The following attestation bundles were made for idempotency-0.1.0-py3-none-any.whl:
Publisher:
publish.yaml on devlarabar/idempotency
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
idempotency-0.1.0-py3-none-any.whl -
Subject digest:
8fd8a429898999b4db36f77980a85a6967001f9e9ecfab5dbe224e4317e723f1 - Sigstore transparency entry: 938710567
- Sigstore integration time:
-
Permalink:
devlarabar/idempotency@88849e6aae518d5d91eb55bd88dcdd44798255ed -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/devlarabar
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yaml@88849e6aae518d5d91eb55bd88dcdd44798255ed -
Trigger Event:
release
-
Statement type: