Repository: aws-powertools/powertools-lambda-python

Issue:
Incomplete input for hash function for @idempotent_function
When annotating functions via @idempotent_function, the wrapped function's name will not go into the hash computation; instead, only the payload and an environment-specific (but not function-specific) function name is assumed. 
This causes idempotency to break in certain cases. 


## Expected Behavior
When two distinct functions which accept the very same payload as parameters are wrapped using @idempotent_function, invocation of one function should not affect invocation of the other function. 

## Current Behavior
Only the payload is considered. 
The culprit appears to be 
```python
generated_hash = self._generate_hash(data=data)
function_name = os.getenv(constants.LAMBDA_FUNCTION_NAME_ENV, "test-func")
```
(lines 180-181) in `persistence/base.py`. One can clearly see that only the environment variable or "test-func" is assumed - but that is - of course - not specific to the actual function being wrapped.
So in short, the library currently only really distinguishes by payload and not be function name, hence causing the aforementioned behaviour. 

## Possible Solution
Pass on the wrapped function's name and use it for the return value of `_get_hashed_idempotency_key`. 

## Steps to Reproduce (for bugs)
```python
def test():
    myIdempotentFunc(product = "bananas")
    anotherIdempotentFunc(product="bananas")
    return {}

@idempotent_function(data_keyword_argument="product", persistence_store=dynamodb)
def myIdempotentFunc(product):
    print("I got invoked with {product}".format(product = product))
    return None

@idempotent_function(data_keyword_argument="product", persistence_store=dynamodb)
def anotherIdempotentFunc(product):
    print("I will never get invoked; someone stole my payload :(".format(product = product))
    return None
```
This only prints myIdempotentFunc's output, so anotherIdempotentFunc never gets invoked. 


## Environment

Using "aws-lambda-powertools==1.22.0" on Python 3.9.7


Thank you in advance! 


Discussion:
Thanks for opening your first issue here! We'll come back to you as soon as we can.

hey @dispyfree thank you so much for spotting this - it's definitely an edge case we missed :/. `function_name` in that case is to be added in the DataRecord, not the actual method name being wrapped -- for that we need to do what you suggested.

We're fixing some papercuts elsewhere and this will be prioritized right after - More than happy to help review/guide the fix if you have some spare cycles too!

Thanks again

