Skip to content

Commit ed6535d

Browse files
fix(idempotency): serialize UUID and datetime fields in dataclass payloads (#8501)
* fix(idempotency): serialize UUID and datetime fields in dataclass payloads #8075 made _prepare_data dump Pydantic models with mode="json", but the dataclass branch still returned the raw dataclasses.asdict() output. A dataclass holding a UUID, date, time, datetime, plain Enum or path failed on every call with "Object of type UUID is not JSON serializable" while hashing the idempotency key. Convert those values to JSON safe ones after asdict(). Values json already encodes, including str and int enums, are left as they are, so keys for dataclasses that worked before don't change. * fix(idempotency): preserve existing hashes when encoding input types --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 749547f commit ed6535d

4 files changed

Lines changed: 341 additions & 1 deletion

File tree

‎aws_lambda_powertools/utilities/idempotency/persistence/base.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
import os
1212
import warnings
1313
from abc import ABC, abstractmethod
14+
from enum import Enum
15+
from pathlib import PurePath
1416
from typing import TYPE_CHECKING, Any
17+
from uuid import UUID
1518

1619
import jmespath
1720

@@ -35,6 +38,23 @@
3538
logger = logging.getLogger(__name__)
3639

3740

41+
class _IdempotencyKeyEncoder(Encoder):
42+
"""Extend hash serialization while preserving values the existing encoder supports."""
43+
44+
def default(self, obj: Any) -> Any:
45+
try:
46+
# Preserve existing encodings, including Decimal subclasses mixed with Enum.
47+
return super().default(obj)
48+
except TypeError:
49+
if isinstance(obj, Enum):
50+
return obj.value
51+
if isinstance(obj, (datetime.date, datetime.time)):
52+
return obj.isoformat()
53+
if isinstance(obj, (UUID, PurePath)):
54+
return str(obj)
55+
raise
56+
57+
3858
class BasePersistenceLayer(ABC):
3959
"""
4060
Abstract Base Class for Idempotency persistence layer.
@@ -178,7 +198,7 @@ def _generate_hash(self, data: Any) -> str:
178198
Hashed representation of the provided data
179199
180200
"""
181-
hashed_data = self.hash_function(json.dumps(data, cls=Encoder, sort_keys=True).encode())
201+
hashed_data = self.hash_function(json.dumps(data, cls=_IdempotencyKeyEncoder, sort_keys=True).encode())
182202
return hashed_data.hexdigest()
183203

184204
def _validate_payload(

‎docs/utilities/idempotency.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ When using this decorator, you **must** call your decorated function using keywo
192192

193193
You can use `data_keyword_argument` to tell us the argument to extract an idempotency key. We support JSON serializable data, [Dataclasses](https://docs.python.org/3.12/library/dataclasses.html){target="_blank" rel="nofollow"}, Pydantic Models, and [Event Source Data Classes](./data_classes.md){target="_blank"}
194194

195+
When hashing dataclass or dictionary inputs, UUID and path values are encoded as strings, `datetime`, `date`, and `time` values use ISO 8601 strings, and enums use their values unless the existing encoder already supports the enum type. Existing encodings, including `Decimal` and enums based on strings, integers, floats, or `Decimal`, are preserved.
196+
197+
These conversions happen after evaluating `event_key_jmespath` and `payload_validation_jmespath`, so their expressions and custom functions receive the original field types. They apply to key and validation hashing; function results continue to use the [output serialization](#output-serialization) configuration.
198+
195199
=== "Using Dataclasses"
196200

197201
```python title="working_with_idempotent_function_dataclass.py" hl_lines="4-8 12 28 41"

‎tests/functional/idempotency/_boto3/test_idempotency.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
import dataclasses
33
import datetime
44
import warnings
5+
from decimal import Decimal
6+
from enum import Enum
7+
from pathlib import PurePosixPath
58
from typing import Any
69
from unittest.mock import MagicMock, Mock
10+
from uuid import UUID
711

812
import jmespath
913
import pytest
@@ -1772,6 +1776,62 @@ def collect_payment(payment: Payment):
17721776
assert result == payment.transaction_id
17731777

17741778

1779+
def test_idempotent_function_dataclass_with_non_json_types(lambda_context):
1780+
# GIVEN a dataclass with UUID, datetime, date, time, Enum and path values, some inside a list or tuple
1781+
config = IdempotencyConfig(use_local_cache=True, lambda_context=lambda_context)
1782+
mock_event = {
1783+
"order_id": "12345678-1234-5678-1234-567812345678",
1784+
"created_at": "2024-03-20T14:30:00",
1785+
"ship_date": "2024-03-22",
1786+
"delivery_window": ["09:00:00", "12:00:00"],
1787+
"item_ids": ["87654321-4321-8765-4321-876543218765"],
1788+
"status": "paid",
1789+
"receipt": "/receipts/ord-001.pdf",
1790+
"amount": "199.99",
1791+
}
1792+
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_dataclass_with_non_json_types.<locals>.process_order#{hash_idempotency_key(mock_event)}" # noqa E501
1793+
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
1794+
1795+
class OrderStatus(Enum):
1796+
PAID = "paid"
1797+
1798+
@dataclasses.dataclass
1799+
class Order:
1800+
order_id: UUID
1801+
created_at: datetime.datetime
1802+
ship_date: datetime.date
1803+
delivery_window: tuple[datetime.time, datetime.time]
1804+
item_ids: list[UUID]
1805+
status: OrderStatus
1806+
receipt: PurePosixPath
1807+
amount: Decimal
1808+
1809+
executions = []
1810+
1811+
@idempotent_function(data_keyword_argument="order", persistence_store=persistence_layer, config=config)
1812+
def process_order(order: Order) -> dict:
1813+
executions.append(order)
1814+
return {"status": "ok"}
1815+
1816+
# WHEN
1817+
order = Order(
1818+
order_id=UUID("12345678-1234-5678-1234-567812345678"),
1819+
created_at=datetime.datetime(2024, 3, 20, 14, 30, 0),
1820+
ship_date=datetime.date(2024, 3, 22),
1821+
delivery_window=(datetime.time(9, 0), datetime.time(12, 0)),
1822+
item_ids=[UUID("87654321-4321-8765-4321-876543218765")],
1823+
status=OrderStatus.PAID,
1824+
receipt=PurePosixPath("/receipts/ord-001.pdf"),
1825+
amount=Decimal("199.99"),
1826+
)
1827+
result = process_order(order=order)
1828+
1829+
# THEN the key is hashed from the JSON version of the order, asserted at MockPersistenceLayer
1830+
assert result == {"status": "ok"}
1831+
assert process_order(order=order) == result
1832+
assert executions == [order]
1833+
1834+
17751835
@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}], indirect=True)
17761836
def test_idempotent_lambda_compound_already_completed(
17771837
idempotency_config: IdempotencyConfig,
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import dataclasses
2+
import datetime
3+
from decimal import Decimal
4+
from enum import Enum, IntEnum
5+
from pathlib import PurePosixPath
6+
from typing import Any, NamedTuple
7+
8+
import pytest
9+
from botocore import stub
10+
from jmespath import functions
11+
12+
from aws_lambda_powertools.utilities.idempotency import IdempotencyConfig, idempotent_function
13+
from aws_lambda_powertools.utilities.idempotency.exceptions import (
14+
IdempotencyPersistenceLayerError,
15+
IdempotencyValidationError,
16+
)
17+
from tests.functional.idempotency.utils import (
18+
build_idempotency_put_item_stub,
19+
build_idempotency_update_item_stub,
20+
hash_idempotency_key,
21+
)
22+
from tests.functional.utils import json_serialize
23+
24+
25+
@dataclasses.dataclass
26+
class Payload:
27+
order_id: str
28+
value: Any
29+
30+
31+
class DecimalAmount(Decimal, Enum):
32+
SMALL = Decimal("1.00")
33+
34+
35+
class StringStatus(str, Enum):
36+
PAID = "paid"
37+
38+
39+
class IntegerStatus(IntEnum):
40+
PAID = 1
41+
42+
43+
class FloatStatus(float, Enum):
44+
PAID = 1.5
45+
46+
47+
class Point(NamedTuple):
48+
x: int
49+
y: int
50+
51+
52+
def _expected_put(key_data, validation_data):
53+
params = build_idempotency_put_item_stub(key_data)
54+
params["Item"]["id"] = {"S": f"orders#{hash_idempotency_key(key_data)}"}
55+
params["Item"]["validation"] = {"S": hash_idempotency_key(validation_data)}
56+
return params
57+
58+
59+
def _completed_record(key_data, validation_data, expiration):
60+
return {
61+
"Item": {
62+
"id": {"S": f"orders#{hash_idempotency_key(key_data)}"},
63+
"expiration": {"N": expiration},
64+
"status": {"S": "COMPLETED"},
65+
"data": {"S": json_serialize({"status": "already processed"})},
66+
"validation": {"S": hash_idempotency_key(validation_data)},
67+
},
68+
}
69+
70+
71+
def _assert_existing_record_is_reused(store, config, payload, key_data, validation_data, expiration):
72+
@idempotent_function(
73+
data_keyword_argument="payload",
74+
persistence_store=store,
75+
config=config,
76+
key_prefix="orders",
77+
)
78+
def process(payload):
79+
pytest.fail("An existing record must be reused without executing the function")
80+
81+
# Both hashes are produced by the existing shared Encoder, independently of the new hash encoder.
82+
with stub.Stubber(store.client) as stubber:
83+
stubber.add_client_error(
84+
"put_item",
85+
"ConditionalCheckFailedException",
86+
modeled_fields=_completed_record(key_data, validation_data, expiration),
87+
expected_params=_expected_put(key_data, validation_data),
88+
)
89+
assert process(payload=payload) == {"status": "already processed"}
90+
stubber.assert_no_pending_responses()
91+
92+
93+
@pytest.mark.parametrize("as_dataclass", [True, False], ids=["dataclass", "dict"])
94+
@pytest.mark.parametrize(
95+
"value",
96+
[
97+
pytest.param({"text": "café", "integer": 1, "float": 1.5, "bool": True, "null": None}, id="primitives"),
98+
pytest.param(Decimal("1.00"), id="decimal"),
99+
pytest.param(Decimal("NaN"), id="decimal-nan"),
100+
pytest.param(DecimalAmount.SMALL, id="decimal-enum"),
101+
pytest.param(StringStatus.PAID, id="str-enum"),
102+
pytest.param(IntegerStatus.PAID, id="int-enum"),
103+
pytest.param(FloatStatus.PAID, id="float-enum"),
104+
pytest.param((1, "two", Decimal("3.00")), id="tuple"),
105+
pytest.param(Point(1, 2), id="namedtuple"),
106+
pytest.param(Payload("nested", Decimal("1.00")), id="nested-dataclass"),
107+
],
108+
)
109+
def test_existing_key_and_validation_hashes_are_preserved(
110+
persistence_store,
111+
lambda_context,
112+
timestamp_future,
113+
as_dataclass,
114+
value,
115+
):
116+
payload = Payload("order-1", value)
117+
legacy_data = dataclasses.asdict(payload)
118+
config = IdempotencyConfig(payload_validation_jmespath="value", lambda_context=lambda_context)
119+
120+
_assert_existing_record_is_reused(
121+
persistence_store,
122+
config,
123+
payload if as_dataclass else legacy_data,
124+
legacy_data,
125+
legacy_data["value"],
126+
timestamp_future,
127+
)
128+
129+
130+
@pytest.mark.parametrize(
131+
"value,expected_type",
132+
[
133+
pytest.param(datetime.datetime(2024, 3, 20, 14, 30), None, id="datetime"),
134+
pytest.param(DecimalAmount.SMALL, None, id="decimal-enum"),
135+
pytest.param(Point(1, 2), None, id="namedtuple"),
136+
pytest.param([1, 2], "array", id="list"),
137+
],
138+
)
139+
@pytest.mark.parametrize("custom_function", [False, True], ids=["builtin", "custom"])
140+
def test_jmespath_sees_original_types_before_hashing(
141+
persistence_store,
142+
lambda_context,
143+
timestamp_future,
144+
value,
145+
expected_type,
146+
custom_function,
147+
):
148+
class CustomFunctions(functions.Functions):
149+
@functions.signature({"types": []})
150+
def _func_original_type(self, value):
151+
return type(value).__name__
152+
153+
if custom_function:
154+
expression = "original_type(value)"
155+
expected_type = type(value).__name__
156+
else:
157+
expression = "type(value)"
158+
159+
key_data = {"order": "order-1", "kind": expected_type}
160+
validation_data = {"kind": expected_type}
161+
config = IdempotencyConfig(
162+
event_key_jmespath=f"{{order: order_id, kind: {expression}}}",
163+
payload_validation_jmespath=f"{{kind: {expression}}}",
164+
jmespath_options={"custom_functions": CustomFunctions()},
165+
lambda_context=lambda_context,
166+
)
167+
_assert_existing_record_is_reused(
168+
persistence_store,
169+
config,
170+
Payload("order-1", value),
171+
key_data,
172+
validation_data,
173+
timestamp_future,
174+
)
175+
176+
177+
@pytest.mark.parametrize("as_dataclass", [True, False], ids=["dataclass", "dict"])
178+
def test_new_types_support_persistent_replay_and_payload_validation(
179+
persistence_store,
180+
lambda_context,
181+
timestamp_future,
182+
as_dataclass,
183+
):
184+
value = datetime.datetime(2024, 3, 20, 14, 30, tzinfo=datetime.timezone.utc)
185+
payload = Payload("order-1", {"created_at": value, "receipt": PurePosixPath("/orders/receipt.pdf")})
186+
prepared_value = {"created_at": value.isoformat(), "receipt": "/orders/receipt.pdf"}
187+
config = IdempotencyConfig(
188+
event_key_jmespath="order_id",
189+
payload_validation_jmespath="value",
190+
lambda_context=lambda_context,
191+
use_local_cache=False,
192+
)
193+
executions = []
194+
195+
@idempotent_function(
196+
data_keyword_argument="payload",
197+
persistence_store=persistence_store,
198+
config=config,
199+
key_prefix="orders",
200+
)
201+
def process(payload):
202+
executions.append(payload)
203+
return {"status": "already processed"}
204+
205+
expected_update = build_idempotency_update_item_stub("order-1", {"status": "already processed"})
206+
expected_update["Key"]["id"] = {"S": f"orders#{hash_idempotency_key('order-1')}"}
207+
expected_update["ExpressionAttributeNames"]["#validation_key"] = "validation"
208+
expected_update["ExpressionAttributeValues"][":validation_key"] = {"S": hash_idempotency_key(prepared_value)}
209+
expected_update["UpdateExpression"] += ", #validation_key = :validation_key"
210+
211+
with stub.Stubber(persistence_store.client) as stubber:
212+
stubber.add_response("put_item", {}, _expected_put("order-1", prepared_value))
213+
stubber.add_response("update_item", {}, expected_update)
214+
stubber.add_client_error(
215+
"put_item",
216+
"ConditionalCheckFailedException",
217+
modeled_fields=_completed_record("order-1", prepared_value, timestamp_future),
218+
expected_params=_expected_put("order-1", prepared_value),
219+
)
220+
221+
request = payload if as_dataclass else dataclasses.asdict(payload)
222+
assert process(payload=request) == {"status": "already processed"}
223+
assert process(payload=request) == {"status": "already processed"}
224+
assert len(executions) == 1
225+
226+
changed = dataclasses.replace(payload, value={**payload.value, "receipt": PurePosixPath("/other.pdf")})
227+
changed_value = {**prepared_value, "receipt": "/other.pdf"}
228+
stubber.add_client_error(
229+
"put_item",
230+
"ConditionalCheckFailedException",
231+
modeled_fields=_completed_record("order-1", prepared_value, timestamp_future),
232+
expected_params=_expected_put("order-1", changed_value),
233+
)
234+
with pytest.raises(IdempotencyValidationError):
235+
process(payload=changed if as_dataclass else dataclasses.asdict(changed))
236+
237+
assert len(executions) == 1
238+
stubber.assert_no_pending_responses()
239+
240+
241+
@pytest.mark.parametrize("value", [b"bytes", {1, 2}, complex(1, 2)], ids=["bytes", "set", "complex"])
242+
def test_unsupported_key_types_still_raise(persistence_store, lambda_context, value):
243+
@idempotent_function(
244+
data_keyword_argument="payload",
245+
persistence_store=persistence_store,
246+
config=IdempotencyConfig(lambda_context=lambda_context),
247+
)
248+
def process(payload):
249+
pytest.fail("Unsupported key types must fail before executing the function")
250+
251+
with stub.Stubber(persistence_store.client) as stubber:
252+
with pytest.raises(IdempotencyPersistenceLayerError) as error:
253+
process(payload=Payload("order-1", value))
254+
assert isinstance(error.value.__cause__, TypeError)
255+
assert "is not JSON serializable" in str(error.value.__cause__)
256+
stubber.assert_no_pending_responses()

0 commit comments

Comments
 (0)