Skip to content

Commit 96cbdc1

Browse files
committed
fix(idempotency): sorting keys before hashing
1 parent 27e3930 commit 96cbdc1

File tree

3 files changed

+37
-15
lines changed

3 files changed

+37
-15
lines changed

aws_lambda_powertools/utilities/idempotency/persistence/base.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def _generate_hash(self, data: Any) -> str:
223223
224224
"""
225225
data = getattr(data, "raw_event", data) # could be a data class depending on decorator order
226-
hashed_data = self.hash_function(json.dumps(data, cls=Encoder).encode())
226+
hashed_data = self.hash_function(json.dumps(data, cls=Encoder, sort_keys=True).encode())
227227
return hashed_data.hexdigest()
228228

229229
def _validate_payload(self, data: Dict[str, Any], data_record: DataRecord) -> None:
@@ -310,7 +310,7 @@ def save_success(self, data: Dict[str, Any], result: dict) -> None:
310310
result: dict
311311
The response from function
312312
"""
313-
response_data = json.dumps(result, cls=Encoder)
313+
response_data = json.dumps(result, cls=Encoder, sort_keys=True)
314314

315315
data_record = DataRecord(
316316
idempotency_key=self._get_hashed_idempotency_key(data=data),

tests/functional/idempotency/conftest.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
TABLE_NAME = "TEST_TABLE"
2222

2323

24+
def serialize(data):
25+
return json.dumps(data, sort_keys=True, cls=Encoder)
26+
27+
2428
@pytest.fixture(scope="module")
2529
def config() -> Config:
2630
return Config(region_name="us-east-1")
@@ -62,12 +66,12 @@ def lambda_response():
6266

6367
@pytest.fixture(scope="module")
6468
def serialized_lambda_response(lambda_response):
65-
return json.dumps(lambda_response, cls=Encoder)
69+
return serialize(lambda_response)
6670

6771

6872
@pytest.fixture(scope="module")
6973
def deserialized_lambda_response(lambda_response):
70-
return json.loads(json.dumps(lambda_response, cls=Encoder))
74+
return json.loads(serialize(lambda_response))
7175

7276

7377
@pytest.fixture
@@ -144,20 +148,20 @@ def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_vali
144148
def hashed_idempotency_key(lambda_apigw_event, default_jmespath, lambda_context):
145149
compiled_jmespath = jmespath.compile(default_jmespath)
146150
data = compiled_jmespath.search(lambda_apigw_event)
147-
return "test-func#" + hashlib.md5(json.dumps(data).encode()).hexdigest()
151+
return "test-func#" + hashlib.md5(serialize(data).encode()).hexdigest()
148152

149153

150154
@pytest.fixture
151155
def hashed_idempotency_key_with_envelope(lambda_apigw_event):
152156
event = extract_data_from_envelope(
153157
data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, jmespath_options={}
154158
)
155-
return "test-func#" + hashlib.md5(json.dumps(event).encode()).hexdigest()
159+
return "test-func#" + hashlib.md5(serialize(event).encode()).hexdigest()
156160

157161

158162
@pytest.fixture
159163
def hashed_validation_key(lambda_apigw_event):
160-
return hashlib.md5(json.dumps(lambda_apigw_event["requestContext"]).encode()).hexdigest()
164+
return hashlib.md5(serialize(lambda_apigw_event["requestContext"]).encode()).hexdigest()
161165

162166

163167
@pytest.fixture

tests/functional/idempotency/test_idempotency.py

+26-8
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent, idempotent_function
2222
from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer, DataRecord
2323
from aws_lambda_powertools.utilities.validation import envelopes, validator
24+
from tests.functional.idempotency.conftest import serialize
2425
from tests.functional.utils import load_event
2526

2627
TABLE_NAME = "TEST_TABLE"
@@ -741,7 +742,7 @@ def test_default_no_raise_on_missing_idempotency_key(
741742
hashed_key = persistence_store._get_hashed_idempotency_key({})
742743

743744
# THEN return the hash of None
744-
expected_value = "test-func#" + md5(json.dumps(None).encode()).hexdigest()
745+
expected_value = "test-func#" + md5(serialize(None).encode()).hexdigest()
745746
assert expected_value == hashed_key
746747

747748

@@ -785,7 +786,7 @@ def test_jmespath_with_powertools_json(
785786
expected_value = [sub_attr_value, key_attr_value]
786787
api_gateway_proxy_event = {
787788
"requestContext": {"authorizer": {"claims": {"sub": sub_attr_value}}},
788-
"body": json.dumps({"id": key_attr_value}),
789+
"body": serialize({"id": key_attr_value}),
789790
}
790791

791792
# WHEN calling _get_hashed_idempotency_key
@@ -869,7 +870,7 @@ def _delete_record(self, data_record: DataRecord) -> None:
869870
def test_idempotent_lambda_event_source(lambda_context):
870871
# Scenario to validate that we can use the event_source decorator before or after the idempotent decorator
871872
mock_event = load_event("apiGatewayProxyV2Event.json")
872-
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest())
873+
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
873874
expected_result = {"message": "Foo"}
874875

875876
# GIVEN an event_source decorator
@@ -889,7 +890,7 @@ def lambda_handler(event, _):
889890
def test_idempotent_function():
890891
# Scenario to validate we can use idempotent_function with any function
891892
mock_event = {"data": "value"}
892-
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest())
893+
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
893894
expected_result = {"message": "Foo"}
894895

895896
@idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record")
@@ -906,7 +907,7 @@ def test_idempotent_function_arbitrary_args_kwargs():
906907
# Scenario to validate we can use idempotent_function with a function
907908
# with an arbitrary number of args and kwargs
908909
mock_event = {"data": "value"}
909-
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest())
910+
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
910911
expected_result = {"message": "Foo"}
911912

