Skip to content

Commit 8534b16

Browse files
aradyaronleandrodamascenarubenfonseca
authored
feat(idempotency): add support to custom serialization/deserialization on idempotency decorator (#2951)
Co-authored-by: arad.yaron <[email protected]> Co-authored-by: Leandro Damascena <[email protected]> Co-authored-by: Ruben Fonseca <[email protected]>
1 parent 3c171ce commit 8534b16

16 files changed

+814
-10
lines changed

aws_lambda_powertools/utilities/idempotency/base.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
BasePersistenceLayer,
1919
DataRecord,
2020
)
21+
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
22+
BaseIdempotencySerializer,
23+
)
24+
from aws_lambda_powertools.utilities.idempotency.serialization.no_op import (
25+
NoOpSerializer,
26+
)
2127

2228
MAX_RETRIES = 2
2329
logger = logging.getLogger(__name__)
@@ -51,6 +57,7 @@ def __init__(
5157
function_payload: Any,
5258
config: IdempotencyConfig,
5359
persistence_store: BasePersistenceLayer,
60+
output_serializer: Optional[BaseIdempotencySerializer] = None,
5461
function_args: Optional[Tuple] = None,
5562
function_kwargs: Optional[Dict] = None,
5663
):
@@ -65,12 +72,16 @@ def __init__(
6572
Idempotency Configuration
6673
persistence_store : BasePersistenceLayer
6774
Instance of persistence layer to store idempotency records
75+
output_serializer: Optional[BaseIdempotencySerializer]
76+
Serializer to transform the data to and from a dictionary.
77+
If not supplied, no serialization is done via the NoOpSerializer
6878
function_args: Optional[Tuple]
6979
Function arguments
7080
function_kwargs: Optional[Dict]
7181
Function keyword arguments
7282
"""
7383
self.function = function
84+
self.output_serializer = output_serializer or NoOpSerializer()
7485
self.data = deepcopy(_prepare_data(function_payload))
7586
self.fn_args = function_args
7687
self.fn_kwargs = function_kwargs
@@ -170,7 +181,7 @@ def _get_idempotency_record(self) -> Optional[DataRecord]:
170181

171182
return data_record
172183

173-
def _handle_for_status(self, data_record: DataRecord) -> Optional[Dict[Any, Any]]:
184+
def _handle_for_status(self, data_record: DataRecord) -> Optional[Any]:
174185
"""
175186
Take appropriate action based on data_record's status
176187
@@ -180,8 +191,9 @@ def _handle_for_status(self, data_record: DataRecord) -> Optional[Dict[Any, Any]
180191
181192
Returns
182193
-------
183-
Optional[Dict[Any, Any]
194+
Optional[Any]
184195
Function's response previously used for this idempotency key, if it has successfully executed already.
196+
In case an output serializer is configured, the response is deserialized.
185197
186198
Raises
187199
------
@@ -206,8 +218,10 @@ def _handle_for_status(self, data_record: DataRecord) -> Optional[Dict[Any, Any]
206218
f"Execution already in progress with idempotency key: "
207219
f"{self.persistence_store.event_key_jmespath}={data_record.idempotency_key}",
208220
)
209-
210-
return data_record.response_json_as_dict()
221+
response_dict: Optional[dict] = data_record.response_json_as_dict()
222+
if response_dict is not None:
223+
return self.output_serializer.from_dict(response_dict)
224+
return None
211225

212226
def _get_function_response(self):
213227
try:
@@ -226,7 +240,8 @@ def _get_function_response(self):
226240

227241
else:
228242
try:
229-
self.persistence_store.save_success(data=self.data, result=response)
243+
serialized_response: dict = self.output_serializer.to_dict(response) if response else None
244+
self.persistence_store.save_success(data=self.data, result=serialized_response)
230245
except Exception as save_exception:
231246
raise IdempotencyPersistenceLayerError(
232247
"Failed to update record state to success in idempotency store",

aws_lambda_powertools/utilities/idempotency/exceptions.py

+12
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,15 @@ class IdempotencyKeyError(BaseError):
7171
"""
7272
Payload does not contain an idempotent key
7373
"""
74+
75+
76+
class IdempotencyModelTypeError(BaseError):
77+
"""
78+
Model type does not match expected payload output
79+
"""
80+
81+
82+
class IdempotencyNoSerializationModelError(BaseError):
83+
"""
84+
No model was supplied to the serializer
85+
"""

aws_lambda_powertools/utilities/idempotency/idempotency.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import functools
55
import logging
66
import os
7-
from typing import Any, Callable, Dict, Optional, cast
7+
from inspect import isclass
8+
from typing import Any, Callable, Dict, Optional, Type, Union, cast
89

910
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
1011
from aws_lambda_powertools.shared import constants
@@ -14,6 +15,10 @@
1415
from aws_lambda_powertools.utilities.idempotency.persistence.base import (
1516
BasePersistenceLayer,
1617
)
18+
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
19+
BaseIdempotencyModelSerializer,
20+
BaseIdempotencySerializer,
21+
)
1722
from aws_lambda_powertools.utilities.typing import LambdaContext
1823

1924
logger = logging.getLogger(__name__)
@@ -85,6 +90,7 @@ def idempotent_function(
8590
data_keyword_argument: str,
8691
persistence_store: BasePersistenceLayer,
8792
config: Optional[IdempotencyConfig] = None,
93+
output_serializer: Optional[Union[BaseIdempotencySerializer, Type[BaseIdempotencyModelSerializer]]] = None,
8894
) -> Any:
8995
"""
9096
Decorator to handle idempotency of any function
@@ -99,6 +105,11 @@ def idempotent_function(
99105
Instance of BasePersistenceLayer to store data
100106
config: IdempotencyConfig
101107
Configuration
108+
output_serializer: Optional[Union[BaseIdempotencySerializer, Type[BaseIdempotencyModelSerializer]]]
109+
Serializer to transform the data to and from a dictionary.
110+
If not supplied, no serialization is done via the NoOpSerializer.
111+
In case a serializer of type inheriting BaseIdempotencyModelSerializer is given,
112+
the serializer is derived from the function return type.
102113
103114
Examples
104115
--------
@@ -124,9 +135,14 @@ def process_order(customer_id: str, order: dict, **kwargs):
124135
data_keyword_argument=data_keyword_argument,
125136
persistence_store=persistence_store,
126137
config=config,
138+
output_serializer=output_serializer,
127139
),
128140
)
129141

142+
if isclass(output_serializer) and issubclass(output_serializer, BaseIdempotencyModelSerializer):
143+
# instantiate an instance of the serializer class
144+
output_serializer = output_serializer.instantiate(function.__annotations__.get("return", None))
145+
130146
config = config or IdempotencyConfig()
131147

132148
@functools.wraps(function)
@@ -147,6 +163,7 @@ def decorate(*args, **kwargs):
147163
function_payload=payload,
148164
config=config,
149165
persistence_store=persistence_store,
166+
output_serializer=output_serializer,
150167
function_args=args,
151168
function_kwargs=kwargs,
152169
)

aws_lambda_powertools/utilities/idempotency/serialization/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""
2+
Serialization for supporting idempotency
3+
"""
4+
from abc import ABC, abstractmethod
5+
from typing import Any, Dict
6+
7+
8+
class BaseIdempotencySerializer(ABC):
9+
"""
10+
Abstract Base Class for Idempotency serialization layer, supporting dict operations.
11+
"""
12+
13+
@abstractmethod
14+
def to_dict(self, data: Any) -> Dict:
15+
raise NotImplementedError("Implementation of to_dict is required")
16+
17+
@abstractmethod
18+
def from_dict(self, data: Dict) -> Any:
19+
raise NotImplementedError("Implementation of from_dict is required")
20+
21+
22+
class BaseIdempotencyModelSerializer(BaseIdempotencySerializer):
23+
"""
24+
Abstract Base Class for Idempotency serialization layer, for using a model as data object representation.
25+
"""
26+
27+
@classmethod
28+
@abstractmethod
29+
def instantiate(cls, model_type: Any) -> BaseIdempotencySerializer:
30+
"""
31+
Creates an instance of a serializer based on a provided model type.
32+
In case the model_type is unknown, None will be sent as `model_type`.
33+
It's on the implementer to verify that:
34+
- None is handled correctly
35+
- A model type not matching the expected types is handled
36+
37+
Parameters
38+
----------
39+
model_type: Any
40+
The model type to instantiate the class for
41+
42+
Returns
43+
-------
44+
BaseIdempotencySerializer
45+
Instance of the serializer class
46+
"""
47+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Any, Callable, Dict
2+
3+
from aws_lambda_powertools.utilities.idempotency.serialization.base import BaseIdempotencySerializer
4+
5+
6+
class CustomDictSerializer(BaseIdempotencySerializer):
7+
def __init__(self, to_dict: Callable[[Any], Dict], from_dict: Callable[[Dict], Any]):
8+
"""
9+
Parameters
10+
----------
11+
to_dict: Callable[[Any], Dict]
12+
A function capable of transforming the saved data object representation into a dictionary
13+
from_dict: Callable[[Dict], Any]
14+
A function capable of transforming the saved dictionary into the original data object representation
15+
"""
16+
self.__to_dict: Callable[[Any], Dict] = to_dict
17+
self.__from_dict: Callable[[Dict], Any] = from_dict
18+
19+
def to_dict(self, data: Any) -> Dict:
20+
return self.__to_dict(data)
21+
22+
def from_dict(self, data: Dict) -> Any:
23+
return self.__from_dict(data)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from dataclasses import asdict, is_dataclass
2+
from typing import Any, Dict, Type
3+
4+
from aws_lambda_powertools.utilities.idempotency.exceptions import (
5+
IdempotencyModelTypeError,
6+
IdempotencyNoSerializationModelError,
7+
)
8+
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
9+
BaseIdempotencyModelSerializer,
10+
BaseIdempotencySerializer,
11+
)
12+
13+
DataClass = Any
14+
15+
16+
class DataclassSerializer(BaseIdempotencyModelSerializer):
17+
"""
18+
A serializer class for transforming data between dataclass objects and dictionaries.
19+
"""
20+
21+
def __init__(self, model: Type[DataClass]):
22+
"""
23+
Parameters
24+
----------
25+
model: Type[DataClass]
26+
A dataclass type to be used for serialization and deserialization
27+
"""
28+
self.__model: Type[DataClass] = model
29+
30+
def to_dict(self, data: DataClass) -> Dict:
31+
return asdict(data)
32+
33+
def from_dict(self, data: Dict) -> DataClass:
34+
return self.__model(**data)
35+
36+
@classmethod
37+
def instantiate(cls, model_type: Any) -> BaseIdempotencySerializer:
38+
if model_type is None:
39+
raise IdempotencyNoSerializationModelError("No serialization model was supplied")
40+
41+
if not is_dataclass(model_type):
42+
raise IdempotencyModelTypeError("Model type is not inherited of dataclass type")
43+
return cls(model=model_type)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import Dict
2+
3+
from aws_lambda_powertools.utilities.idempotency.serialization.base import BaseIdempotencySerializer
4+
5+
6+
class NoOpSerializer(BaseIdempotencySerializer):
7+
def __init__(self):
8+
"""
9+
Parameters
10+
----------
11+
Default serializer, does not transform data
12+
"""
13+
14+
def to_dict(self, data: Dict) -> Dict:
15+
return data
16+
17+
def from_dict(self, data: Dict) -> Dict:
18+
return data
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import Any, Dict, Type
2+
3+
from pydantic import BaseModel
4+
5+
from aws_lambda_powertools.utilities.idempotency.exceptions import (
6+
IdempotencyModelTypeError,
7+
IdempotencyNoSerializationModelError,
8+
)
9+
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
10+
BaseIdempotencyModelSerializer,
11+
BaseIdempotencySerializer,
12+
)
13+
14+
15+
class PydanticSerializer(BaseIdempotencyModelSerializer):
16+
"""Pydantic serializer for idempotency models"""
17+
18+
def __init__(self, model: Type[BaseModel]):
19+
"""
20+
Parameters
21+
----------
22+
model: Model
23+
Pydantic model to be used for serialization
24+
"""
25+
self.__model: Type[BaseModel] = model
26+
27+
def to_dict(self, data: BaseModel) -> Dict:
28+
if callable(getattr(data, "model_dump", None)):
29+
# Support for pydantic V2
30+
return data.model_dump() # type: ignore[unused-ignore,attr-defined]
31+
return data.dict()
32+
33+
def from_dict(self, data: Dict) -> BaseModel:
34+
if callable(getattr(self.__model, "model_validate", None)):
35+
# Support for pydantic V2
36+
return self.__model.model_validate(data) # type: ignore[unused-ignore,attr-defined]
37+
return self.__model.parse_obj(data)
38+
39+
@classmethod
40+
def instantiate(cls, model_type: Any) -> BaseIdempotencySerializer:
41+
if model_type is None:
42+
raise IdempotencyNoSerializationModelError("No serialization model was supplied")
43+
44+
if not issubclass(model_type, BaseModel):
45+
raise IdempotencyModelTypeError("Model type is not inherited from pydantic BaseModel")
46+
47+
return cls(model=model_type)

docs/utilities/idempotency.md

+39
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,45 @@ When using `idempotent_function`, you must tell us which keyword parameter in yo
152152
--8<-- "examples/idempotency/src/working_with_idempotent_function_pydantic.py"
153153
```
154154

155+
#### Output serialization
156+
157+
The default return of the `idempotent_function` decorator is a JSON object, but you can customize the function's return type by utilizing the `output_serializer` parameter. The output serializer supports any JSON serializable data, **Python Dataclasses** and **Pydantic Models**.
158+
159+
!!! info "When using the `output_serializer` parameter, the data will continue to be stored in DynamoDB as a JSON object."
160+
161+
Working with Pydantic Models:
162+
163+
=== "Explicitly passing the Pydantic model type"
164+
165+
```python hl_lines="6 24 25 32 35 44"
166+
--8<-- "examples/idempotency/src/working_with_pydantic_explicitly_output_serializer.py"
167+
```
168+
=== "Deducing the Pydantic model type from the return type annotation"
169+
170+
```python hl_lines="6 24 25 32 36 45"
171+
--8<-- "examples/idempotency/src/working_with_pydantic_deduced_output_serializer.py"
172+
```
173+
174+
Working with Python Dataclasses:
175+
176+
=== "Explicitly passing the model type"
177+
178+
```python hl_lines="8 27-29 36 39 48"
179+
--8<-- "examples/idempotency/src/working_with_dataclass_explicitly_output_serializer.py"
180+
```
181+
182+
=== "Deducing the model type from the return type annotation"
183+
184+
```python hl_lines="8 27-29 36 40 49"
185+
--8<-- "examples/idempotency/src/working_with_dataclass_deduced_output_serializer.py"
186+
```
187+
188+
=== "Using A Custom Type (Dataclasses)"
189+
190+
```python hl_lines="9 33 37 41-44 51 54"
191+
--8<-- "examples/idempotency/src/working_with_idempotent_function_custom_output_serializer.py"
192+
```
193+
155194
#### Batch integration
156195

157196
You can can easily integrate with [Batch utility](batch.md){target="_blank"} via context manager. This ensures that you process each record in an idempotent manner, and guard against a [Lambda timeout](#lambda-timeouts) idempotent situation.

0 commit comments

Comments
 (0)