Skip to content

Commit b149b15

Browse files
feat(Idempotency): add feature for manipulating idempotent responses (#4037)
* feat(idempotent-response-manipulation): Added capability of providing an IdempotentHook functiont to be called when an idempotent response is being returned. * chore(mypy): resolve myopy static typing issues, make response+hook properly optional * feat(response_hook): added some documentation, call response_hook after custom de-serialization * feat(response_hook): review items * chore(mypy): resolve type erro r in example code - expiry_timestamp can be None * chore(docs): fix formatting error in markdown * chore(docs): fix highlighting of example code - lines moved * Improving doc * Improving doc * Addressing Ruben's feedback --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 5a6ae52 commit b149b15

File tree

8 files changed

+295
-47
lines changed

8 files changed

+295
-47
lines changed

aws_lambda_powertools/utilities/idempotency/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
Utility for adding idempotency to lambda functions
33
"""
44

5+
from aws_lambda_powertools.utilities.idempotency.hook import (
6+
IdempotentHookFunction,
7+
)
58
from aws_lambda_powertools.utilities.idempotency.persistence.base import (
69
BasePersistenceLayer,
710
)
@@ -17,4 +20,5 @@
1720
"idempotent",
1821
"idempotent_function",
1922
"IdempotencyConfig",
23+
"IdempotentHookFunction",
2024
)

aws_lambda_powertools/utilities/idempotency/base.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from copy import deepcopy
44
from typing import Any, Callable, Dict, Optional, Tuple
55

6-
from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig
6+
from aws_lambda_powertools.utilities.idempotency.config import (
7+
IdempotencyConfig,
8+
)
79
from aws_lambda_powertools.utilities.idempotency.exceptions import (
810
IdempotencyAlreadyInProgressError,
911
IdempotencyInconsistentStateError,
@@ -227,7 +229,15 @@ def _handle_for_status(self, data_record: DataRecord) -> Optional[Any]:
227229
)
228230
response_dict: Optional[dict] = data_record.response_json_as_dict()
229231
if response_dict is not None:
230-
return self.output_serializer.from_dict(response_dict)
232+
serialized_response = self.output_serializer.from_dict(response_dict)
233+
if self.config.response_hook is not None:
234+
logger.debug("Response hook configured, invoking function")
235+
return self.config.response_hook(
236+
serialized_response,
237+
data_record,
238+
)
239+
return serialized_response
240+
231241
return None
232242

233243
def _get_function_response(self):

aws_lambda_powertools/utilities/idempotency/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Dict, Optional
22

3+
from aws_lambda_powertools.utilities.idempotency import IdempotentHookFunction
34
from aws_lambda_powertools.utilities.typing import LambdaContext
45

56

@@ -15,6 +16,7 @@ def __init__(
1516
local_cache_max_items: int = 256,
1617
hash_function: str = "md5",
1718
lambda_context: Optional[LambdaContext] = None,
19+
response_hook: Optional[IdempotentHookFunction] = None,
1820
):
1921
"""
2022
Initialize the base persistence layer
@@ -37,6 +39,8 @@ def __init__(
3739
Function to use for calculating hashes, by default md5.
3840
lambda_context: LambdaContext, optional
3941
Lambda Context containing information about the invocation, function and execution environment.
42+
response_hook: IdempotentHookFunction, optional
43+
Hook function to be called when an idempotent response is returned from the idempotent store.
4044
"""
4145
self.event_key_jmespath = event_key_jmespath
4246
self.payload_validation_jmespath = payload_validation_jmespath
@@ -47,6 +51,7 @@ def __init__(
4751
self.local_cache_max_items = local_cache_max_items
4852
self.hash_function = hash_function
4953
self.lambda_context: Optional[LambdaContext] = lambda_context
54+
self.response_hook: Optional[IdempotentHookFunction] = response_hook
5055

5156
def register_lambda_context(self, lambda_context: LambdaContext):
5257
"""Captures the Lambda context, to calculate the remaining time before the invocation times out"""
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Any
2+
3+
from aws_lambda_powertools.shared.types import Protocol
4+
from aws_lambda_powertools.utilities.idempotency.persistence.datarecord import DataRecord
5+
6+
7+
class IdempotentHookFunction(Protocol):
8+
"""
9+
The IdempotentHookFunction.
10+
This class defines the calling signature for IdempotentHookFunction callbacks.
11+
"""
12+
13+
def __call__(self, response: Any, idempotent_data: DataRecord) -> Any: ...

docs/utilities/idempotency.md

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ We currently support Amazon DynamoDB and Redis as a storage layer. The following
7373
If you're not [changing the default configuration for the DynamoDB persistence layer](#dynamodbpersistencelayer), this is the expected default configuration:
7474

7575
| Configuration | Value | Notes |
76-
| ------------------ | ------------ | ----------------------------------------------------------------------------------- |
77-
| Partition key | `id` |
76+
| ------------------ | ------------ |-------------------------------------------------------------------------------------|
77+
| Partition key | `id` | |
7878
| TTL attribute name | `expiration` | This can only be configured after your table is created if you're using AWS Console |
7979

8080
???+ tip "Tip: You can share a single state table for all functions"
@@ -454,6 +454,40 @@ sequenceDiagram
454454
<i>Idempotent successful request cached</i>
455455
</center>
456456

457+
#### Successful request with response_hook configured
458+
459+
<center>
460+
```mermaid
461+
sequenceDiagram
462+
participant Client
463+
participant Lambda
464+
participant Response hook
465+
participant Persistence Layer
466+
alt initial request
467+
Client->>Lambda: Invoke (event)
468+
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
469+
activate Persistence Layer
470+
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
471+
Lambda-->>Lambda: Call your function
472+
Lambda->>Persistence Layer: Update record with result
473+
deactivate Persistence Layer
474+
Persistence Layer-->>Persistence Layer: Update record
475+
Note over Lambda,Persistence Layer: Set record status to COMPLETE. <br> New invocations with the same payload <br> now return the same result
476+
Lambda-->>Client: Response sent to client
477+
else retried request
478+
Client->>Lambda: Invoke (event)
479+
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
480+
activate Persistence Layer
481+
Persistence Layer-->>Response hook: Already exists in persistence layer.
482+
deactivate Persistence Layer
483+
Note over Response hook,Persistence Layer: Record status is COMPLETE and not expired
484+
Response hook->>Lambda: Response hook invoked
485+
Lambda-->>Client: Manipulated idempotent response sent to client
486+
end
487+
```
488+
<i>Successful idempotent request with a response hook</i>
489+
</center>
490+
457491
#### Expired idempotency records
458492

459493
<center>
@@ -699,15 +733,16 @@ For advanced configurations, such as setting up SSL certificates or customizing
699733

700734
Idempotent decorator can be further configured with **`IdempotencyConfig`** as seen in the previous example. These are the available options for further configuration
701735

702-
| Parameter | Default | Description |
703-
| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
704-
| **event_key_jmespath** | `""` | JMESPath expression to extract the idempotency key from the event record using [built-in functions](./jmespath_functions.md#built-in-jmespath-functions){target="_blank"} |
705-
| **payload_validation_jmespath** | `""` | JMESPath expression to validate whether certain parameters have changed in the event while the event payload |
706-
| **raise_on_no_idempotency_key** | `False` | Raise exception if no idempotency key was found in the request |
707-
| **expires_after_seconds** | 3600 | The number of seconds to wait before a record is expired |
708-
| **use_local_cache** | `False` | Whether to locally cache idempotency results |
709-
| **local_cache_max_items** | 256 | Max number of items to store in local cache |
710-
| **hash_function** | `md5` | Function to use for calculating hashes, as provided by [hashlib](https://docs.python.org/3/library/hashlib.html){target="_blank" rel="nofollow"} in the standard library. |
736+
| Parameter | Default | Description |
737+
|---------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
738+
| **event_key_jmespath** | `""` | JMESPath expression to extract the idempotency key from the event record using [built-in functions](./jmespath_functions.md#built-in-jmespath-functions){target="_blank"} |
739+
| **payload_validation_jmespath** | `""` | JMESPath expression to validate whether certain parameters have changed in the event while the event payload |
740+
| **raise_on_no_idempotency_key** | `False` | Raise exception if no idempotency key was found in the request |
741+
| **expires_after_seconds** | 3600 | The number of seconds to wait before a record is expired |
742+
| **use_local_cache** | `False` | Whether to locally cache idempotency results |
743+
| **local_cache_max_items** | 256 | Max number of items to store in local cache |
744+
| **hash_function** | `md5` | Function to use for calculating hashes, as provided by [hashlib](https://docs.python.org/3/library/hashlib.html){target="_blank" rel="nofollow"} in the standard library. |
745+
| **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](idempotency.md#manipulating-the-idempotent-response) |
711746

712747
### Handling concurrent executions with the same payload
713748

@@ -909,6 +944,36 @@ You can create your own persistent store from scratch by inheriting the `BasePer
909944

910945
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.
911946

947+
### Manipulating the Idempotent Response
948+
949+
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.
950+
951+
=== "Using an Idempotent Response Hook"
952+
953+
```python hl_lines="18 20 23 32"
954+
--8<-- "examples/idempotency/src/working_with_response_hook.py"
955+
```
956+
957+
=== "Sample event"
958+
959+
```json
960+
--8<-- "examples/idempotency/src/working_with_response_hook_payload.json"
961+
```
962+
963+
???+ info "Info: Using custom de-serialization?"
964+
965+
The response_hook is called after the custom de-serialization so the payload you process will be the de-serialized version.
966+
967+
#### Being a good citizen
968+
969+
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:
970+
971+
1. **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.
972+
973+
2. **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.
974+
975+
3. **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.
976+
912977
## Compatibility with other utilities
913978

914979
### Batch
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import datetime
2+
import uuid
3+
from typing import Dict
4+
5+
from aws_lambda_powertools import Logger
6+
from aws_lambda_powertools.utilities.idempotency import (
7+
DynamoDBPersistenceLayer,
8+
IdempotencyConfig,
9+
idempotent_function,
10+
)
11+
from aws_lambda_powertools.utilities.idempotency.persistence.base import (
12+
DataRecord,
13+
)
14+
from aws_lambda_powertools.utilities.typing import LambdaContext
15+
16+
logger = Logger()
17+
18+
19+
def my_response_hook(response: Dict, idempotent_data: DataRecord) -> Dict:
20+
# Return inserted Header data into the Idempotent Response
21+
response["x-idempotent-key"] = idempotent_data.idempotency_key
22+
23+
# expiry_timestamp could be None so include if set
24+
expiry_timestamp = idempotent_data.expiry_timestamp
25+
if expiry_timestamp:
26+
expiry_time = datetime.datetime.fromtimestamp(int(expiry_timestamp))
27+
response["x-idempotent-expiration"] = expiry_time.isoformat()
28+
29+
# Must return the response here
30+
return response
31+
32+
33+
dynamodb = DynamoDBPersistenceLayer(table_name="IdempotencyTable")
34+
config = IdempotencyConfig(response_hook=my_response_hook)
35+
36+
37+
@idempotent_function(data_keyword_argument="order", config=config, persistence_store=dynamodb)
38+
def process_order(order: dict) -> dict:
39+
# create the order_id
40+
order_id = str(uuid.uuid4())
41+
42+
# create your logic to save the order
43+
# append the order_id created
44+
order["order_id"] = order_id
45+
46+
# return the order
47+
return {"order": order}
48+
49+
50+
def lambda_handler(event: dict, context: LambdaContext):
51+
config.register_lambda_context(context) # see Lambda timeouts section
52+
try:
53+
logger.info(f"Processing order id {event.get('order_id')}")
54+
return process_order(order=event.get("order"))
55+
except Exception as err:
56+
return {"status_code": 400, "error": f"Error processing {str(err)}"}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"order" : {
3+
"user_id": "xyz",
4+
"product_id": "123456789",
5+
"quantity": 2,
6+
"value": 30
7+
}
8+
}

0 commit comments

Comments
 (0)