From 170848c9ab5f740a36665e07e5ab86f06e66418a Mon Sep 17 00:00:00 2001 From: Aditya Jain Date: Sat, 15 Aug 2026 15:39:17 -0700 Subject: [PATCH 1/2] fix(idempotency): is_missing_idempotency_key iterates dict keys instead of values is_missing_idempotency_key iterated `data` directly for dict input, which walks its keys, not its values. For a dict whose values are all None but whose keys are ordinary non-None strings -- exactly what a JMESPath multi-select expression like '{user: headers.user_id, order: body.order_id}' produces when the referenced event fields are absent -- this returns False ("not missing") when it should return True. With raise_on_no_idempotency_key=True, the safety check that's supposed to raise IdempotencyKeyError in this situation silently doesn't fire. With the default False, no warning is emitted and the persistence layer hashes the all-None dict into a real idempotency key, so unrelated invocations that both fail to populate those fields collapse onto the same idempotency key and get incorrectly deduplicated against each other. The existing test only covered a dict of {None: None} (None as the key), which happens to still pass under the old key-iterating behavior and so never caught this. Iterate data.values() for dict input instead, and add a test covering the realistic non-None-keys/all-None-values case. --- .../utilities/idempotency/persistence/base.py | 8 +++++++- tests/functional/idempotency/_boto3/test_idempotency.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 3d54a01f018..951d6dc2a18 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -131,7 +131,13 @@ def _get_hashed_idempotency_key(self, data: dict[str, Any]) -> str | None: @staticmethod def is_missing_idempotency_key(data) -> bool: - if isinstance(data, (tuple, list, dict)): + if isinstance(data, dict): + # Iterating a dict directly walks its keys, not its values, so a dict of + # {"user": None, "order": None} (e.g. from a JMESPath multi-select expression + # over fields that are all absent from the event) would otherwise be treated + # as present just because its keys are non-None strings. + return all(x is None for x in data.values()) + elif isinstance(data, (tuple, list)): return all(x is None for x in data) elif isinstance(data, (int, float, bool)): return False diff --git a/tests/functional/idempotency/_boto3/test_idempotency.py b/tests/functional/idempotency/_boto3/test_idempotency.py index e5916dba0fa..1810c6d86f8 100644 --- a/tests/functional/idempotency/_boto3/test_idempotency.py +++ b/tests/functional/idempotency/_boto3/test_idempotency.py @@ -1046,6 +1046,15 @@ def test_is_missing_idempotency_key(): assert BasePersistenceLayer.is_missing_idempotency_key((None, None)) # GIVEN a dict of Nones THEN is_missing_idempotency_key is True assert BasePersistenceLayer.is_missing_idempotency_key({None: None}) + # GIVEN a dict with non-None keys but all-None values (e.g. from a JMESPath + # multi-select expression over event fields that are all absent) THEN + # is_missing_idempotency_key is True. Iterating a dict directly walks its keys, + # not its values, so this case is not covered by the {None: None} case above, + # whose key happens to also be None. + assert BasePersistenceLayer.is_missing_idempotency_key({"user": None, "order": None}) + + # GIVEN a dict with a real value THEN is_missing_idempotency_key is False + assert BasePersistenceLayer.is_missing_idempotency_key({"user": "abc"}) is False # GIVEN True THEN is_missing_idempotency_key is False assert BasePersistenceLayer.is_missing_idempotency_key(True) is False From a959bb04769c04f2f4e2d6c3d87b7f88d7151020 Mon Sep 17 00:00:00 2001 From: Leandro Date: Fri, 28 Aug 2026 16:58:51 +0100 Subject: [PATCH 2/2] test(idempotency): cover missing dictionary keys --- .../utilities/idempotency/persistence/base.py | 5 +---- .../idempotency/_boto3/test_idempotency.py | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 951d6dc2a18..2271520139d 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -132,10 +132,7 @@ def _get_hashed_idempotency_key(self, data: dict[str, Any]) -> str | None: @staticmethod def is_missing_idempotency_key(data) -> bool: if isinstance(data, dict): - # Iterating a dict directly walks its keys, not its values, so a dict of - # {"user": None, "order": None} (e.g. from a JMESPath multi-select expression - # over fields that are all absent from the event) would otherwise be treated - # as present just because its keys are non-None strings. + # JMESPath multi-select dicts retain their keys when all selected values are missing. return all(x is None for x in data.values()) elif isinstance(data, (tuple, list)): return all(x is None for x in data) diff --git a/tests/functional/idempotency/_boto3/test_idempotency.py b/tests/functional/idempotency/_boto3/test_idempotency.py index 1810c6d86f8..a4a55dd947a 100644 --- a/tests/functional/idempotency/_boto3/test_idempotency.py +++ b/tests/functional/idempotency/_boto3/test_idempotency.py @@ -1046,14 +1046,9 @@ def test_is_missing_idempotency_key(): assert BasePersistenceLayer.is_missing_idempotency_key((None, None)) # GIVEN a dict of Nones THEN is_missing_idempotency_key is True assert BasePersistenceLayer.is_missing_idempotency_key({None: None}) - # GIVEN a dict with non-None keys but all-None values (e.g. from a JMESPath - # multi-select expression over event fields that are all absent) THEN - # is_missing_idempotency_key is True. Iterating a dict directly walks its keys, - # not its values, so this case is not covered by the {None: None} case above, - # whose key happens to also be None. + # GIVEN a dict with all-None values THEN is_missing_idempotency_key is True assert BasePersistenceLayer.is_missing_idempotency_key({"user": None, "order": None}) - - # GIVEN a dict with a real value THEN is_missing_idempotency_key is False + # GIVEN a dict with a non-None value THEN is_missing_idempotency_key is False assert BasePersistenceLayer.is_missing_idempotency_key({"user": "abc"}) is False # GIVEN True THEN is_missing_idempotency_key is False @@ -1123,6 +1118,19 @@ def test_raise_on_no_idempotency_key( assert "No data found to create a hashed idempotency_key" in str(excinfo.value) +def test_raise_on_no_idempotency_key_for_dict_jmespath(persistence_store: DynamoDBPersistenceLayer): + # GIVEN a dict multi-select expression whose values are missing + idempotency_config = IdempotencyConfig( + event_key_jmespath="{user: headers.user_id, order: body.order_id}", + raise_on_no_idempotency_key=True, + ) + persistence_store.configure(idempotency_config) + + # WHEN extracting the idempotency key THEN raise IdempotencyKeyError + with pytest.raises(IdempotencyKeyError, match="No data found to create a hashed idempotency_key"): + persistence_store._get_hashed_idempotency_key({"headers": {}, "body": {}}) + + @pytest.mark.parametrize( "idempotency_config", [