Skip to content

Commit f58cd87

Browse files
fix(idempotency): serialize PydanticSerializer output in JSON mode (#8570)
* fix(idempotency): serialize PydanticSerializer output in JSON mode PydanticSerializer.to_dict used model_dump() in Python mode, but save_success stores the result with json.dumps and the shared Encoder, which only handles Decimal, Pydantic models and dataclasses. A returned model with a UUID, datetime, plain Enum, bytes or set field raised TypeError after the function had already run. The record stayed INPROGRESS, so a retry after the in-progress expiry ran the function again. Dump in JSON mode, as _prepare_data does for inputs since #8075. from_dict validates the JSON values back into the model's types, and models that already worked read back the same as before. * docs(idempotency): warn about SecretStr and strict models with PydanticSerializer --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent ca28871 commit f58cd87

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

‎aws_lambda_powertools/utilities/idempotency/serialization/pydantic.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ def __init__(self, model: type[BaseModel]):
2828
self.__model: type[BaseModel] = model
2929

3030
def to_dict(self, data: BaseModel) -> dict:
31-
return data.model_dump()
31+
# The result is stored as JSON, so dump JSON-compatible values; from_dict validates them back into the model.
32+
return data.model_dump(mode="json")
3233

3334
def from_dict(self, data: dict) -> BaseModel:
3435
return self.__model.model_validate(data)

‎docs/utilities/idempotency.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@ The output serializer supports any JSON serializable data, **Python Dataclasses*
243243
--8<-- "examples/idempotency/src/working_with_pydantic_explicitly_output_serializer.py"
244244
```
245245

246+
???+ warning "Results are stored as JSON"
247+
`PydanticSerializer` stores your model with `model_dump(mode="json")`, so fields such as `UUID`, `datetime`, `Enum`, `bytes` and `set` are saved as JSON values and validated back into the model on replay. Two cases need attention:
248+
249+
* `SecretStr` and `SecretBytes` fields are stored **masked** (`**********`), so a replayed result returns the masked value, not the secret. Keep secrets out of idempotent results.
250+
* Models with `strict=True` and fields like `datetime` or `UUID` save correctly, but the replay fails validation because strict mode does not accept the stored strings. Use a non-strict model for the result, or a custom serializer.
251+
246252
=== "Dataclasses"
247253

248254
Use `DataclassSerializer` to automatically serialize what's retrieved from the persistent storage based on the return type annotated.

‎tests/functional/idempotency/_pydantic/test_idempotency_with_pydantic.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import json
2+
from datetime import datetime, timezone
3+
from enum import Enum
4+
from uuid import UUID
5+
16
import pytest
27
from pydantic import BaseModel
8+
from pytest_mock import MockerFixture
39

410
from aws_lambda_powertools.utilities.idempotency import (
511
IdempotencyConfig,
@@ -22,6 +28,7 @@
2228
from tests.functional.idempotency.utils import (
2329
hash_idempotency_key,
2430
)
31+
from tests.functional.utils import json_serialize
2532

2633
TESTS_MODULE_PREFIX = "test-func.tests.functional.idempotency._pydantic.test_idempotency_with_pydantic"
2734

@@ -263,3 +270,87 @@ def collect_payment(payment: PaymentInput) -> PaymentOutput | None:
263270
assert isinstance(second_call, PaymentOutput)
264271
assert second_call.customer_id == payment.customer_id
265272
assert second_call.transaction_id == payment.transaction_id
273+
274+
275+
class PaymentStatus(Enum):
276+
PAID = "paid"
277+
278+
279+
class PaymentReceipt(BaseModel):
280+
payment_id: UUID
281+
paid_at: datetime
282+
status: PaymentStatus
283+
customer_id: str
284+
285+
286+
@pytest.fixture
287+
def payment_receipt() -> PaymentReceipt:
288+
return PaymentReceipt(
289+
payment_id=UUID("12345678-1234-5678-1234-567812345678"),
290+
paid_at=datetime(2024, 3, 20, 14, 30, tzinfo=timezone.utc),
291+
status=PaymentStatus.PAID,
292+
customer_id="c-456",
293+
)
294+
295+
296+
def test_pydantic_serializer_round_trips_non_json_types(payment_receipt: PaymentReceipt):
297+
# GIVEN a serializer for a model with UUID, datetime and plain Enum fields
298+
serializer = PydanticSerializer(model=PaymentReceipt)
299+
300+
# WHEN the model is stored as JSON, the same way the persistence layer saves a result
301+
stored = json_serialize(serializer.to_dict(payment_receipt))
302+
303+
# THEN reading it back returns the same model
304+
assert serializer.from_dict(json.loads(stored)) == payment_receipt
305+
306+
307+
@pytest.mark.parametrize("output_serializer_type", ["explicit", "deduced"])
308+
def test_idempotent_function_serialization_pydantic_with_non_json_types(
309+
output_serializer_type: str,
310+
payment_receipt: PaymentReceipt,
311+
mocker: MockerFixture,
312+
):
313+
# GIVEN a function that returns a model with UUID, datetime and plain Enum fields
314+
config = IdempotencyConfig(use_local_cache=True)
315+
mock_event = {"customer_id": "c-456", "transaction_id": "fake-id"}
316+
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_serialization_pydantic_with_non_json_types.<locals>.collect_payment#{hash_idempotency_key(mock_event)}" # noqa: E501
317+
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
318+
update_record_spy = mocker.spy(persistence_layer, "_update_record")
319+
320+
class PaymentInput(BaseModel):
321+
customer_id: str
322+
transaction_id: str
323+
324+
if output_serializer_type == "explicit":
325+
output_serializer = PydanticSerializer(
326+
model=PaymentReceipt,
327+
)
328+
else:
329+
output_serializer = PydanticSerializer
330+
331+
executions = []
332+
333+
@idempotent_function(
334+
data_keyword_argument="payment",
335+
persistence_store=persistence_layer,
336+
config=config,
337+
output_serializer=output_serializer,
338+
)
339+
def collect_payment(payment: PaymentInput) -> PaymentReceipt:
340+
executions.append(payment)
341+
return payment_receipt
342+
343+
# WHEN calling the function for the first time
344+
payment = PaymentInput(**mock_event)
345+
first_call = collect_payment(payment=payment)
346+
347+
# THEN the result is saved as a COMPLETED record
348+
assert update_record_spy.call_args.kwargs["data_record"].status == "COMPLETED"
349+
350+
# WHEN calling the function again with the same payment
351+
second_call = collect_payment(payment=payment)
352+
353+
# THEN the saved result is returned as the model, without running the function again
354+
assert isinstance(second_call, PaymentReceipt)
355+
assert second_call == first_call
356+
assert executions == [payment]

0 commit comments

Comments
 (0)