Idempotency
The idempotency utility allows you to retry operations within a time window with the same input, producing the same output.
Key features¶
- Produces the previous successful result when a function is called repeatedly with the same idempotency key
- Choose your idempotency key from one or more fields, or entire payload
- Safeguard concurrent requests, timeouts, missing idempotency keys, and payload tampering
- Support for Amazon DynamoDB, Redis, bring your own persistence layer, and in-memory caching
Terminology¶
The property of idempotency means that an operation does not cause additional side effects if it is called more than once with the same input parameters.
Idempotency key is a combination of (a) Lambda function name, (b) fully qualified name of your function, and (c) a hash of the entire payload or part(s) of the payload you specify.
Idempotent request is an operation with the same input previously processed that is not expired in your persistent storage or in-memory cache.
Persistence layer is a storage we use to create, read, expire, and delete idempotency records.
Idempotency record is the data representation of an idempotent request saved in the persistent layer and in its various status. We use it to coordinate whether (a) a request is idempotent, (b) it's not expired, (c) JSON response to return, and more.
classDiagram
direction LR
class IdempotencyRecord {
idempotency_key str
status Status
expiry_timestamp int
in_progress_expiry_timestamp int
response_data str~JSON~
payload_hash str
}
class Status {
<<Enumeration>>
INPROGRESS
COMPLETE
EXPIRED internal_only
}
IdempotencyRecord -- Status
Idempotency record representation
Getting started¶
We use Amazon DynamoDB as the default persistence layer in the documentation. If you prefer Redis, you can learn more from this section.
IAM Permissions¶
When using Amazon DynamoDB as the persistence layer, you will need the following IAM permissions:
IAM Permission | Operation |
---|---|
dynamodb:GetItem |
Retrieve idempotent record (strong consistency) |
dynamodb:PutItem |
New idempotent records, replace expired idempotent records |
dynamodb:UpdateItem |
Complete idempotency transaction, and/or update idempotent records state |
dynamodb:DeleteItem |
Delete idempotent records for unsuccessful idempotency transactions |
First time setting it up?
We provide Infrastrucure as Code examples with AWS Serverless Application Model (SAM), AWS Cloud Development Kit (CDK), and Terraform with the required permissions.
Required resources¶
To start, you'll need:
-
Persistent storage
-
AWS Lambda function
With permissions to use your persistent storage
Primary key for any persistence storage
We combine the Lambda function name and the fully qualified name for classes/functions to prevent accidental reuse for similar code sharing input/output.
Primary key sample: {lambda_fn_name}.{module_name}.{fn_qualified_name}#{idempotency_key_hash}
DynamoDB table¶
Unless you're looking to use an existing table or customize each attribute, you only need the following:
Configuration | Value | Notes |
---|---|---|
Partition key | id |
|
TTL attribute name | expiration |
Using AWS Console? This is configurable after table creation |
You can use a single DynamoDB table for all functions annotated with Idempotency.
DynamoDB IaC examples¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
|
`
Limitations¶
-
DynamoDB restricts item sizes to 400KB. This means that if your annotated function's response must be smaller than 400KB, otherwise your function will fail. Consider Redis as an alternative.
-
Expect 2 WCU per non-idempotent call. During the first invocation, we use
PutItem
for locking andUpdateItem
for completion. Consider reviewing DynamoDB pricing documentation to estimate cost. -
Old boto3 versions can increase costs. For cost optimization, we use a conditional
PutItem
to always lock a new idempotency record. If locking fails, it means we already have an idempotency record saving us an additionalGetItem
call. However, this is only supported in boto31.26.194
and higher (June 30th 2023).
Redis database¶
We recommend you start with a Redis compatible management services such as Amazon ElastiCache for Redis or Amazon MemoryDB for Redis.
In both services, you'll need to configure VPC access to your AWS Lambda.
Redis IaC examples¶
Prefer AWS Console/CLI?
Follow the official tutorials for Amazon ElastiCache for Redis or Amazon MemoryDB for Redis
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
- Replace the Security Group ID and Subnet ID to match your VPC settings.
- Replace the Security Group ID and Subnet ID to match your VPC settings.
Once setup, you can find a quick start and advanced examples for Redis in the persistent layers section.
Idempotent decorator¶
For simple use cases, you can use the idempotent
decorator on your Lambda handler function.
It will treat the entire event as an idempotency key. That is, the same event will return the previously stored result within a configurable time window (1 hour, by default).
You can also choose one or more fields as an idempotency key.
getting_started_with_idempotency.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
getting_started_with_idempotency_payload.json | |
---|---|
1 2 3 4 |
|
Idempotent_function decorator¶
For full flexibility, you can use the idempotent_function
decorator for any synchronous Python function.
When using this decorator, you must call your decorated function using keyword arguments.
You can use data_keyword_argument
to tell us the argument to extract an idempotency key. We support JSON serializable data, Dataclasses, Pydantic Models, and Event Source Data Classes
working_with_idempotent_function_dataclass.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
|
- Notice how
data_keyword_argument
matches the name of the parameter.
This allows us to extract one or all fields as idempotency key. - Different from
idempotent
decorator, we must explicitly register the Lambda context to protect against timeouts.
working_with_idempotent_function_pydantic.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
Output serialization¶
By default, idempotent_function
serializes, stores, and returns your annotated function's result as a JSON object. You can change this behavior using output_serializer
parameter.
The output serializer supports any JSON serializable data, Python Dataclasses and Pydantic Models.
When using the output_serializer
parameter, the data will continue to be stored in your persistent storage as a JSON string.
Use PydanticSerializer
to automatically serialize what's retrieved from the persistent storage based on the return type annotated.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
- We'll use
OrderOutput
to instantiate a new object using the data retrieved from persistent storage as input.
This ensures the return of the function is not impacted when@idempotent_function
is used.
Alternatively, you can provide an explicit model as an input to PydanticSerializer
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
|
Use DataclassSerializer
to automatically serialize what's retrieved from the persistent storage based on the return type annotated.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
|
- We'll use
OrderOutput
to instantiate a new object using the data retrieved from persistent storage as input.
This ensures the return of the function is not impacted when@idempotent_function
is used.
Alternatively, you can provide an explicit model as an input to DataclassSerializer
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
|
Use CustomDictSerializer
to have full control over the serialization process for any type. It expects two functions:
- to_dict. Function to convert any type to a JSON serializable dictionary before it saves into the persistent storage.
- from_dict. Function to convert from a dictionary retrieved from persistent storage and serialize in its original form.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
|
- This function does the following
1. Receives the return fromprocess_order
2. Converts to dictionary before it can be saved into the persistent storage. - This function does the following
1. Receives the dictionary saved into the persistent storage
1 Serializes toOrderOutput
before@idempotent
returns back to the caller. - This serializer receives both functions so it knows who to call when to serialize to and from dictionary.
Using in-memory cache¶
In-memory cache is local to each Lambda execution environment.
You can enable caching with the use_local_cache
parameter in IdempotencyConfig
. When enabled, you can adjust cache capacity (256) with local_cache_max_items
.
By default, caching is disabled since we don't know how big your response could be in relation to your configured memory size.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
- You can adjust cache capacity with
local_cache_max_items
parameter.
1 2 3 |
|
Choosing a payload subset¶
Tip: Dealing with always changing payloads
When dealing with a more elaborate payload, where parts of the payload always change, you should use event_key_jmespath
parameter.
Use event_key_jmespath
parameter in IdempotencyConfig
to select one or more payload parts as your idempotency key.
Example scenario
In this example, we have a Lambda handler that creates a payment for a user subscribing to a product. We want to ensure that we don't accidentally charge our customer by subscribing them more than once.
Imagine the function runs successfully, but the client never receives the response due to a connection issue. It is safe to immediately retry in this instance, as the idempotent decorator will return a previously saved response.
We want to use user_id
and product_id
fields as our idempotency key. If we were to treat the entire request as our idempotency key, a simple HTTP header change would cause our function to run again.
Deserializing JSON strings in payloads for increased accuracy.
The payload extracted by the event_key_jmespath
is treated as a string by default.
This means there could be differences in whitespace even when the JSON payload itself is identical.
To alter this behaviour, we can use the JMESPath built-in function powertools_json()
to treat the payload as a JSON object (dict) rather than a string.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
Adjusting expiration window¶
By default, we expire idempotency records after an hour (3600 seconds). After that, a transaction with the same payload will not be considered idempotent.
You can change this expiration window with the expires_after_seconds
parameter. There is no limit on how long this expiration window can be set to.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
1 2 3 |
|
Idempotency record expiration vs DynamoDB time-to-live (TTL)
DynamoDB TTL is a feature to remove items after a certain period of time, it may occur within 48 hours of expiration.
We don't rely on DynamoDB or any persistence storage layer to determine whether a record is expired to avoid eventual inconsistency states.
Instead, Idempotency records saved in the storage layer contain timestamps that can be verified upon retrieval and double checked within Idempotency feature.
Why?
A record might still be valid (COMPLETE
) when we retrieved, but in some rare cases it might expire a second later. A record could also be cached in memory. You might also want to have idempotent transactions that should expire in seconds.
Lambda timeouts¶
You can skip this section if you are using the @idempotent
decorator
By default, we protect against concurrent executions with the same payload using a locking mechanism. However, if your Lambda function times out before completing the first invocation it will only accept the same request when the idempotency record expire.
To prevent extended failures, use register_lambda_context
function from your idempotency config to calculate and include the remaining invocation time in your idempotency record.
working_with_lambda_timeout.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
Mechanics
If a second invocation happens after this timestamp, and the record is marked as INPROGRESS
, we will run the invocation again as if it was in the EXPIRED
state.
This means that if an invocation expired during execution, it will be quickly executed again on the next retry.
Handling exceptions¶
There are two failure modes that can cause new invocations to execute your code again despite having the same payload:
- Unhandled exception. We catch them to delete the idempotency record to prevent inconsistencies, then propagate them.
- Persistent layer errors. We raise
IdempotencyPersistenceLayerError
for any persistence layer errors e.g., remove idempotency record.
If an exception is handled or raised outside your decorated function, then idempotency will be maintained.
working_with_exceptions.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
Persistence layers¶
DynamoDBPersistenceLayer¶
This persistence layer is built-in, allowing you to use an existing DynamoDB table or create a new one dedicated to idempotency state (recommended).
customize_persistence_layer.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Using a composite primary key¶
Use sort_key_attr
parameter when your table is configured with a composite primary key (hash+range key).
When enabled, we will save the idempotency key in the sort key instead. By default, the primary key will now be set to idempotency#{LAMBDA_FUNCTION_NAME}
.
You can optionally set a static value for the partition key using the static_pk_value
parameter.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
1 2 3 |
|
Click to expand and learn how table items would look like
id | sort_key | expiration | status | data |
---|---|---|---|---|
idempotency#MyLambdaFunction | 1e956ef7da78d0cb890be999aecc0c9e | 1636549553 | COMPLETED | {"user_id": 12391, "message": "success"} |
idempotency#MyLambdaFunction | 2b2cdb5f86361e97b4383087c1ffdf27 | 1636549571 | COMPLETED | {"user_id": 527212, "message": "success"} |
idempotency#MyLambdaFunction | f091d2527ad1c78f05d54cc3f363be80 | 1636549585 | IN_PROGRESS |
DynamoDB attributes¶
You can customize the attribute names during initialization:
Parameter | Required | Default | Description |
---|---|---|---|
table_name | Table name to store state | ||
key_attr | id |
Partition key of the table. Hashed representation of the payload (unless sort_key_attr is specified) | |
expiry_attr | expiration |
Unix timestamp of when record expires | |
in_progress_expiry_attr | in_progress_expiration |
Unix timestamp of when record expires while in progress (in case of the invocation times out) | |
status_attr | status |
Stores status of the lambda execution during and after invocation | |
data_attr | data |
Stores results of successfully executed Lambda handlers | |
validation_key_attr | validation |
Hashed representation of the parts of the event used for validation | |
sort_key_attr | Sort key of the table (if table is configured with a sort key). | ||
static_pk_value | idempotency#{LAMBDA_FUNCTION_NAME} |
Static value to use as the partition key. Only used when sort_key_attr is set. |
RedisPersistenceLayer¶
We recommend Redis version 7 or higher for optimal performance.
For simple setups, initialize RedisCachePersistenceLayer
with your Redis endpoint and port to connect.
For security, we enforce SSL connections by default; to disable it, set ssl=False
.
getting_started_with_idempotency_redis_config.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
|
getting_started_with_idempotency_redis_client.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
|
getting_started_with_idempotency_payload.json | |
---|---|
1 2 3 4 |
|
Redis SSL connections¶
We recommend using AWS Secrets Manager to store and rotate certificates safely, and the Parameters feature to fetch and cache optimally.
For advanced configurations, we recommend using an existing Redis client for optimal compatibility like SSL certificates and timeout.
using_redis_client_with_aws_secrets.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
- JSON stored:
1 2 3 4 5
{ "REDIS_ENDPOINT": "127.0.0.1", "REDIS_PORT": "6379", "REDIS_PASSWORD": "redis-secret" }
using_redis_client_with_local_certs.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
- JSON stored:
1 2 3 4 5
{ "REDIS_ENDPOINT": "127.0.0.1", "REDIS_PORT": "6379", "REDIS_PASSWORD": "redis-secret" }
- redis_user.crt file stored in the "certs" directory of your Lambda function
- redis_user_private.key file stored in the "certs" directory of your Lambda function
- redis_ca.pem file stored in the "certs" directory of your Lambda function
Redis attributes¶
You can customize the attribute names during initialization:
Parameter | Required | Default | Description |
---|---|---|---|
in_progress_expiry_attr | in_progress_expiration |
Unix timestamp of when record expires while in progress (in case of the invocation times out) | |
status_attr | status |
Stores status of the Lambda execution during and after invocation | |
data_attr | data |
Stores results of successfully executed Lambda handlers | |
validation_key_attr | validation |
Hashed representation of the parts of the event used for validation |
customize_persistence_layer_redis.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
Common use cases¶
Batch processing¶
You can can easily integrate with Batch using the idempotent_function decorator to handle idempotency per message/record in a given batch.
Choosing an unique batch record attribute
In this example, we choose messageId
as our idempotency key since we know it'll be unique.
Depending on your use case, it might be more accurate to choose another field your producer intentionally set to define uniqueness.
integrate_idempotency_with_batch_processor.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
integrate_idempotency_with_batch_processor_payload.json | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
Idempotency request flow¶
The following sequence diagrams explain how the Idempotency feature behaves under different scenarios.
Successful request¶
sequenceDiagram
participant Client
participant Lambda
participant Persistence Layer
alt initial request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
Lambda-->>Lambda: Call your function
Lambda->>Persistence Layer: Update record with result
deactivate Persistence Layer
Persistence Layer-->>Persistence Layer: Update record
Note over Lambda,Persistence Layer: Set record status to COMPLETE. <br> New invocations with the same payload <br> now return the same result
Lambda-->>Client: Response sent to client
else retried request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Persistence Layer-->>Lambda: Already exists in persistence layer.
deactivate Persistence Layer
Note over Lambda,Persistence Layer: Record status is COMPLETE and not expired
Lambda-->>Client: Same response sent to client
end
Idempotent successful request
Successful request with cache enabled¶
sequenceDiagram
participant Client
participant Lambda
participant Persistence Layer
alt initial request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
Lambda-->>Lambda: Call your function
Lambda->>Persistence Layer: Update record with result
deactivate Persistence Layer
Persistence Layer-->>Persistence Layer: Update record
Note over Lambda,Persistence Layer: Set record status to COMPLETE. <br> New invocations with the same payload <br> now return the same result
Lambda-->>Lambda: Save record and result in memory
Lambda-->>Client: Response sent to client
else retried request
Client->>Lambda: Invoke (event)
Lambda-->>Lambda: Get idempotency_key=hash(payload)
Note over Lambda,Persistence Layer: Record status is COMPLETE and not expired
Lambda-->>Client: Same response sent to client
end
Idempotent successful request cached
Successful request with response_hook configured¶
sequenceDiagram
participant Client
participant Lambda
participant Response hook
participant Persistence Layer
alt initial request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
Lambda-->>Lambda: Call your function
Lambda->>Persistence Layer: Update record with result
deactivate Persistence Layer
Persistence Layer-->>Persistence Layer: Update record
Note over Lambda,Persistence Layer: Set record status to COMPLETE. <br> New invocations with the same payload <br> now return the same result
Lambda-->>Client: Response sent to client
else retried request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Persistence Layer-->>Response hook: Already exists in persistence layer.
deactivate Persistence Layer
Note over Response hook,Persistence Layer: Record status is COMPLETE and not expired
Response hook->>Lambda: Response hook invoked
Lambda-->>Client: Manipulated idempotent response sent to client
end
Successful idempotent request with a response hook
Expired idempotency records¶
sequenceDiagram
participant Client
participant Lambda
participant Persistence Layer
alt initial request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
Lambda-->>Lambda: Call your function
Lambda->>Persistence Layer: Update record with result
deactivate Persistence Layer
Persistence Layer-->>Persistence Layer: Update record
Note over Lambda,Persistence Layer: Set record status to COMPLETE. <br> New invocations with the same payload <br> now return the same result
Lambda-->>Client: Response sent to client
else retried request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Persistence Layer-->>Lambda: Already exists in persistence layer.
deactivate Persistence Layer
Note over Lambda,Persistence Layer: Record status is COMPLETE but expired hours ago
loop Repeat initial request process
Note over Lambda,Persistence Layer: 1. Set record to INPROGRESS, <br> 2. Call your function, <br> 3. Set record to COMPLETE
end
Lambda-->>Client: Same response sent to client
end
Previous Idempotent request expired
Concurrent identical in-flight requests¶
sequenceDiagram
participant Client
participant Lambda
participant Persistence Layer
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
par Second request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
Lambda--xLambda: IdempotencyAlreadyInProgressError
Lambda->>Client: Error sent to client if unhandled
end
Lambda-->>Lambda: Call your function
Lambda->>Persistence Layer: Update record with result
deactivate Persistence Layer
Persistence Layer-->>Persistence Layer: Update record
Note over Lambda,Persistence Layer: Set record status to COMPLETE. <br> New invocations with the same payload <br> now return the same result
Lambda-->>Client: Response sent to client
Concurrent identical in-flight requests
Unhandled exception¶
sequenceDiagram
participant Client
participant Lambda
participant Persistence Layer
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set (id=event.search(payload))
activate Persistence Layer
Note right of Persistence Layer: Locked during this time. Prevents multiple<br/>Lambda invocations with the same<br/>payload running concurrently.
Lambda--xLambda: Call handler (event).<br/>Raises exception
Lambda->>Persistence Layer: Delete record (id=event.search(payload))
deactivate Persistence Layer
Lambda-->>Client: Return error response
Idempotent sequence exception
Lambda request timeout¶
sequenceDiagram
participant Client
participant Lambda
participant Persistence Layer
alt initial request
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
Lambda-->>Lambda: Call your function
Note right of Lambda: Time out
Lambda--xLambda: Time out error
Lambda-->>Client: Return error response
deactivate Persistence Layer
else retry after Lambda timeout elapses
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Reset in_progress_expiry attribute
Lambda-->>Lambda: Call your function
Lambda->>Persistence Layer: Update record with result
deactivate Persistence Layer
Persistence Layer-->>Persistence Layer: Update record
Lambda-->>Client: Response sent to client
end
Idempotent request during and after Lambda timeouts
Optional idempotency key¶
sequenceDiagram
participant Client
participant Lambda
participant Persistence Layer
alt request with idempotency key
Client->>Lambda: Invoke (event)
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
activate Persistence Layer
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
Lambda-->>Lambda: Call your function
Lambda->>Persistence Layer: Update record with result
deactivate Persistence Layer
Persistence Layer-->>Persistence Layer: Update record
Note over Lambda,Persistence Layer: Set record status to COMPLETE. <br> New invocations with the same payload <br> now return the same result
Lambda-->>Client: Response sent to client
else request(s) without idempotency key
Client->>Lambda: Invoke (event)
Note over Lambda: Idempotency key is missing
Note over Persistence Layer: Skips any operation to fetch, update, and delete
Lambda-->>Lambda: Call your function
Lambda-->>Client: Response sent to client
end
Optional idempotency key
Race condition with Redis¶
graph TD;
A(Existing orphan record in redis)-->A1;
A1[Two Lambda invoke at same time]-->B1[Lambda handler1];
B1-->B2[Fetch from Redis];
B2-->B3[Handler1 got orphan record];
B3-->B4[Handler1 acquired lock];
B4-->B5[Handler1 overwrite orphan record]
B5-->B6[Handler1 continue to execution];
A1-->C1[Lambda handler2];
C1-->C2[Fetch from Redis];
C2-->C3[Handler2 got orphan record];
C3-->C4[Handler2 failed to acquire lock];
C4-->C5[Handler2 wait and fetch from Redis];
C5-->C6[Handler2 return without executing];
B6-->D(Lambda handler executed only once);
C6-->D;
Race condition with Redis
Advanced¶
Customizing the default behavior¶
You can override and further extend idempotency behavior via IdempotencyConfig
with the following options:
Parameter | Default | Description |
---|---|---|
event_key_jmespath | "" |
JMESPath expression to extract the idempotency key from the event record using built-in functions |
payload_validation_jmespath | "" |
JMESPath expression to validate whether certain parameters have changed in the event while the event payload e.g., payload tampering. |
raise_on_no_idempotency_key | False |
Raise exception if no idempotency key was found in the request |
expires_after_seconds | 3600 | The number of seconds to wait before a record is expired, allowing a new transaction with the same idempotency key |
use_local_cache | False |
Whether to cache idempotency results in-memory to save on persistence storage latency and costs |
local_cache_max_items | 256 | Max number of items to store in local cache |
hash_function | md5 |
Function to use for calculating hashes, as provided by hashlib in the standard library. |
response_hook | None |
Function to use for processing the stored Idempotent response. This function hook is called when an existing idempotent response is found. See Manipulating The Idempotent Response |
Handling concurrent executions with the same payload¶
This utility will raise an IdempotencyAlreadyInProgressError
exception if you receive multiple invocations with the same payload while the first invocation hasn't completed yet.
Info
If you receive IdempotencyAlreadyInProgressError
, you can safely retry the operation.
This is a locking mechanism for correctness. Since we don't know the result from the first invocation yet, we can't safely allow another concurrent execution.
Payload validation¶
Question: What if your function is invoked with the same payload except some outer parameters have changed?
Example: A payment transaction for a given productID was requested twice for the same customer, however the amount to be paid has changed in the second transaction.
By default, we will return the same result as it returned before, however in this instance it may be misleading; we provide a fail fast payload validation to address this edge case.
With payload_validation_jmespath
, you can provide an additional JMESPath expression to specify which part of the event body should be validated against previous idempotent invocations
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
|
1 2 3 4 5 6 |
|
1 2 3 4 5 6 |
|
In this example, the user_id
and product_id
keys are used as the payload to generate the idempotency key, as per event_key_jmespath
parameter.
Note
If we try to send the same request but with a different amount, we will raise IdempotencyValidationError
.
Without payload validation, we would have returned the same result as we did for the initial request. Since we're also returning an amount in the response, this could be quite confusing for the client.
By using payload_validation_jmespath="amount"
, we prevent this potentially confusing behavior and instead raise an Exception.
Making idempotency key required¶
If you want to enforce that an idempotency key is required, you can set raise_on_no_idempotency_key
to True
.
This means that we will raise IdempotencyKeyError
if the evaluation of event_key_jmespath
is None
.
Warning
To prevent errors, transactions will not be treated as idempotent if raise_on_no_idempotency_key
is set to False
and the evaluation of event_key_jmespath
is None
. Therefore, no data will be fetched, stored, or deleted in the idempotency storage layer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
1 2 3 4 5 6 7 |
|
1 2 3 4 5 6 7 |
|
Customizing boto configuration¶
The boto_config
and boto3_session
parameters enable you to pass in a custom botocore config object or a custom boto3 session when constructing the persistence store.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
1 2 3 |
|
Bring your own persistent store¶
This utility provides an abstract base class (ABC), so that you can implement your choice of persistent storage layer.
You can create your own persistent store from scratch by inheriting the BasePersistenceLayer
class, and implementing _get_record()
, _put_record()
, _update_record()
and _delete_record()
.
_get_record()
– Retrieves an item from the persistence store using an idempotency key and returns it as aDataRecord
instance._put_record()
– Adds aDataRecord
to the persistence store if it doesn't already exist with that key. Raises anItemAlreadyExists
exception if a non-expired entry already exists._update_record()
– Updates an item in the persistence store._delete_record()
– Removes an item from the persistence store.
bring_your_own_persistent_store.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
|
Danger
Pay attention to the documentation for each - you may need to perform additional checks inside these methods to ensure the idempotency guarantees remain intact.
For example, the _put_record
method needs to raise an exception if a non-expired record already exists in the data store with a matching key.
Manipulating the Idempotent Response¶
You can set up a response_hook
in the IdempotentConfig
class to manipulate the returned data when an operation is idempotent. The hook function will be called with the current deserialized response object and the Idempotency record.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
|
1 2 3 4 5 6 7 8 |
|
Info: Using custom de-serialization?
The response_hook is called after the custom de-serialization so the payload you process will be the de-serialized version.
Being a good citizen¶
When using response hooks to manipulate returned data from idempotent operations, it's important to follow best practices to avoid introducing complexity or issues. Keep these guidelines in mind:
-
Response hook works exclusively when operations are idempotent. The hook will not be called when an operation is not idempotent, or when the idempotent logic fails.
-
Catch and Handle Exceptions. Your response hook code should catch and handle any exceptions that may arise from your logic. Unhandled exceptions will cause the Lambda function to fail unexpectedly.
-
Keep Hook Logic Simple Response hooks should consist of minimal and straightforward logic for manipulating response data. Avoid complex conditional branching and aim for hooks that are easy to reason about.
Compatibility with other utilities¶
JSON Schema Validation¶
The idempotency utility can be used with the validator
decorator. Ensure that idempotency is the innermost decorator.
Warning
If you use an envelope with the validator, the event received by the idempotency utility will be the unwrapped event - not the "raw" event Lambda was invoked with.
Make sure to account for this behavior, if you set the event_key_jmespath
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
|
Tip: JMESPath Powertools for AWS Lambda (Python) functions are also available
Built-in functions known in the validation utility like powertools_json
, powertools_base64
, powertools_base64_gzip
are also available to use in this utility.
Tracer¶
The idempotency utility can be used with the tracer
decorator. Ensure that idempotency is the innermost decorator.
First execution¶
During the first execution with a payload, Lambda performs a PutItem
followed by an UpdateItem
operation to persist the record in DynamoDB.
Subsequent executions¶
On subsequent executions with the same payload, Lambda optimistically tries to save the record in DynamoDB. If the record already exists, DynamoDB returns the item.
Explore how to handle conditional write errors in high-concurrency scenarios with DynamoDB in this blog post.
Testing your code¶
The idempotency utility provides several routes to test your code.
Disabling the idempotency utility¶
When testing your code, you may wish to disable the idempotency logic altogether and focus on testing your business logic. To do this, you can set the environment variable POWERTOOLS_IDEMPOTENCY_DISABLED
with a truthy value. If you prefer setting this for specific tests, and are using Pytest, you can use monkeypatch fixture:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Testing with DynamoDB Local¶
To test with DynamoDB Local, you can replace the DynamoDB client
used by the persistence layer with one you create inside your tests. This allows you to set the endpoint_url.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
How do I mock all DynamoDB I/O operations¶
The idempotency utility lazily creates the dynamodb Table which it uses to access DynamoDB. This means it is possible to pass a mocked Table resource, or stub various methods.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Testing with Redis¶
To test locally, you can either utilize fakeredis-py for a simulated Redis environment or refer to the MockRedis class used in our tests to mock Redis operations.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
If you want to set up a real Redis client for integration testing, you can reference the code provided below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
|
1 2 3 |
|
- Use this script to setup a temp Redis docker and auto remove it upon completion
Extra resources¶
If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out this article.