Skip to main content

A function-level idempotency guard that prevents duplicate side effects

Project description

Idempotency Library Banner

Idempotency

A function-level idempotency guard that prevents duplicate side effects caused by retries, race conditions, or replayed events.

Python 3.10+ License: MIT

๐Ÿ›‘ 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:

  1. Write tests for new features
  2. Follow existing code style (ruff + mypy)
  3. Update documentation
  4. Add examples for new functionality

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

idempotency-0.1.0.tar.gz (20.8 kB view details)

Uploaded Source

Built Distribution

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

idempotency-0.1.0-py3-none-any.whl (16.5 kB view details)

Uploaded Python 3

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

Hashes for idempotency-0.1.0.tar.gz
Algorithm Hash digest
SHA256 7973bb06f475b8d633055e6f6ec31043867d77a7e15823beec298f18fae71a1f
MD5 a299031579bf19a93461ef053e4bd311
BLAKE2b-256 a76e6d329b3f8b121e3d5be3449abf8a582213ab88d4d504d141aec8e067a2cf

See more details on using hashes here.

Provenance

The following attestation bundles were made for idempotency-0.1.0.tar.gz:

Publisher: publish.yaml on devlarabar/idempotency

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for idempotency-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8fd8a429898999b4db36f77980a85a6967001f9e9ecfab5dbe224e4317e723f1
MD5 0ca764252398a1225fc9f37e2888cfa3
BLAKE2b-256 00326ef91a8544865a3ffa734a46c60df7559d4c59dc93061e59084074baaa35

See more details on using hashes here.

Provenance

The following attestation bundles were made for idempotency-0.1.0-py3-none-any.whl:

Publisher: publish.yaml on devlarabar/idempotency

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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