Skip to content

Commit d652dc2

Browse files
fix(idempotency): treat missing idempotency key as non-idempotent transaction (no-op) when raise_on_no_idempotency_key is False (#2477)
* bug: fix missing idempotency key * fix return * chore: add e2e test for optional idempotency key * chore: fix e2e third payload * docs: clarify operations Signed-off-by: Heitor Lessa <[email protected]> * chore: clarify warning Signed-off-by: Heitor Lessa <[email protected]> * chore: strip extra space Signed-off-by: Heitor Lessa <[email protected]> * chore: ignore long warning line --------- Signed-off-by: Heitor Lessa <[email protected]> Co-authored-by: heitorlessa <[email protected]> Co-authored-by: Heitor Lessa <[email protected]>
1 parent c1ce6fa commit d652dc2

File tree

7 files changed

+99
-13
lines changed

7 files changed

+99
-13
lines changed

Diff for: aws_lambda_powertools/utilities/idempotency/base.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ def _process_idempotency(self):
111111
except IdempotencyItemAlreadyExistsError:
112112
# Now we know the item already exists, we can retrieve it
113113
record = self._get_idempotency_record()
114-
return self._handle_for_status(record)
114+
if record is not None:
115+
return self._handle_for_status(record)
115116
except Exception as exc:
116117
raise IdempotencyPersistenceLayerError(
117118
"Failed to save in progress record to idempotency store", exc
@@ -138,7 +139,7 @@ def _get_remaining_time_in_millis(self) -> Optional[int]:
138139

139140
return None
140141

141-
def _get_idempotency_record(self) -> DataRecord:
142+
def _get_idempotency_record(self) -> Optional[DataRecord]:
142143
"""
143144
Retrieve the idempotency record from the persistence layer.
144145

Diff for: aws_lambda_powertools/utilities/idempotency/persistence/base.py

+35-6
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def configure(self, config: IdempotencyConfig, function_name: Optional[str] = No
161161
self._cache = LRUDict(max_items=config.local_cache_max_items)
162162
self.hash_function = getattr(hashlib, config.hash_function)
163163

164-
def _get_hashed_idempotency_key(self, data: Dict[str, Any]) -> str:
164+
def _get_hashed_idempotency_key(self, data: Dict[str, Any]) -> Optional[str]:
165165
"""
166166
Extract idempotency key and return a hashed representation
167167
@@ -182,7 +182,12 @@ def _get_hashed_idempotency_key(self, data: Dict[str, Any]) -> str:
182182
if self.is_missing_idempotency_key(data=data):
183183
if self.raise_on_no_idempotency_key:
184184
raise IdempotencyKeyError("No data found to create a hashed idempotency_key")
185-
warnings.warn(f"No value found for idempotency_key. jmespath: {self.event_key_jmespath}", stacklevel=2)
185+
186+
warnings.warn(
187+
f"No idempotency key value found. Skipping persistence layer and validation operations. jmespath: {self.event_key_jmespath}", # noqa: E501
188+
stacklevel=2,
189+
)
190+
return None
186191

187192
generated_hash = self._generate_hash(data=data)
188193
return f"{self.function_name}#{generated_hash}"
@@ -315,10 +320,16 @@ def save_success(self, data: Dict[str, Any], result: dict) -> None:
315320
result: dict
316321
The response from function
317322
"""
323+
idempotency_key = self._get_hashed_idempotency_key(data=data)
324+
if idempotency_key is None:
325+
# If the idempotency key is None, no data will be saved in the Persistence Layer.
326+
# See: https://github.com/awslabs/aws-lambda-powertools-python/issues/2465
327+
return None
328+
318329
response_data = json.dumps(result, cls=Encoder, sort_keys=True)
319330

320331
data_record = DataRecord(
321-
idempotency_key=self._get_hashed_idempotency_key(data=data),
332+
idempotency_key=idempotency_key,
322333
status=STATUS_CONSTANTS["COMPLETED"],
323334
expiry_timestamp=self._get_expiry_timestamp(),
324335
response_data=response_data,
@@ -343,8 +354,15 @@ def save_inprogress(self, data: Dict[str, Any], remaining_time_in_millis: Option
343354
remaining_time_in_millis: Optional[int]
344355
If expiry of in-progress invocations is enabled, this will contain the remaining time available in millis
345356
"""
357+
358+
idempotency_key = self._get_hashed_idempotency_key(data=data)
359+
if idempotency_key is None:
360+
# If the idempotency key is None, no data will be saved in the Persistence Layer.
361+
# See: https://github.com/awslabs/aws-lambda-powertools-python/issues/2465
362+
return None
363+
346364
data_record = DataRecord(
347-
idempotency_key=self._get_hashed_idempotency_key(data=data),
365+
idempotency_key=idempotency_key,
348366
status=STATUS_CONSTANTS["INPROGRESS"],
349367
expiry_timestamp=self._get_expiry_timestamp(),
350368
payload_hash=self._get_hashed_payload(data=data),
@@ -381,7 +399,14 @@ def delete_record(self, data: Dict[str, Any], exception: Exception):
381399
exception
382400
The exception raised by the function
383401
"""
384-
data_record = DataRecord(idempotency_key=self._get_hashed_idempotency_key(data=data))
402+
403+
idempotency_key = self._get_hashed_idempotency_key(data=data)
404+
if idempotency_key is None:
405+
# If the idempotency key is None, no data will be saved in the Persistence Layer.
406+
# See: https://github.com/awslabs/aws-lambda-powertools-python/issues/2465
407+
return None
408+
409+
data_record = DataRecord(idempotency_key=idempotency_key)
385410

386411
logger.debug(
387412
f"Function raised an exception ({type(exception).__name__}). Clearing in progress record in persistence "
@@ -391,7 +416,7 @@ def delete_record(self, data: Dict[str, Any], exception: Exception):
391416

392417
self._delete_from_cache(idempotency_key=data_record.idempotency_key)
393418

394-
def get_record(self, data: Dict[str, Any]) -> DataRecord:
419+
def get_record(self, data: Dict[str, Any]) -> Optional[DataRecord]:
395420
"""
396421
Retrieve idempotency key for data provided, fetch from persistence store, and convert to DataRecord.
397422
@@ -414,6 +439,10 @@ def get_record(self, data: Dict[str, Any]) -> DataRecord:
414439
"""
415440

416441
idempotency_key = self._get_hashed_idempotency_key(data=data)
442+
if idempotency_key is None:
443+
# If the idempotency key is None, no data will be saved in the Persistence Layer.
444+
# See: https://github.com/awslabs/aws-lambda-powertools-python/issues/2465
445+
return None
417446

418447
cached_record = self._retrieve_from_cache(idempotency_key=idempotency_key)
419448
if cached_record:

Diff for: docs/utilities/idempotency.md

+5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ title: Idempotency
33
description: Utility
44
---
55

6+
<!-- markdownlint-disable MD051 -->
7+
68
The idempotency utility provides a simple solution to convert your Lambda functions into idempotent operations which
79
are safe to retry.
810

@@ -853,6 +855,9 @@ If you want to enforce that an idempotency key is required, you can set **`raise
853855

854856
This means that we will raise **`IdempotencyKeyError`** if the evaluation of **`event_key_jmespath`** is `None`.
855857

858+
???+ warning
859+
To prevent errors, transactions will not be treated as idempotent if **`raise_on_no_idempotency_key`** is set to `False` and the evaluation of **`event_key_jmespath`** is `None`. Therefore, no data will be fetched, stored, or deleted in the idempotency storage layer.
860+
856861
=== "app.py"
857862

858863
```python hl_lines="9-10 13"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import os
2+
import uuid
3+
4+
from aws_lambda_powertools.utilities.idempotency import (
5+
DynamoDBPersistenceLayer,
6+
IdempotencyConfig,
7+
idempotent,
8+
)
9+
10+
TABLE_NAME = os.getenv("IdempotencyTable", "")
11+
persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME)
12+
config = IdempotencyConfig(event_key_jmespath='headers."X-Idempotency-Key"', use_local_cache=True)
13+
14+
15+
@idempotent(config=config, persistence_store=persistence_layer)
16+
def lambda_handler(event, context):
17+
return {"request": str(uuid.uuid4())}

Diff for: tests/e2e/idempotency/infrastructure.py

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def create_resources(self):
1616
table.grant_read_write_data(functions["TtlCacheTimeoutHandler"])
1717
table.grant_read_write_data(functions["ParallelExecutionHandler"])
1818
table.grant_read_write_data(functions["FunctionThreadSafetyHandler"])
19+
table.grant_read_write_data(functions["OptionalIdempotencyKeyHandler"])
1920

2021
def _create_dynamodb_table(self) -> Table:
2122
table = dynamodb.Table(

Diff for: tests/e2e/idempotency/test_idempotency_dynamodb.py

+35
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ def function_thread_safety_handler_fn_arn(infrastructure: dict) -> str:
2727
return infrastructure.get("FunctionThreadSafetyHandlerArn", "")
2828

2929

30+
@pytest.fixture
31+
def optional_idempotency_key_fn_arn(infrastructure: dict) -> str:
32+
return infrastructure.get("OptionalIdempotencyKeyHandlerArn", "")
33+
34+
3035
@pytest.fixture
3136
def idempotency_table_name(infrastructure: dict) -> str:
3237
return infrastructure.get("DynamoDBTable", "")
@@ -132,3 +137,33 @@ def test_idempotent_function_thread_safety(function_thread_safety_handler_fn_arn
132137

133138
# we use set() here because we want to compare the elements regardless of their order in the array
134139
assert set(first_execution_response) == set(second_execution_response)
140+
141+
142+
@pytest.mark.xdist_group(name="idempotency")
143+
def test_optional_idempotency_key(optional_idempotency_key_fn_arn: str):
144+
# GIVEN two payloads where only one has the expected idempotency key
145+
payload = json.dumps({"headers": {"X-Idempotency-Key": "here"}})
146+
payload_without = json.dumps({"headers": {}})
147+
148+
# WHEN
149+
# we make one request with an idempotency key
150+
first_execution, _ = data_fetcher.get_lambda_response(lambda_arn=optional_idempotency_key_fn_arn, payload=payload)
151+
first_execution_response = first_execution["Payload"].read().decode("utf-8")
152+
153+
# and two others without the idempotency key
154+
second_execution, _ = data_fetcher.get_lambda_response(
155+
lambda_arn=optional_idempotency_key_fn_arn, payload=payload_without
156+
)
157+
second_execution_response = second_execution["Payload"].read().decode("utf-8")
158+
159+
third_execution, _ = data_fetcher.get_lambda_response(
160+
lambda_arn=optional_idempotency_key_fn_arn, payload=payload_without
161+
)
162+
third_execution_response = third_execution["Payload"].read().decode("utf-8")
163+
164+
# THEN
165+
# we should treat 2nd and 3rd requests with NULL idempotency key as non-idempotent transactions
166+
# that is, no cache, no calls to persistent store, etc.
167+
assert first_execution_response != second_execution_response
168+
assert first_execution_response != third_execution_response
169+
assert second_execution_response != third_execution_response

Diff for: tests/functional/idempotency/test_idempotency.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import datetime
33
import sys
44
import warnings
5-
from hashlib import md5
65
from unittest.mock import MagicMock
76

87
import jmespath
@@ -995,8 +994,7 @@ def test_default_no_raise_on_missing_idempotency_key(
995994
hashed_key = persistence_store._get_hashed_idempotency_key({})
996995

997996
# THEN return the hash of None
998-
expected_value = f"test-func.{function_name}#" + md5(json_serialize(None).encode()).hexdigest()
999-
assert expected_value == hashed_key
997+
assert hashed_key is None
1000998

1001999

10021000
@pytest.mark.parametrize(
@@ -1093,7 +1091,7 @@ def lambda_handler(event, context):
10931091
# WHEN handling the idempotent call
10941092
# AND save_inprogress raises a ClientError
10951093
with pytest.raises(IdempotencyPersistenceLayerError) as e:
1096-
lambda_handler({}, lambda_context)
1094+
lambda_handler({"data": "some"}, lambda_context)
10971095

10981096
# THEN idempotent should raise an IdempotencyPersistenceLayerError
10991097
# AND append downstream exception details
@@ -1363,7 +1361,7 @@ def two(data):
13631361

13641362
assert one(data=mock_event) == "one"
13651363
assert two(data=mock_event) == "two"
1366-
assert len(persistence_store.client.method_calls) == 4
1364+
assert len(persistence_store.client.method_calls) == 0
13671365

13681366

13691367
def test_invalid_dynamodb_persistence_layer():

0 commit comments

Comments
 (0)