912913
@idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record")
@@ -921,7 +922,7 @@ def record_handler(arg_one, arg_two, record, is_record):
921922

922923
def test_idempotent_function_invalid_data_kwarg():
923924
mock_event = {"data": "value"}
924-
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest())
925+
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
925926
expected_result = {"message": "Foo"}
926927
keyword_argument = "payload"
927928

@@ -938,7 +939,7 @@ def record_handler(record):
938939

939940
def test_idempotent_function_arg_instead_of_kwarg():
940941
mock_event = {"data": "value"}
941-
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest())
942+
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
942943
expected_result = {"message": "Foo"}
943944
keyword_argument = "record"
944945

@@ -956,7 +957,7 @@ def record_handler(record):
956957
def test_idempotent_function_and_lambda_handler(lambda_context):
957958
# Scenario to validate we can use both idempotent_function and idempotent decorators
958959
mock_event = {"data": "value"}
959-
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest())
960+
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
960961
expected_result = {"message": "Foo"}
961962

962963
@idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record")
@@ -976,3 +977,20 @@ def lambda_handler(event, _):
976977
# THEN we expect the function and lambda handler to execute successfully
977978
assert fn_result == expected_result
978979
assert handler_result == expected_result
980+
981+
982+
def test_idempotent_data_sorting():
983+
# Scenario to validate same data in different order hashes to the same idempotency key
984+
data_one = {"data": "test message 1", "more_data": "more data 1"}
985+
data_two = {"more_data": "more data 1", "data": "test message 1"}
986+
987+
# Assertion will happen in MockPersistenceLayer
988+
persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(data_one).encode()).hexdigest())
989+
990+
# GIVEN
991+
@idempotent_function(data_keyword_argument="payload", persistence_store=persistence_layer)
992+
def dummy(payload):
993+
return {"message": "hello"}
994+
995+
# WHEN
996+
dummy(payload=data_two)

0 commit comments

Comments
 (0)