Resolving patch:
diff --git a/aws_lambda_powertools/utilities/idempotency/base.py b/aws_lambda_powertools/utilities/idempotency/base.py
--- a/aws_lambda_powertools/utilities/idempotency/base.py
+++ b/aws_lambda_powertools/utilities/idempotency/base.py
@@ -56,7 +56,7 @@ def __init__(
         self.fn_args = function_args
         self.fn_kwargs = function_kwargs
 
-        persistence_store.configure(config)
+        persistence_store.configure(config, self.function.__name__)
         self.persistence_store = persistence_store
 
     def handle(self) -> Any:
diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py
--- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py
+++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py
@@ -112,6 +112,7 @@ class BasePersistenceLayer(ABC):
 
     def __init__(self):
         """Initialize the defaults"""
+        self.function_name = ""
         self.configured = False
         self.event_key_jmespath: Optional[str] = None
         self.event_key_compiled_jmespath = None
@@ -124,7 +125,7 @@ def __init__(self):
         self._cache: Optional[LRUDict] = None
         self.hash_function = None
 
-    def configure(self, config: IdempotencyConfig) -> None:
+    def configure(self, config: IdempotencyConfig, function_name: Optional[str] = None) -> None:
         """
         Initialize the base persistence layer from the configuration settings
 
@@ -132,7 +133,11 @@ def configure(self, config: IdempotencyConfig) -> None:
         ----------
         config: IdempotencyConfig
             Idempotency configuration settings
+        function_name: str, Optional
+            The name of the function being decorated
         """
+        self.function_name = f"{os.getenv(constants.LAMBDA_FUNCTION_NAME_ENV, 'test-func')}.{function_name or ''}"
+
         if self.configured:
             # Prevent being reconfigured multiple times
             return
@@ -178,8 +183,7 @@ def _get_hashed_idempotency_key(self, data: Dict[str, Any]) -> str:
             warnings.warn(f"No value found for idempotency_key. jmespath: {self.event_key_jmespath}")
 
         generated_hash = self._generate_hash(data=data)
-        function_name = os.getenv(constants.LAMBDA_FUNCTION_NAME_ENV, "test-func")
-        return f"{function_name}#{generated_hash}"
+        return f"{self.function_name}#{generated_hash}"
 
     @staticmethod
     def is_missing_idempotency_key(data) -> bool:


Test patch:
diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py
--- a/tests/functional/idempotency/conftest.py
+++ b/tests/functional/idempotency/conftest.py
@@ -150,7 +150,7 @@ def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_vali
 def hashed_idempotency_key(lambda_apigw_event, default_jmespath, lambda_context):
     compiled_jmespath = jmespath.compile(default_jmespath)
     data = compiled_jmespath.search(lambda_apigw_event)
-    return "test-func#" + hashlib.md5(serialize(data).encode()).hexdigest()
+    return "test-func.lambda_handler#" + hashlib.md5(serialize(data).encode()).hexdigest()
 
 
 @pytest.fixture
@@ -158,7 +158,7 @@ def hashed_idempotency_key_with_envelope(lambda_apigw_event):
     event = extract_data_from_envelope(
         data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, jmespath_options={}
     )
-    return "test-func#" + hashlib.md5(serialize(event).encode()).hexdigest()
+    return "test-func.lambda_handler#" + hashlib.md5(serialize(event).encode()).hexdigest()
 
 
 @pytest.fixture
diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py
--- a/tests/functional/idempotency/test_idempotency.py
+++ b/tests/functional/idempotency/test_idempotency.py
@@ -735,7 +735,8 @@ def test_default_no_raise_on_missing_idempotency_key(
     idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer, lambda_context
 ):
     # GIVEN a persistence_store with use_local_cache = False and event_key_jmespath = "body"
-    persistence_store.configure(idempotency_config)
+    function_name = "foo"
+    persistence_store.configure(idempotency_config, function_name)
     assert persistence_store.use_local_cache is False
     assert "body" in persistence_store.event_key_jmespath
 
@@ -743,7 +744,7 @@ def test_default_no_raise_on_missing_idempotency_key(
     hashed_key = persistence_store._get_hashed_idempotency_key({})
 
     # THEN return the hash of None
-    expected_value = "test-func#" + md5(serialize(None).encode()).hexdigest()
+    expected_value = f"test-func.{function_name}#" + md5(serialize(None).encode()).hexdigest()
     assert expected_value == hashed_key
 
 
@@ -781,7 +782,7 @@ def test_jmespath_with_powertools_json(
     idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer, lambda_context
 ):
     # GIVEN an event_key_jmespath with powertools_json custom function
-    persistence_store.configure(idempotency_config)
+    persistence_store.configure(idempotency_config, "handler")
     sub_attr_value = "cognito_user"
     static_pk_value = "some_key"
     expected_value = [sub_attr_value, static_pk_value]
@@ -794,14 +795,14 @@ def test_jmespath_with_powertools_json(
     result = persistence_store._get_hashed_idempotency_key(api_gateway_proxy_event)
 
     # THEN the hashed idempotency key should match the extracted values generated hash
-    assert result == "test-func#" + persistence_store._generate_hash(expected_value)
+    assert result == "test-func.handler#" + persistence_store._generate_hash(expected_value)
 
 
 @pytest.mark.parametrize("config_with_jmespath_options", ["powertools_json(data).payload"], indirect=True)
 def test_custom_jmespath_function_overrides_builtin_functions(
     config_with_jmespath_options: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer, lambda_context
 ):
-    # GIVEN an persistence store with a custom jmespath_options
+    # GIVEN a persistence store with a custom jmespath_options
     # AND use a builtin powertools custom function
     persistence_store.configure(config_with_jmespath_options)
 
@@ -871,7 +872,9 @@ def _delete_record(self, data_record: DataRecord) -> None:
 def test_idempotent_lambda_event_source(lambda_context):
     # Scenario to validate that we can use the event_source decorator before or after the idempotent decorator
     mock_event = load_event("apiGatewayProxyV2Event.json")
-    persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
+    persistence_layer = MockPersistenceLayer(
+        "test-func.lambda_handler#" + hashlib.md5(serialize(mock_event).encode()).hexdigest()
+    )
     expected_result = {"message": "Foo"}
 
     # GIVEN an event_source decorator
@@ -891,7 +894,9 @@ def lambda_handler(event, _):
 def test_idempotent_function():
     # Scenario to validate we can use idempotent_function with any function
     mock_event = {"data": "value"}
-    persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
+    persistence_layer = MockPersistenceLayer(
+        "test-func.record_handler#" + hashlib.md5(serialize(mock_event).encode()).hexdigest()
+    )
     expected_result = {"message": "Foo"}
 
     @idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record")
@@ -908,7 +913,9 @@ def test_idempotent_function_arbitrary_args_kwargs():
     # Scenario to validate we can use idempotent_function with a function
     # with an arbitrary number of args and kwargs
     mock_event = {"data": "value"}
-    persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
+    persistence_layer = MockPersistenceLayer(
+        "test-func.record_handler#" + hashlib.md5(serialize(mock_event).encode()).hexdigest()
+    )
     expected_result = {"message": "Foo"}
 
     @idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record")
@@ -923,7 +930,9 @@ def record_handler(arg_one, arg_two, record, is_record):
 
 def test_idempotent_function_invalid_data_kwarg():
     mock_event = {"data": "value"}
-    persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
+    persistence_layer = MockPersistenceLayer(
+        "test-func.record_handler#" + hashlib.md5(serialize(mock_event).encode()).hexdigest()
+    )
     expected_result = {"message": "Foo"}
     keyword_argument = "payload"
 
@@ -940,7 +949,9 @@ def record_handler(record):
 
 def test_idempotent_function_arg_instead_of_kwarg():
     mock_event = {"data": "value"}
-    persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
+    persistence_layer = MockPersistenceLayer(
+        "test-func.record_handler#" + hashlib.md5(serialize(mock_event).encode()).hexdigest()
+    )
     expected_result = {"message": "Foo"}
     keyword_argument = "record"
 
@@ -958,13 +969,19 @@ def record_handler(record):
 def test_idempotent_function_and_lambda_handler(lambda_context):
     # Scenario to validate we can use both idempotent_function and idempotent decorators
     mock_event = {"data": "value"}
-    persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(serialize(mock_event).encode()).hexdigest())
+    persistence_layer = MockPersistenceLayer(
+        "test-func.record_handler#" + hashlib.md5(serialize(mock_event).encode()).hexdigest()
+    )
     expected_result = {"message": "Foo"}
 
     @idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record")
     def record_handler(record):
         return expected_result
 
+    persistence_layer = MockPersistenceLayer(
+        "test-func.lambda_handler#" + hashlib.md5(serialize(mock_event).encode()).hexdigest()
+    )
+
     @idempotent(persistence_store=persistence_layer)
     def lambda_handler(event, _):
         return expected_result
@@ -986,7 +1003,9 @@ def test_idempotent_data_sorting():
     data_two = {"more_data": "more data 1", "data": "test message 1"}
 
     # Assertion will happen in MockPersistenceLayer
-    persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(data_one).encode()).hexdigest())
+    persistence_layer = MockPersistenceLayer(
+        "test-func.dummy#" + hashlib.md5(json.dumps(data_one).encode()).hexdigest()
+    )
 
     # GIVEN
     @idempotent_function(data_keyword_argument="payload", persistence_store=persistence_layer)
@@ -1017,3 +1036,24 @@ def dummy_handler(event, context):
     dummy_handler(mock_event, lambda_context)
 
     assert len(persistence_store.table.method_calls) == 0
+
+
+@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True)
+def test_idempotent_function_duplicates(
+    idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer
+):
+    # Scenario to validate the both methods are called
+    mock_event = {"data": "value"}
+    persistence_store.table = MagicMock()
+
+    @idempotent_function(data_keyword_argument="data", persistence_store=persistence_store, config=idempotency_config)
+    def one(data):
+        return "one"
+
+    @idempotent_function(data_keyword_argument="data", persistence_store=persistence_store, config=idempotency_config)
+    def two(data):
+        return "two"
+
+    assert one(data=mock_event) == "one"
+    assert two(data=mock_event) == "two"
+    assert len(persistence_store.table.method_calls) == 4