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:
-
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. -
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.
-
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.
-
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.
-
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.
-
Conflict: If a request cannot acquire the lock within its timeout, it means the operation is already in progress. In this case, an
IdempotencyConflictErroris raised, which can be translated to anHTTP 409 Conflictresponse.
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.