diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 62a8994889c..a6da94d30c0 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -39,6 +39,20 @@ ContextT = TypeVar("ContextT", bound="LambdaContext | DurableContextProtocol") +def _is_idempotency_disabled() -> bool: + # An empty or unparsable value is easy to end up with in IaC templates and CI variables; + # treat it as "not disabled" instead of failing every invocation + value = os.getenv(constants.IDEMPOTENCY_DISABLED_ENV, "false") + try: + return strtobool(value) + except ValueError: + logger.debug( + f"Ignoring {constants.IDEMPOTENCY_DISABLED_ENV}={value!r}: " + "not a recognised boolean, idempotency stays enabled", + ) + return False + + class _IdempotentHandlerDecorator(Protocol): # The return type is Any, as responses replayed from the persistence store are deserialized JSON def __call__( @@ -106,7 +120,7 @@ def handler(event, context): # Skip idempotency controls when POWERTOOLS_IDEMPOTENCY_DISABLED has a truthy value # Raises a warning if not running in development mode - if strtobool(os.getenv(constants.IDEMPOTENCY_DISABLED_ENV, "false")): + if _is_idempotency_disabled(): warnings.warn( message="Disabling idempotency is intended for development environments only " "and should not be used in production.", @@ -216,7 +230,7 @@ def process_order(customer_id: str, order: dict, **kwargs): def decorate(*args, **kwargs): # Skip idempotency controls when POWERTOOLS_IDEMPOTENCY_DISABLED has a truthy value # Raises a warning if not running in development mode - if strtobool(os.getenv(constants.IDEMPOTENCY_DISABLED_ENV, "false")): + if _is_idempotency_disabled(): warnings.warn( message="Disabling idempotency is intended for development environments only " "and should not be used in production.", diff --git a/tests/functional/idempotency/_boto3/test_idempotency.py b/tests/functional/idempotency/_boto3/test_idempotency.py index fe70251905d..7aa0e825a73 100644 --- a/tests/functional/idempotency/_boto3/test_idempotency.py +++ b/tests/functional/idempotency/_boto3/test_idempotency.py @@ -1704,6 +1704,38 @@ def dummy_handler(event, context): assert len(persistence_store.client.method_calls) == 4 +@pytest.mark.parametrize("idempotency_disabled_value", ["", " ", "maybe", None]) +def test_idempotency_disabled_envvar_empty_or_unparsable( + monkeypatch, + lambda_context, + persistence_store: DynamoDBPersistenceLayer, + idempotency_disabled_value, +): + # Scenario to validate an empty, unset or unparsable 'POWERTOOLS_IDEMPOTENCY_DISABLED' + # keeps idempotency enabled instead of crashing the invocation + mock_event = {"data": "value"} + + persistence_store.client = MagicMock() + + if idempotency_disabled_value is None: + monkeypatch.delenv("POWERTOOLS_IDEMPOTENCY_DISABLED", raising=False) + else: + monkeypatch.setenv("POWERTOOLS_IDEMPOTENCY_DISABLED", idempotency_disabled_value) + + @idempotent_function(data_keyword_argument="data", persistence_store=persistence_store) + def dummy(data): + return {"message": "hello"} + + @idempotent(persistence_store=persistence_store) + def dummy_handler(event, context): + return {"message": "hi"} + + assert dummy(data=mock_event) == {"message": "hello"} + assert dummy_handler(mock_event, lambda_context) == {"message": "hi"} + + assert len(persistence_store.client.method_calls) == 4 + + @pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True) def test_idempotent_function_duplicates( idempotency_config: IdempotencyConfig,