From 3f7a26f796d800c4c586b8f9ee465e24a4b2a6d1 Mon Sep 17 00:00:00 2001 From: Aditya Jain Date: Sat, 15 Aug 2026 15:39:17 -0700 Subject: [PATCH] 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