Skip to content

Commit 006caf6

Browse files
fix(idempotency): pass by value on idem key to guard inadvert mutations (#1090)
Co-authored-by: heitorlessa <[email protected]>
1 parent f24332d commit 006caf6

File tree

5 files changed

+89
-9
lines changed

5 files changed

+89
-9
lines changed

aws_lambda_powertools/utilities/idempotency/base.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from typing import Any, Callable, Dict, Optional, Tuple
3+
from copy import deepcopy
34

45
from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig
56
from aws_lambda_powertools.utilities.idempotency.exceptions import (
@@ -69,7 +70,7 @@ def __init__(
6970
Function keyword arguments
7071
"""
7172
self.function = function
72-
self.data = _prepare_data(function_payload)
73+
self.data = deepcopy(_prepare_data(function_payload))
7374
self.fn_args = function_args
7475
self.fn_kwargs = function_kwargs
7576

tests/functional/idempotency/conftest.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig
1515
from aws_lambda_powertools.utilities.jmespath_utils import extract_data_from_envelope
1616
from aws_lambda_powertools.utilities.validation import envelopes
17-
from tests.functional.utils import hash_idempotency_key, json_serialize, load_event
17+
from tests.functional.idempotency.utils import hash_idempotency_key
18+
from tests.functional.utils import json_serialize, load_event
1819

1920
TABLE_NAME = "TEST_TABLE"
2021

tests/functional/idempotency/test_idempotency.py

+40-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent, idempotent_function
2323
from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer, DataRecord
2424
from aws_lambda_powertools.utilities.validation import envelopes, validator
25-
from tests.functional.utils import hash_idempotency_key, json_serialize, load_event
25+
from tests.functional.idempotency.utils import (
26+
build_idempotency_put_item_stub,
27+
build_idempotency_update_item_stub,
28+
hash_idempotency_key,
29+
)
30+
from tests.functional.utils import json_serialize, load_event
2631

2732
TABLE_NAME = "TEST_TABLE"
2833

@@ -275,6 +280,40 @@ def lambda_handler(event, context):
275280
stubber.deactivate()
276281

277282

283+
@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True, "event_key_jmespath": "body"}], indirect=True)
284+
def test_idempotent_lambda_first_execution_event_mutation(
285+
idempotency_config: IdempotencyConfig,
286+
persistence_store: DynamoDBPersistenceLayer,
287+
lambda_apigw_event,
288+
lambda_response,
289+
lambda_context,
290+
):
291+
"""
292+
Test idempotent decorator where lambda_handler mutates the event.
293+
Ensures we're passing data by value, not reference.
294+
"""
295+
event = copy.deepcopy(lambda_apigw_event)
296+
stubber = stub.Stubber(persistence_store.table.meta.client)
297+
ddb_response = {}
298+
stubber.add_response("put_item", ddb_response, build_idempotency_put_item_stub(data=event["body"]))
299+
stubber.add_response(
300+
"update_item",
301+
ddb_response,
302+
build_idempotency_update_item_stub(data=event["body"], handler_response=lambda_response),
303+
)
304+
stubber.activate()
305+
306+
@idempotent(config=idempotency_config, persistence_store=persistence_store)
307+
def lambda_handler(event, context):
308+
event.pop("body") # remove exact key we're using for idempotency
309+
return lambda_response
310+
311+
lambda_handler(event, lambda_context)
312+
313+
stubber.assert_no_pending_responses()
314+
stubber.deactivate()
315+
316+
278317
@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True)
279318
def test_idempotent_lambda_expired(
280319
idempotency_config: IdempotencyConfig,

tests/functional/idempotency/utils.py

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import hashlib
2+
from typing import Any, Dict
3+
4+
from botocore import stub
5+
6+
from tests.functional.utils import json_serialize
7+
8+
9+
def hash_idempotency_key(data: Any):
10+
"""Serialize data to JSON, encode, and hash it for idempotency key"""
11+
return hashlib.md5(json_serialize(data).encode()).hexdigest()
12+
13+
14+
def build_idempotency_put_item_stub(
15+
data: Dict, function_name: str = "test-func", handler_name: str = "lambda_handler"
16+
) -> Dict:
17+
idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}"
18+
return {
19+
"ConditionExpression": "attribute_not_exists(#id) OR #now < :now",
20+
"ExpressionAttributeNames": {"#id": "id", "#now": "expiration"},
21+
"ExpressionAttributeValues": {":now": stub.ANY},
22+
"Item": {"expiration": stub.ANY, "id": idempotency_key_hash, "status": "INPROGRESS"},
23+
"TableName": "TEST_TABLE",
24+
}
25+
26+
27+
def build_idempotency_update_item_stub(
28+
data: Dict,
29+
handler_response: Dict,
30+
function_name: str = "test-func",
31+
handler_name: str = "lambda_handler",
32+
) -> Dict:
33+
idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}"
34+
serialized_lambda_response = json_serialize(handler_response)
35+
return {
36+
"ExpressionAttributeNames": {"#expiry": "expiration", "#response_data": "data", "#status": "status"},
37+
"ExpressionAttributeValues": {
38+
":expiry": stub.ANY,
39+
":response_data": serialized_lambda_response,
40+
":status": "COMPLETED",
41+
},
42+
"Key": {"id": idempotency_key_hash},
43+
"TableName": "TEST_TABLE",
44+
"UpdateExpression": "SET #response_data = :response_data, " "#expiry = :expiry, #status = :status",
45+
}

tests/functional/utils.py

-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import base64
2-
import hashlib
32
import json
43
from pathlib import Path
54
from typing import Any
@@ -22,8 +21,3 @@ def b64_to_str(data: str) -> str:
2221

2322
def json_serialize(data):
2423
return json.dumps(data, sort_keys=True, cls=Encoder)
25-
26-
27-
def hash_idempotency_key(data: Any):
28-
"""Serialize data to JSON, encode, and hash it for idempotency key"""
29-
return hashlib.md5(json_serialize(data).encode()).hexdigest()

0 commit comments

Comments
 (0)