diff --git a/aws_lambda_powertools/utilities/parameters/appconfig.py b/aws_lambda_powertools/utilities/parameters/appconfig.py index 87e7d2b17e8..2606c3b7e5e 100644 --- a/aws_lambda_powertools/utilities/parameters/appconfig.py +++ b/aws_lambda_powertools/utilities/parameters/appconfig.py @@ -13,10 +13,9 @@ from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.functions import ( resolve_env_var_choice, - resolve_max_age, ) -from aws_lambda_powertools.utilities.parameters.base import BaseProvider -from aws_lambda_powertools.utilities.parameters.constants import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS +from aws_lambda_powertools.utilities.parameters.base import BaseProvider, resolve_parameters_max_age +from aws_lambda_powertools.utilities.parameters.constants import DEFAULT_PROVIDERS from aws_lambda_powertools.utilities.parameters.exceptions import GetParameterError from aws_lambda_powertools.warnings import PowertoolsDeprecationWarning @@ -262,7 +261,7 @@ def get_app_config( My configuration's JSON value """ # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # One provider per environment/application pair, created on first use provider_key = f"appconfig:{environment}:{application}" diff --git a/aws_lambda_powertools/utilities/parameters/base.py b/aws_lambda_powertools/utilities/parameters/base.py index 0343fd05343..348d6342de6 100644 --- a/aws_lambda_powertools/utilities/parameters/base.py +++ b/aws_lambda_powertools/utilities/parameters/base.py @@ -8,14 +8,15 @@ import json import os +import warnings from abc import ABC, abstractmethod from collections.abc import Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, NamedTuple, cast, overload from aws_lambda_powertools.shared import constants, user_agent -from aws_lambda_powertools.shared.functions import resolve_max_age from aws_lambda_powertools.utilities.parameters.exceptions import GetParameterError, TransformParameterError +from aws_lambda_powertools.warnings import PowertoolsUserWarning if TYPE_CHECKING: from aws_lambda_powertools.utilities.parameters.types import TransformOptions @@ -102,7 +103,7 @@ def get( key = self._build_cache_key(name=name, transform=transform, sdk_options=sdk_options) # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) if not force_fetch and self.has_not_expired_in_cache(key): return self.fetch_from_cache(key) @@ -176,7 +177,7 @@ def get_multiple( key = self._build_cache_key(name=path, transform=transform, is_nested=True, sdk_options=sdk_options) # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) if not force_fetch and self.has_not_expired_in_cache(key): return self.fetch_from_cache(key) @@ -272,6 +273,41 @@ def _build_cache_key( return (name, transform, is_nested, json.dumps(sdk_options, sort_keys=True, default=str)) +def resolve_parameters_max_age(choice: int | None) -> int: + """ + Resolve the caching max age against the ``POWERTOOLS_PARAMETERS_MAX_AGE`` environment variable. + + An explicit ``choice`` always wins. Otherwise the environment variable is used, and an + empty or non-integer value is ignored with a :class:`PowertoolsUserWarning` rather than + crashing every lookup: a configuration typo must not break parameter and secret reads + (ADR 0006: warn now, harden in a future major). + + Parameters + ---------- + choice : int | None + Explicit max age passed by the caller, taking precedence over the environment. + + Returns + ------- + int + The resolved max age in seconds. + """ + if choice is not None: + return choice + + env = os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS) + try: + return int(env) + except ValueError: + warnings.warn( + f"Ignoring invalid {constants.PARAMETERS_MAX_AGE_ENV} value {env!r}; " + f"falling back to the default of {DEFAULT_MAX_AGE_SECS} seconds.", + category=PowertoolsUserWarning, + stacklevel=3, + ) + return int(DEFAULT_MAX_AGE_SECS) + + def get_transform_method(value: str, transform: TransformOptions = None) -> Callable[..., Any]: """ Determine the transform method diff --git a/aws_lambda_powertools/utilities/parameters/secrets.py b/aws_lambda_powertools/utilities/parameters/secrets.py index dc3f4b22eff..09412229548 100644 --- a/aws_lambda_powertools/utilities/parameters/secrets.py +++ b/aws_lambda_powertools/utilities/parameters/secrets.py @@ -6,17 +6,14 @@ import json import logging -import os import warnings from typing import TYPE_CHECKING, Any, Literal, overload import boto3 -from aws_lambda_powertools.shared import constants -from aws_lambda_powertools.shared.functions import resolve_max_age from aws_lambda_powertools.shared.json_encoder import Encoder -from aws_lambda_powertools.utilities.parameters.base import BaseProvider, transform_value -from aws_lambda_powertools.utilities.parameters.constants import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS +from aws_lambda_powertools.utilities.parameters.base import BaseProvider, resolve_parameters_max_age, transform_value +from aws_lambda_powertools.utilities.parameters.constants import DEFAULT_PROVIDERS from aws_lambda_powertools.utilities.parameters.exceptions import ( GetSecretError, SetSecretError, @@ -256,7 +253,7 @@ def get_multiple( # type: ignore[override] # ty: ignore[invalid-method-overrid ) # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) if not force_fetch and self.has_not_expired_in_cache(key): cached_values = self.fetch_from_cache(key) @@ -517,7 +514,7 @@ def get_secret( """ # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # Only create the provider if this function is called at least once if "secrets" not in DEFAULT_PROVIDERS: @@ -596,7 +593,7 @@ def get_secrets_by_name( raise GetSecretError("You must provide at least one secret name") # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # Only create the provider if this function is called at least once if "secrets" not in DEFAULT_PROVIDERS: diff --git a/aws_lambda_powertools/utilities/parameters/ssm.py b/aws_lambda_powertools/utilities/parameters/ssm.py index 9d8aba69ad9..35b56d4c089 100644 --- a/aws_lambda_powertools/utilities/parameters/ssm.py +++ b/aws_lambda_powertools/utilities/parameters/ssm.py @@ -13,16 +13,15 @@ from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.functions import ( - resolve_max_age, resolve_truthy_env_var_choice, slice_dictionary, ) from aws_lambda_powertools.utilities.parameters.base import ( BaseProvider, + resolve_parameters_max_age, transform_value, ) from aws_lambda_powertools.utilities.parameters.constants import ( - DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, SSM_PARAMETER_TIER, SSM_PARAMETER_TYPES, @@ -181,7 +180,7 @@ def get_multiple( # type: ignore[override] # ty: ignore[invalid-method-overrid """ # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # If decrypt is not set, resolve it from the environment variable, defaulting to False decrypt = resolve_truthy_env_var_choice( @@ -235,7 +234,7 @@ def get( # type: ignore[override] # ty: ignore[invalid-method-override] """ # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # If decrypt is not set, resolve it from the environment variable, defaulting to False decrypt = resolve_truthy_env_var_choice( @@ -490,7 +489,7 @@ def get_parameters_by_name( """ # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # If decrypt is not set, resolve it from the environment variable, defaulting to False decrypt = resolve_truthy_env_var_choice( @@ -881,7 +880,7 @@ def get_parameter( DEFAULT_PROVIDERS["ssm"] = SSMProvider() # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # If decrypt is not set, resolve it from the environment variable, defaulting to False decrypt = resolve_truthy_env_var_choice( @@ -1019,7 +1018,7 @@ def get_parameters( DEFAULT_PROVIDERS["ssm"] = SSMProvider() # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # If decrypt is not set, resolve it from the environment variable, defaulting to False decrypt = resolve_truthy_env_var_choice( @@ -1215,7 +1214,7 @@ def get_parameters_by_name( # see: https://github.com/aws-powertools/powertools-lambda-python/issues/1040#issuecomment-1299954613 # If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS - max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age) + max_age = resolve_parameters_max_age(choice=max_age) # If decrypt is not set, resolve it from the environment variable, defaulting to False decrypt = resolve_truthy_env_var_choice( diff --git a/tests/functional/parameters/_boto3/test_utilities_parameters.py b/tests/functional/parameters/_boto3/test_utilities_parameters.py index 1dcc66a8c3d..a7482896988 100644 --- a/tests/functional/parameters/_boto3/test_utilities_parameters.py +++ b/tests/functional/parameters/_boto3/test_utilities_parameters.py @@ -16,6 +16,7 @@ from botocore.config import Config from botocore.response import StreamingBody +from aws_lambda_powertools.shared import constants from aws_lambda_powertools.utilities import parameters from aws_lambda_powertools.utilities.parameters import AppConfigProvider, DynamoDBProvider, SecretsProvider, SSMProvider from aws_lambda_powertools.utilities.parameters.base import ( @@ -24,7 +25,7 @@ ExpirableValue, ) from aws_lambda_powertools.utilities.parameters.exceptions import GetSecretError -from aws_lambda_powertools.warnings import PowertoolsDeprecationWarning +from aws_lambda_powertools.warnings import PowertoolsDeprecationWarning, PowertoolsUserWarning @pytest.fixture(scope="function") @@ -572,6 +573,51 @@ def test_ssm_provider_get(mock_name, mock_value, mock_version, config): stubber.deactivate() +def test_ssm_provider_get_with_invalid_max_age_env_var(mock_name, mock_value, mock_version, config, monkeypatch): + """ + Test SSMProvider.get() when POWERTOOLS_PARAMETERS_MAX_AGE is empty or non-integer. + + A malformed environment variable must not break every parameters call: the + documented default applies and a PowertoolsUserWarning names the ignored value. + """ + + # Create a new provider + provider = parameters.SSMProvider(boto_config=config) + + # Stub the boto3 client; one SDK call per env var value tested + stubber = stub.Stubber(provider.client) + response = { + "Parameter": { + "Name": mock_name, + "Type": "String", + "Value": mock_value, + "Version": mock_version, + "Selector": f"{mock_name}:{mock_version}", + "SourceResult": "string", + "LastModifiedDate": datetime(2015, 1, 1), + "ARN": f"arn:aws:ssm:us-east-2:111122223333:parameter/{mock_name}", + }, + } + expected_params = {"Name": mock_name, "WithDecryption": False} + stubber.add_response("get_parameter", response, expected_params) + stubber.add_response("get_parameter", response, expected_params) + stubber.activate() + + try: + for env_value in ("", "abc"): + monkeypatch.setenv(constants.PARAMETERS_MAX_AGE_ENV, env_value) + # Each iteration resolves the env var afresh: the default applied + # after the first lookup would otherwise serve the second from cache. + provider.clear_cache() + with pytest.warns(PowertoolsUserWarning, match=constants.PARAMETERS_MAX_AGE_ENV): + value = provider.get(mock_name) + assert value == mock_value + + stubber.assert_no_pending_responses() + finally: + stubber.deactivate() + + def test_set_parameter(monkeypatch, mock_name, mock_value): """ Test set_parameter()