Skip to content

Idempotency

Overview

Idempotency is a critical property of distributed systems that ensures an operation can be performed multiple times with the same result as if it were performed only once. This is essential for building reliable APIs and message handlers, as it allows clients to safely retry requests (e.g., after a network failure) without causing duplicate side effects, such as creating two orders or charging a credit card twice.

The Athomic Layer provides a powerful @idempotent decorator that makes any asynchronous function idempotent by storing its result and using a distributed lock to handle concurrent requests.

Key Features

  • Safe Retries: Protects POST, PUT, and other state-changing operations from creating duplicate resources.
  • Single-Flight Execution: Uses a distributed lock to ensure that for a given idempotency key, the business logic is executed only once, even under high concurrency.
  • Cache-Aside Result Storage: The result of the first successful operation is stored in a Key-Value store. Subsequent requests with the same key receive this stored result immediately.
  • Declarative Usage: Enforce idempotency with a simple and clean decorator.

How It Works: Single-Flight Cache-Aside

The @idempotent decorator orchestrates a sophisticated, race-condition-free workflow:

  1. Key Resolution: When the decorated function is called, a unique idempotency key is generated for that specific operation. This is typically resolved from a request header (e.g., Idempotency-Key) or a field in a message payload.

  2. Cache Check (Attempt 1): The system first checks the configured KV store (e.g., Redis) to see if a result for this key has already been stored. If a result is found, it is returned immediately.

  3. Distributed Lock: If no result is found (a cache miss), the system attempts to acquire a distributed lock for that idempotency key. This is the crucial "Single-Flight" step:

    • Only the first request to arrive will acquire the lock.
    • Any other concurrent requests for the same key will wait for the lock to be released.
  4. Double-Checked Locking: After acquiring the lock, the system checks the cache a second time. This handles the case where another request finished computing the result while the current request was waiting for the lock. If a result is now found, it is returned, and the original function is not executed.

  5. Execution & Storage: If the cache is still empty, the original decorated function (your business logic) is executed. Its result is then stored in the KV store with a configured TTL, and finally, the lock is released.

  6. Conflict: If a request cannot acquire the lock within its timeout, it means the operation is already in progress. In this case, an IdempotencyConflictError is raised, which can be translated to an HTTP 409 Conflict response.


Usage Example

Here is how you would protect a FastAPI endpoint that creates a new resource. The client is expected to provide a unique Idempotency-Key header.

import uuid
from fastapi import Header, Request
from nala.athomic.resilience.idempotency import idempotent, IdempotencyConflictError

@router.post("/orders")
@idempotent(
    # The key resolver is a lambda that extracts the key from the request kwargs.
    key=lambda request, **kwargs: request.headers.get("Idempotency-Key"),
    lock_timeout=10 # Wait up to 10s for a concurrent request to finish.
)
async def create_order(request: Request, order_data: dict):
    """
    Creates a new order. This operation is idempotent.
    """
    # This logic will only be executed ONCE for a given Idempotency-Key.
    order_id = await order_service.create(order_data)
    return {"status": "created", "order_id": order_id}

# You would typically have an exception handler to catch the conflict error.
@app.exception_handler(IdempotencyConflictError)
async def idempotency_conflict_handler(request: Request, exc: IdempotencyConflictError):
    return JSONResponse(
        status_code=409,
        content={"message": "Request conflict: An identical request is already being processed."},
    )

Configuration

The idempotency module is configured under the [resilience.idempotency] section in your settings.toml. It requires a KV store for storing results and managing locks.

[default.resilience.idempotency]
enabled = true

# The default Time-To-Live in seconds for stored idempotency results.
default_ttl_seconds = 86400 # 24 hours

# The default time in seconds to wait for a distributed lock.
default_lock_timeout_seconds = 10

  # The KVStore configuration for storing results and locks.
  [default.resilience.idempotency.kvstore]
  namespace = "idempotency"
    [default.resilience.idempotency.kvstore.provider]
    backend = "redis"
    uri = "redis://localhost:6379/6"

API Reference

nala.athomic.resilience.idempotency.decorator.idempotent(key, ttl=None, lock_timeout=None, storage=None, locker=None)

Decorator to make an asynchronous function idempotent.

The primary goal is to ensure that a function is executed logically only once for a given idempotency key, returning the cached result on all subsequent calls within the key's TTL.

Parameters:

Name Type Description Default
key ContextualKeyResolverType

A resolver mechanism to generate the unique key from function arguments. Can be a callable (function/lambda) or an f-string template referencing kwargs (e.g., "order:{order_id}").

required
ttl Optional[int]

Time-to-live (in seconds) for the stored result. Uses configured default if None.

None
lock_timeout Optional[int]

Time (in seconds) to wait for the distributed lock during cache miss before raising a conflict error. Uses configured default if None.

None
storage Optional[KVStoreProtocol]

Optional storage provider instance (for DI/testing).

None
locker Optional[LockingProtocol]

Optional distributed locking provider instance (for DI/testing).

None

Returns:

Name Type Description
Callable Callable[..., Any]

The decorator function.

nala.athomic.resilience.idempotency.exceptions.IdempotencyConflictError

Bases: IdempotencyError

Raised when an idempotent operation is attempted for a key that is already being processed by a concurrent request.

nala.athomic.resilience.idempotency.handler.IdempotencyHandler

Orchestrates the entire idempotency check and execution logic.

Supports two modes: 1. Cache-Aside (Single-Flight): For RPC/HTTP where the result is computed, locked, and cached. (Via execute method). 2. Batch Filtering: For message consumers where we lock/filter duplicates before processing using atomic batch operations. (Via acquire_batch method).

__init__(func=None, args=None, kwargs=None, key_resolver=None, ttl=None, lock_timeout=None, storage=None, locker=None, settings=None)

Initializes the IdempotencyHandler.

Parameters:

Name Type Description Default
func Optional[Callable]

The function being decorated. Optional for Batch mode.

None
args Optional[Tuple]

Positional arguments. Optional for Batch mode.

None
kwargs Optional[Dict]

Keyword arguments. Optional for Batch mode.

None
key_resolver Optional[ContextualKeyResolverType]

Mechanism to generate the unique key.

None
ttl Optional[int]

Time-to-live for the stored result/lock.

None
lock_timeout Optional[int]

Time to wait to acquire the distributed lock.

None
storage Optional[KVIdempotencyStorage]

Storage provider instance.

None
locker Optional[LockingProtocol]

Distributed locking provider instance.

None
settings Optional[IdempotencySettings]

Settings instance.

None

acquire_batch(keys, ttl) async

Attempts to acquire locks for multiple keys in a single atomic batch operation. Used for 'Filter & Forward' patterns in consumers.

Parameters:

Name Type Description Default
keys List[str]

List of LOGICAL keys from the message.

required
ttl int

Time to live for the lock.

required

Returns:

Type Description
Dict[str, bool]

Dict[str, bool]: Map of {logical_key: success}.

confirm_batch(keys, ttl) async

Marks a batch of keys as COMPLETED. Updates the state in storage to prevent re-processing.

execute() async

Executes the full idempotency flow (Cache-Aside + Single Flight). Used primarily for decorators on single methods.

release_batch(keys) async

Releases locks for a batch of keys (Rollback). Used when the batch processing fails to allow immediate retry.