From c3d2fca9d4344a37e63a8c66cfb63ad4f6eb98f3 Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Mon, 27 Apr 2026 18:03:42 -0400 Subject: [PATCH 01/10] fix(idempotency): fix str(None) producing "None" string in Redis persistence Replace str(item.get(...)) with item.get(...) to avoid storing the string "None" when a value is missing from Redis hash map. When data_attr or validation_key_attr is missing from Redis, item.get() returns None. Wrapping with str() converts it to the string "None" which is incorrect. Now correctly returns None. Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- .../utilities/idempotency/persistence/redis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/redis.py b/aws_lambda_powertools/utilities/idempotency/persistence/redis.py index d1c490ee0f3..0113c8daa0d 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/redis.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/redis.py @@ -332,8 +332,8 @@ def _item_to_data_record(self, idempotency_key: str, item: dict[str, Any]) -> Da idempotency_key=idempotency_key, status=item[self.status_attr], in_progress_expiry_timestamp=in_progress_expiry_timestamp, - response_data=str(item.get(self.data_attr)), - payload_hash=str(item.get(self.validation_key_attr)), + response_data=item.get(self.data_attr), + payload_hash=item.get(self.validation_key_attr), expiry_timestamp=item.get("expiration"), ) From 002eccbb5876c1b3d226ffa338506fee966f84fc Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Mon, 27 Apr 2026 18:15:00 -0400 Subject: [PATCH 02/10] refactor(idempotency): extract duplicated idempotency key null-check into helper method Replace 4 identical null-check blocks across save_success, save_inprogress, delete_record, and get_record with a single helper method _get_idempotency_key_or_return_none() to reduce code duplication. The helper encapsulates the pattern of calling _get_hashed_idempotency_key() and returning None early if the key is None, keeping each method cleaner and easier to read. Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- .../utilities/idempotency/persistence/base.py | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 3d54a01f018..d05d2d76d5d 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -137,6 +137,27 @@ def is_missing_idempotency_key(data) -> bool: return False return not data + def _get_idempotency_key_or_return_none(self, data: dict[str, Any]) -> str | None: + """ + Get hashed idempotency key or return None early if key is None. + If the idempotency key is None, no data will be saved in the Persistence Layer. + See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 + + Parameters + ---------- + data: dict[str, Any] + Payload + + Returns + ------- + str | None + Hashed idempotency key or None + """ + idempotency_key = self._get_hashed_idempotency_key(data=data) + if idempotency_key is None: + return None + return idempotency_key + def _get_hashed_payload(self, data: dict[str, Any]) -> str: """ Extract payload using validation key jmespath and return a hashed representation @@ -267,10 +288,8 @@ def save_success(self, data: dict[str, Any], result: dict) -> None: result: dict The response from function """ - idempotency_key = self._get_hashed_idempotency_key(data=data) + idempotency_key = self._get_idempotency_key_or_return_none(data=data) if idempotency_key is None: - # If the idempotency key is None, no data will be saved in the Persistence Layer. - # See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 return None response_data = json.dumps(result, cls=Encoder, sort_keys=True) @@ -302,10 +321,8 @@ def save_inprogress(self, data: dict[str, Any], remaining_time_in_millis: int | If expiry of in-progress invocations is enabled, this will contain the remaining time available in millis """ - idempotency_key = self._get_hashed_idempotency_key(data=data) + idempotency_key = self._get_idempotency_key_or_return_none(data=data) if idempotency_key is None: - # If the idempotency key is None, no data will be saved in the Persistence Layer. - # See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 return None data_record = DataRecord( @@ -349,10 +366,8 @@ def delete_record(self, data: dict[str, Any], exception: Exception): The exception raised by the function """ - idempotency_key = self._get_hashed_idempotency_key(data=data) + idempotency_key = self._get_idempotency_key_or_return_none(data=data) if idempotency_key is None: - # If the idempotency key is None, no data will be saved in the Persistence Layer. - # See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 return None data_record = DataRecord(idempotency_key=idempotency_key) @@ -387,10 +402,8 @@ def get_record(self, data: dict[str, Any]) -> DataRecord | None: Payload doesn't match the stored record for the given idempotency key """ - idempotency_key = self._get_hashed_idempotency_key(data=data) + idempotency_key = self._get_idempotency_key_or_return_none(data=data) if idempotency_key is None: - # If the idempotency key is None, no data will be saved in the Persistence Layer. - # See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 return None cached_record = self._retrieve_from_cache(idempotency_key=idempotency_key) From 78058def1f691644cefdd0029ff71614d96c3720 Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Mon, 27 Apr 2026 18:19:46 -0400 Subject: [PATCH 03/10] test(idempotency): add unit tests for tech debt fixes in issue #8090 Fix 1 - str(None) in Redis _item_to_data_record: - Missing data_attr returns None not string "None" - Existing data_attr returns value correctly - Demonstrates old bug vs new correct behavior Fix 2 - _get_idempotency_key_or_return_none helper: - Returns None when key is None - Returns key string when key exists - Correctly used in save_success, save_inprogress, delete_record, and get_record (all return None early) Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- .../test_idempotency_tech_debt_8090.py | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/functional/idempotency/test_idempotency_tech_debt_8090.py diff --git a/tests/functional/idempotency/test_idempotency_tech_debt_8090.py b/tests/functional/idempotency/test_idempotency_tech_debt_8090.py new file mode 100644 index 00000000000..adacbf32b67 --- /dev/null +++ b/tests/functional/idempotency/test_idempotency_tech_debt_8090.py @@ -0,0 +1,208 @@ +""" +Unit tests for tech debt fixes in idempotency utility. +Issue: https://github.com/aws-powertools/powertools-lambda-python/issues/8090 +""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from aws_lambda_powertools.utilities.idempotency.persistence.datarecord import DataRecord + +# ── Helpers ────────────────────────────────────────────────────────────────── + +class MockPersistenceLayer: + """Minimal concrete subclass of BasePersistenceLayer for testing.""" + + def _get_record(self, idempotency_key): + ... + + def _put_record(self, data_record): + ... + + def _update_record(self, data_record): + ... + + def _delete_record(self, data_record): + ... + +# ── Fix 1 for item-3: str(None) in Redis _item_to_data_record ─────────────────────────── + +def test_redis_missing_data_attr_returns_none_not_string(): + """ + When data_attr is missing from Redis hash, response_data should be + None — not the string "None". + """ + item = { + "status": "COMPLETED", + "expiration": 9999999999, + # data_attr and validation_key_attr intentionally missing + } + + data_attr = "data" + validation_key_attr = "validation" + + response_data = item.get(data_attr) + payload_hash = item.get(validation_key_attr) + + assert response_data is None, "Missing data_attr should return None, not string 'None'" + assert payload_hash is None, "Missing validation_key_attr should return None, not string 'None'" + +def test_str_none_produces_wrong_string(): + """ + Demonstrate the old bug: str(None) produces the string 'None' + instead of actual None. + """ + missing_value = None + + # Old broken behavior + old_result = str(missing_value) + assert old_result == "None" + + # New correct behavior + new_result = missing_value + assert new_result is None + +def test_redis_existing_data_attr_returns_value(): + """ + When data_attr exists in Redis hash, response_data should be + returned as-is without str() conversion. + """ + item = { + "status": "COMPLETED", + "expiration": 9999999999, + "data": '{"payment_id": 123}', + "validation": "abc123hash", + } + + data_attr = "data" + validation_key_attr = "validation" + + response_data = item.get(data_attr) + payload_hash = item.get(validation_key_attr) + + assert response_data == '{"payment_id": 123}' + assert payload_hash == "abc123hash" + +# ── Fix 2 for item-4: _get_idempotency_key_or_return_none helper ──────────────────────── + +def test_helper_returns_none_when_key_is_none(): + """ + _get_idempotency_key_or_return_none should return None when + _get_hashed_idempotency_key returns None. + """ + from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer + + class ConcretePersistenceLayer(BasePersistenceLayer): + def _get_record(self, idempotency_key): ... + def _put_record(self, data_record): ... + def _update_record(self, data_record): ... + def _delete_record(self, data_record): ... + + layer = ConcretePersistenceLayer() + layer._get_hashed_idempotency_key = MagicMock(return_value=None) + + result = layer._get_idempotency_key_or_return_none(data={"key": "value"}) + + assert result is None + layer._get_hashed_idempotency_key.assert_called_once_with(data={"key": "value"}) + +def test_helper_returns_key_when_present(): + """ + _get_idempotency_key_or_return_none should return the key string + when _get_hashed_idempotency_key returns a valid key. + """ + from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer + + class ConcretePersistenceLayer(BasePersistenceLayer): + def _get_record(self, idempotency_key): ... + def _put_record(self, data_record): ... + def _update_record(self, data_record): ... + def _delete_record(self, data_record): ... + + layer = ConcretePersistenceLayer() + expected_key = "my-function#abc123hash" + layer._get_hashed_idempotency_key = MagicMock(return_value=expected_key) + + result = layer._get_idempotency_key_or_return_none(data={"key": "value"}) + + assert result == expected_key + layer._get_hashed_idempotency_key.assert_called_once_with(data={"key": "value"}) + +def test_helper_is_used_in_save_success(): + """ + save_success should return None early when idempotency key is None + (no data saved to persistence layer). + """ + from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer + + class ConcretePersistenceLayer(BasePersistenceLayer): + def _get_record(self, idempotency_key): ... + def _put_record(self, data_record): ... + def _update_record(self, data_record): ... + def _delete_record(self, data_record): ... + + layer = ConcretePersistenceLayer() + layer._get_hashed_idempotency_key = MagicMock(return_value=None) + + result = layer.save_success(data={"key": "value"}, result={"status": "ok"}) + + assert result is None + +def test_helper_is_used_in_save_inprogress(): + """ + save_inprogress should return None early when idempotency key is None. + """ + from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer + + class ConcretePersistenceLayer(BasePersistenceLayer): + def _get_record(self, idempotency_key): ... + def _put_record(self, data_record): ... + def _update_record(self, data_record): ... + def _delete_record(self, data_record): ... + + layer = ConcretePersistenceLayer() + layer._get_hashed_idempotency_key = MagicMock(return_value=None) + + result = layer.save_inprogress(data={"key": "value"}) + + assert result is None + +def test_helper_is_used_in_delete_record(): + """ + delete_record should return None early when idempotency key is None. + """ + from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer + + class ConcretePersistenceLayer(BasePersistenceLayer): + def _get_record(self, idempotency_key): ... + def _put_record(self, data_record): ... + def _update_record(self, data_record): ... + def _delete_record(self, data_record): ... + + layer = ConcretePersistenceLayer() + layer._get_hashed_idempotency_key = MagicMock(return_value=None) + + result = layer.delete_record(data={"key": "value"}, exception=Exception("test")) + + assert result is None + +def test_helper_is_used_in_get_record(): + """ + get_record should return None early when idempotency key is None. + """ + from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer + + class ConcretePersistenceLayer(BasePersistenceLayer): + def _get_record(self, idempotency_key): ... + def _put_record(self, data_record): ... + def _update_record(self, data_record): ... + def _delete_record(self, data_record): ... + + layer = ConcretePersistenceLayer() + layer._get_hashed_idempotency_key = MagicMock(return_value=None) + + result = layer.get_record(data={"key": "value"}) + + assert result is None From 1b5edc6502c07196461a0d6614b08e3ca2331144 Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Tue, 28 Apr 2026 06:40:01 -0400 Subject: [PATCH 04/10] fix(idempotency): fix falsy response handling and inconsistent status constant Fix 1 - Falsy response handling in _get_function_response(): Replace `if response else None` with `if response is not None else None`. So valid falsy return values (0, False, {}, [], "") are correctly serialized and stored instead of being silently discarded. Fix 2 - Inconsistent status constant in _process_idempotency(): Replace string literal "INPROGRESS" with STATUS_CONSTANTS["INPROGRESS"] for consistency with the rest of the codebase. Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- aws_lambda_powertools/utilities/idempotency/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/base.py b/aws_lambda_powertools/utilities/idempotency/base.py index f6a3563c103..bee109ef842 100644 --- a/aws_lambda_powertools/utilities/idempotency/base.py +++ b/aws_lambda_powertools/utilities/idempotency/base.py @@ -167,7 +167,7 @@ def _process_idempotency(self, is_replay: bool): # We give preference to ReturnValuesOnConditionCheckFailure because it is a faster and more cost-effective # way of retrieving the existing record after a failed conditional write operation. record = exc.old_data_record or self._get_idempotency_record() - if is_replay and record is not None and record.status == "INPROGRESS": + if is_replay and record is not None and record.status == STATUS_CONSTANTS["INPROGRESS"]: return self._get_function_response() # If a record is found, handle it for status if record: @@ -296,7 +296,7 @@ def _get_function_response(self): else: try: - serialized_response: dict = self.output_serializer.to_dict(response) if response else None + serialized_response: dict = self.output_serializer.to_dict(response) if response is not None else None self.persistence_store.save_success(data=self.data, result=serialized_response) except Exception as save_exception: raise IdempotencyPersistenceLayerError( From 2880a7cd4c3ed02691a202052b514b916ae04068 Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Tue, 28 Apr 2026 06:43:34 -0400 Subject: [PATCH 05/10] refactor(idempotency): revert helper method - restore original inline null-check pattern Per Leandro Damascena's feedback: _get_idempotency_key_or_return_none() helper added indirection without reducing duplication since the if None: return None check remained in all 4 callers anyway. Restored original inline 3-line pattern in save_success, save_inprogress, delete_record, and get_record which is clearer and instantly readable. Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- .../utilities/idempotency/persistence/base.py | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index d05d2d76d5d..d26b125bafb 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -136,27 +136,6 @@ def is_missing_idempotency_key(data) -> bool: elif isinstance(data, (int, float, bool)): return False return not data - - def _get_idempotency_key_or_return_none(self, data: dict[str, Any]) -> str | None: - """ - Get hashed idempotency key or return None early if key is None. - If the idempotency key is None, no data will be saved in the Persistence Layer. - See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 - - Parameters - ---------- - data: dict[str, Any] - Payload - - Returns - ------- - str | None - Hashed idempotency key or None - """ - idempotency_key = self._get_hashed_idempotency_key(data=data) - if idempotency_key is None: - return None - return idempotency_key def _get_hashed_payload(self, data: dict[str, Any]) -> str: """ @@ -288,8 +267,10 @@ def save_success(self, data: dict[str, Any], result: dict) -> None: result: dict The response from function """ - idempotency_key = self._get_idempotency_key_or_return_none(data=data) + idempotency_key = self._get_hashed_idempotency_key(data=data) if idempotency_key is None: + # If the idempotency key is None, no data will be saved in the Persistence Layer. + # See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 return None response_data = json.dumps(result, cls=Encoder, sort_keys=True) @@ -321,8 +302,10 @@ def save_inprogress(self, data: dict[str, Any], remaining_time_in_millis: int | If expiry of in-progress invocations is enabled, this will contain the remaining time available in millis """ - idempotency_key = self._get_idempotency_key_or_return_none(data=data) + idempotency_key = self._get_hashed_idempotency_key(data=data) if idempotency_key is None: + # If the idempotency key is None, no data will be saved in the Persistence Layer. + # See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 return None data_record = DataRecord( @@ -366,8 +349,10 @@ def delete_record(self, data: dict[str, Any], exception: Exception): The exception raised by the function """ - idempotency_key = self._get_idempotency_key_or_return_none(data=data) + idempotency_key = self._get_hashed_idempotency_key(data=data) if idempotency_key is None: + # If the idempotency key is None, no data will be saved in the Persistence Layer. + # See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 return None data_record = DataRecord(idempotency_key=idempotency_key) @@ -402,8 +387,10 @@ def get_record(self, data: dict[str, Any]) -> DataRecord | None: Payload doesn't match the stored record for the given idempotency key """ - idempotency_key = self._get_idempotency_key_or_return_none(data=data) + idempotency_key = self._get_hashed_idempotency_key(data=data) if idempotency_key is None: + # If the idempotency key is None, no data will be saved in the Persistence Layer. + # See: https://github.com/aws-powertools/powertools-lambda-python/issues/2465 return None cached_record = self._retrieve_from_cache(idempotency_key=idempotency_key) From e734420b8dd22e8cd7ab3b98cc515c769a3aa465 Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Tue, 28 Apr 2026 06:44:29 -0400 Subject: [PATCH 06/10] test(idempotency): remove standalone test file per maintainer feedback Tests should be added to existing test files following established patterns, not in new standalone files. The Redis fix will be tested in _redis/test_redis_layer.py next to the existing test_item_to_datarecord_conversion. Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- .../test_idempotency_tech_debt_8090.py | 208 ------------------ 1 file changed, 208 deletions(-) delete mode 100644 tests/functional/idempotency/test_idempotency_tech_debt_8090.py diff --git a/tests/functional/idempotency/test_idempotency_tech_debt_8090.py b/tests/functional/idempotency/test_idempotency_tech_debt_8090.py deleted file mode 100644 index adacbf32b67..00000000000 --- a/tests/functional/idempotency/test_idempotency_tech_debt_8090.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -Unit tests for tech debt fixes in idempotency utility. -Issue: https://github.com/aws-powertools/powertools-lambda-python/issues/8090 -""" -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest - -from aws_lambda_powertools.utilities.idempotency.persistence.datarecord import DataRecord - -# ── Helpers ────────────────────────────────────────────────────────────────── - -class MockPersistenceLayer: - """Minimal concrete subclass of BasePersistenceLayer for testing.""" - - def _get_record(self, idempotency_key): - ... - - def _put_record(self, data_record): - ... - - def _update_record(self, data_record): - ... - - def _delete_record(self, data_record): - ... - -# ── Fix 1 for item-3: str(None) in Redis _item_to_data_record ─────────────────────────── - -def test_redis_missing_data_attr_returns_none_not_string(): - """ - When data_attr is missing from Redis hash, response_data should be - None — not the string "None". - """ - item = { - "status": "COMPLETED", - "expiration": 9999999999, - # data_attr and validation_key_attr intentionally missing - } - - data_attr = "data" - validation_key_attr = "validation" - - response_data = item.get(data_attr) - payload_hash = item.get(validation_key_attr) - - assert response_data is None, "Missing data_attr should return None, not string 'None'" - assert payload_hash is None, "Missing validation_key_attr should return None, not string 'None'" - -def test_str_none_produces_wrong_string(): - """ - Demonstrate the old bug: str(None) produces the string 'None' - instead of actual None. - """ - missing_value = None - - # Old broken behavior - old_result = str(missing_value) - assert old_result == "None" - - # New correct behavior - new_result = missing_value - assert new_result is None - -def test_redis_existing_data_attr_returns_value(): - """ - When data_attr exists in Redis hash, response_data should be - returned as-is without str() conversion. - """ - item = { - "status": "COMPLETED", - "expiration": 9999999999, - "data": '{"payment_id": 123}', - "validation": "abc123hash", - } - - data_attr = "data" - validation_key_attr = "validation" - - response_data = item.get(data_attr) - payload_hash = item.get(validation_key_attr) - - assert response_data == '{"payment_id": 123}' - assert payload_hash == "abc123hash" - -# ── Fix 2 for item-4: _get_idempotency_key_or_return_none helper ──────────────────────── - -def test_helper_returns_none_when_key_is_none(): - """ - _get_idempotency_key_or_return_none should return None when - _get_hashed_idempotency_key returns None. - """ - from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer - - class ConcretePersistenceLayer(BasePersistenceLayer): - def _get_record(self, idempotency_key): ... - def _put_record(self, data_record): ... - def _update_record(self, data_record): ... - def _delete_record(self, data_record): ... - - layer = ConcretePersistenceLayer() - layer._get_hashed_idempotency_key = MagicMock(return_value=None) - - result = layer._get_idempotency_key_or_return_none(data={"key": "value"}) - - assert result is None - layer._get_hashed_idempotency_key.assert_called_once_with(data={"key": "value"}) - -def test_helper_returns_key_when_present(): - """ - _get_idempotency_key_or_return_none should return the key string - when _get_hashed_idempotency_key returns a valid key. - """ - from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer - - class ConcretePersistenceLayer(BasePersistenceLayer): - def _get_record(self, idempotency_key): ... - def _put_record(self, data_record): ... - def _update_record(self, data_record): ... - def _delete_record(self, data_record): ... - - layer = ConcretePersistenceLayer() - expected_key = "my-function#abc123hash" - layer._get_hashed_idempotency_key = MagicMock(return_value=expected_key) - - result = layer._get_idempotency_key_or_return_none(data={"key": "value"}) - - assert result == expected_key - layer._get_hashed_idempotency_key.assert_called_once_with(data={"key": "value"}) - -def test_helper_is_used_in_save_success(): - """ - save_success should return None early when idempotency key is None - (no data saved to persistence layer). - """ - from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer - - class ConcretePersistenceLayer(BasePersistenceLayer): - def _get_record(self, idempotency_key): ... - def _put_record(self, data_record): ... - def _update_record(self, data_record): ... - def _delete_record(self, data_record): ... - - layer = ConcretePersistenceLayer() - layer._get_hashed_idempotency_key = MagicMock(return_value=None) - - result = layer.save_success(data={"key": "value"}, result={"status": "ok"}) - - assert result is None - -def test_helper_is_used_in_save_inprogress(): - """ - save_inprogress should return None early when idempotency key is None. - """ - from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer - - class ConcretePersistenceLayer(BasePersistenceLayer): - def _get_record(self, idempotency_key): ... - def _put_record(self, data_record): ... - def _update_record(self, data_record): ... - def _delete_record(self, data_record): ... - - layer = ConcretePersistenceLayer() - layer._get_hashed_idempotency_key = MagicMock(return_value=None) - - result = layer.save_inprogress(data={"key": "value"}) - - assert result is None - -def test_helper_is_used_in_delete_record(): - """ - delete_record should return None early when idempotency key is None. - """ - from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer - - class ConcretePersistenceLayer(BasePersistenceLayer): - def _get_record(self, idempotency_key): ... - def _put_record(self, data_record): ... - def _update_record(self, data_record): ... - def _delete_record(self, data_record): ... - - layer = ConcretePersistenceLayer() - layer._get_hashed_idempotency_key = MagicMock(return_value=None) - - result = layer.delete_record(data={"key": "value"}, exception=Exception("test")) - - assert result is None - -def test_helper_is_used_in_get_record(): - """ - get_record should return None early when idempotency key is None. - """ - from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer - - class ConcretePersistenceLayer(BasePersistenceLayer): - def _get_record(self, idempotency_key): ... - def _put_record(self, data_record): ... - def _update_record(self, data_record): ... - def _delete_record(self, data_record): ... - - layer = ConcretePersistenceLayer() - layer._get_hashed_idempotency_key = MagicMock(return_value=None) - - result = layer.get_record(data={"key": "value"}) - - assert result is None From 01f565a851e9fe045d1c0ecf155357bc0814c26d Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Tue, 28 Apr 2026 06:45:53 -0400 Subject: [PATCH 07/10] test(idempotency): add regression test for str(None) fix in _item_to_data_record Added single test next to existing test_item_to_datarecord_conversion to verify missing Redis attributes return None instead of string "None". Follows existing test patterns using fixtures instead of MagicMock. Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- .../idempotency/_redis/test_redis_layer.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/functional/idempotency/_redis/test_redis_layer.py b/tests/functional/idempotency/_redis/test_redis_layer.py index c2a0976b0ab..b5730570ba0 100644 --- a/tests/functional/idempotency/_redis/test_redis_layer.py +++ b/tests/functional/idempotency/_redis/test_redis_layer.py @@ -330,6 +330,25 @@ def test_item_to_datarecord_conversion(valid_record): assert record.in_progress_expiry_timestamp == item[layer.in_progress_expiry_attr] +def test_item_to_datarecord_conversion_missing_optional_attributes(persistence_store_redis): + """ + When data_attr or validation_key_attr is missing from Redis, + response_data and payload_hash should be None — not the string "None". + Regression test for: https://github.com/aws-powertools/powertools-lambda-python/issues/8090 + """ + idempotency_key = "test-func#abc123" + item = { + persistence_store_redis.status_attr: "COMPLETED", + persistence_store_redis.expiry_attr: 9999999999, + # data_attr and validation_key_attr intentionally absent + } + + record = persistence_store_redis._item_to_data_record(idempotency_key, item) + + assert record.response_data is None + assert record.payload_hash is None + + def test_idempotent_function_and_lambda_handler_redis_basic( persistence_store_standalone_redis: RedisCachePersistenceLayer, lambda_context, From fec6b64bee1cfd59103ad4fd4efa7298ce0265bf Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Wed, 29 Apr 2026 17:59:37 -0400 Subject: [PATCH 08/10] fix(idempotency): fix ruff formatting - remove trailing whitespace in base.py Remove trailing whitespace on blank line between is_missing_idempotency_key and _get_hashed_payload methods to pass ruff format check. Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- aws_lambda_powertools/utilities/idempotency/persistence/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index d26b125bafb..3d54a01f018 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -136,7 +136,7 @@ def is_missing_idempotency_key(data) -> bool: elif isinstance(data, (int, float, bool)): return False return not data - + def _get_hashed_payload(self, data: dict[str, Any]) -> str: """ Extract payload using validation key jmespath and return a hashed representation From 75fec0aaaa6f57eae32147a57d201821566b31dc Mon Sep 17 00:00:00 2001 From: hirenkumar-n-dholariya Date: Wed, 29 Apr 2026 18:05:27 -0400 Subject: [PATCH 09/10] fix(idempotency): fix test fixture name and trailing whitespace in test_redis_layer.py - Fix fixture name from persistence_store_redis to persistence_store_standalone_redis to match existing fixture defined in the test file - Remove trailing whitespace on blank line after test function to pass ruff format check Part of #8090 Signed-off-by: hirenkumar-n-dholariya --- .../functional/idempotency/_redis/test_redis_layer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/functional/idempotency/_redis/test_redis_layer.py b/tests/functional/idempotency/_redis/test_redis_layer.py index b5730570ba0..3af02e49c47 100644 --- a/tests/functional/idempotency/_redis/test_redis_layer.py +++ b/tests/functional/idempotency/_redis/test_redis_layer.py @@ -330,7 +330,7 @@ def test_item_to_datarecord_conversion(valid_record): assert record.in_progress_expiry_timestamp == item[layer.in_progress_expiry_attr] -def test_item_to_datarecord_conversion_missing_optional_attributes(persistence_store_redis): +def test_item_to_datarecord_conversion_missing_optional_attributes(persistence_store_standalone_redis): """ When data_attr or validation_key_attr is missing from Redis, response_data and payload_hash should be None — not the string "None". @@ -338,16 +338,16 @@ def test_item_to_datarecord_conversion_missing_optional_attributes(persistence_s """ idempotency_key = "test-func#abc123" item = { - persistence_store_redis.status_attr: "COMPLETED", - persistence_store_redis.expiry_attr: 9999999999, + persistence_store_standalone_redis.status_attr: "COMPLETED", + persistence_store_standalone_redis.expiry_attr: 9999999999, # data_attr and validation_key_attr intentionally absent } - record = persistence_store_redis._item_to_data_record(idempotency_key, item) + record = persistence_store_standalone_redis._item_to_data_record(idempotency_key, item) assert record.response_data is None assert record.payload_hash is None - + def test_idempotent_function_and_lambda_handler_redis_basic( persistence_store_standalone_redis: RedisCachePersistenceLayer, From 75e47d4a13926699bc9054f589bcdf6f9b03f3fe Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Thu, 30 Apr 2026 09:00:51 +0100 Subject: [PATCH 10/10] fix: small changes --- .../utilities/idempotency/persistence/redis.py | 4 ++-- tests/functional/idempotency/_redis/test_redis_layer.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/redis.py b/aws_lambda_powertools/utilities/idempotency/persistence/redis.py index 0113c8daa0d..9327c33bda7 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/redis.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/redis.py @@ -332,8 +332,8 @@ def _item_to_data_record(self, idempotency_key: str, item: dict[str, Any]) -> Da idempotency_key=idempotency_key, status=item[self.status_attr], in_progress_expiry_timestamp=in_progress_expiry_timestamp, - response_data=item.get(self.data_attr), - payload_hash=item.get(self.validation_key_attr), + response_data=item.get(self.data_attr, ""), + payload_hash=item.get(self.validation_key_attr, ""), expiry_timestamp=item.get("expiration"), ) diff --git a/tests/functional/idempotency/_redis/test_redis_layer.py b/tests/functional/idempotency/_redis/test_redis_layer.py index 3af02e49c47..22c3b9a6d83 100644 --- a/tests/functional/idempotency/_redis/test_redis_layer.py +++ b/tests/functional/idempotency/_redis/test_redis_layer.py @@ -333,7 +333,7 @@ def test_item_to_datarecord_conversion(valid_record): def test_item_to_datarecord_conversion_missing_optional_attributes(persistence_store_standalone_redis): """ When data_attr or validation_key_attr is missing from Redis, - response_data and payload_hash should be None — not the string "None". + response_data and payload_hash should be empty string, not the string "None". Regression test for: https://github.com/aws-powertools/powertools-lambda-python/issues/8090 """ idempotency_key = "test-func#abc123" @@ -345,8 +345,8 @@ def test_item_to_datarecord_conversion_missing_optional_attributes(persistence_s record = persistence_store_standalone_redis._item_to_data_record(idempotency_key, item) - assert record.response_data is None - assert record.payload_hash is None + assert record.response_data == "" + assert record.payload_hash == "" def test_idempotent_function_and_lambda_handler_redis_basic(