Skip to content

Commit 72b9546

Browse files
fix(idempotency): fix response hook invocation when function returns None (#5251)
Fix reponse hook invocation with None
1 parent 43da947 commit 72b9546

File tree

7 files changed

+180
-11
lines changed

7 files changed

+180
-11
lines changed

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

+8-11
Original file line numberDiff line numberDiff line change
@@ -239,18 +239,15 @@ def _handle_for_status(self, data_record: DataRecord) -> Any | None:
239239
f"Execution already in progress with idempotency key: "
240240
f"{self.persistence_store.event_key_jmespath}={data_record.idempotency_key}",
241241
)
242-
response_dict: dict | None = data_record.response_json_as_dict()
243-
if response_dict is not None:
244-
serialized_response = self.output_serializer.from_dict(response_dict)
245-
if self.config.response_hook is not None:
246-
logger.debug("Response hook configured, invoking function")
247-
return self.config.response_hook(
248-
serialized_response,
249-
data_record,
250-
)
251-
return serialized_response
252242

253-
return None
243+
response_dict = data_record.response_json_as_dict()
244+
serialized_response = self.output_serializer.from_dict(response_dict) if response_dict else None
245+
246+
if self.config.response_hook:
247+
logger.debug("Response hook configured, invoking function")
248+
return self.config.response_hook(serialized_response, data_record)
249+
250+
return serialized_response
254251

255252
def _get_function_response(self):
256253
try:

Diff for: tests/e2e/idempotency/handlers/response_hook.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import os
2+
3+
from aws_lambda_powertools.utilities.idempotency import (
4+
DynamoDBPersistenceLayer,
5+
IdempotencyConfig,
6+
idempotent,
7+
)
8+
from aws_lambda_powertools.utilities.idempotency.persistence.datarecord import (
9+
DataRecord,
10+
)
11+
12+
TABLE_NAME = os.getenv("IdempotencyTable", "")
13+
persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME)
14+
15+
16+
def my_response_hook(response: dict, idempotent_data: DataRecord) -> dict:
17+
# Return inserted Header data into the Idempotent Response
18+
response["x-response-hook"] = idempotent_data.idempotency_key
19+
20+
# Must return the response here
21+
return response
22+
23+
24+
config = IdempotencyConfig(response_hook=my_response_hook)
25+
26+
27+
@idempotent(config=config, persistence_store=persistence_layer)
28+
def lambda_handler(event, context):
29+
return {"message": "first_response"}

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

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def create_resources(self):
2020
table.grant_read_write_data(functions["FunctionThreadSafetyHandler"])
2121
table.grant_read_write_data(functions["OptionalIdempotencyKeyHandler"])
2222
table.grant_read_write_data(functions["PayloadTamperingValidationHandler"])
23+
table.grant_read_write_data(functions["ResponseHook"])
2324

2425
def _create_dynamodb_table(self) -> Table:
2526
table = dynamodb.Table(

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

+31
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ def payload_tampering_validation_fn_arn(infrastructure: dict) -> str:
4141
return infrastructure.get("PayloadTamperingValidationHandlerArn", "")
4242

4343

44+
@pytest.fixture
45+
def response_hook_handler_fn_arn(infrastructure: dict) -> str:
46+
return infrastructure.get("ResponseHookArn", "")
47+
48+
4449
@pytest.fixture
4550
def idempotency_table_name(infrastructure: dict) -> str:
4651
return infrastructure.get("DynamoDBTable", "")
@@ -219,3 +224,29 @@ def test_payload_tampering_validation(payload_tampering_validation_fn_arn: str):
219224
lambda_arn=payload_tampering_validation_fn_arn,
220225
payload=json.dumps(tampered_transaction),
221226
)
227+
228+
229+
@pytest.mark.xdist_group(name="idempotency")
230+
def test_response_hook_idempotency(response_hook_handler_fn_arn: str):
231+
# GIVEN
232+
payload = json.dumps({"message": "Powertools for AWS Lambda (Python)"})
233+
234+
# WHEN
235+
# first execution
236+
first_execution, _ = data_fetcher.get_lambda_response(
237+
lambda_arn=response_hook_handler_fn_arn,
238+
payload=payload,
239+
)
240+
first_execution_response = first_execution["Payload"].read().decode("utf-8")
241+
242+
# the second execution should include response hook
243+
second_execution, _ = data_fetcher.get_lambda_response(
244+
lambda_arn=response_hook_handler_fn_arn,
245+
payload=payload,
246+
)
247+
second_execution_response = second_execution["Payload"].read().decode("utf-8")
248+
249+
# THEN first execution should not trigger response hook
250+
# THEN seconde execution must trigger response hook
251+
assert "x-response-hook" not in first_execution_response
252+
assert "x-response-hook" in second_execution_response
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import os
2+
3+
from aws_lambda_powertools.utilities.idempotency import (
4+
DynamoDBPersistenceLayer,
5+
IdempotencyConfig,
6+
idempotent,
7+
)
8+
from aws_lambda_powertools.utilities.idempotency.persistence.datarecord import (
9+
DataRecord,
10+
)
11+
12+
TABLE_NAME = os.getenv("IdempotencyTable", "")
13+
persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME)
14+
15+
16+
def my_response_hook(response: dict, idempotent_data: DataRecord) -> dict:
17+
# Return inserted Header data into the Idempotent Response
18+
response["x-response-hook"] = idempotent_data.idempotency_key
19+
20+
# Must return the response here
21+
return response
22+
23+
24+
config = IdempotencyConfig(response_hook=my_response_hook)
25+
26+
27+
@idempotent(config=config, persistence_store=persistence_layer)
28+
def lambda_handler(event, context):
29+
return {"message": "first_response"}

Diff for: tests/e2e/idempotency_redis/test_idempotency_redis.py

+31
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ def optional_idempotency_key_fn_arn(infrastructure: dict) -> str:
3232
return infrastructure.get("OptionalIdempotencyKeyHandlerArn", "")
3333

3434

35+
@pytest.fixture
36+
def response_hook_handler_fn_arn(infrastructure: dict) -> str:
37+
return infrastructure.get("ResponseHookArn", "")
38+
39+
3540
@pytest.mark.xdist_group(name="idempotency-redis")
3641
def test_ttl_caching_expiration_idempotency(ttl_cache_expiration_handler_fn_arn: str):
3742
# GIVEN
@@ -181,3 +186,29 @@ def test_optional_idempotency_key(optional_idempotency_key_fn_arn: str):
181186
assert first_execution_response != second_execution_response
182187
assert first_execution_response != third_execution_response
183188
assert second_execution_response != third_execution_response
189+
190+
191+
@pytest.mark.xdist_group(name="idempotency")
192+
def test_response_hook_idempotency(response_hook_handler_fn_arn: str):
193+
# GIVEN
194+
payload = json.dumps({"message": "Powertools for AWS Lambda (Python)"})
195+
196+
# WHEN
197+
# first execution
198+
first_execution, _ = data_fetcher.get_lambda_response(
199+
lambda_arn=response_hook_handler_fn_arn,
200+
payload=payload,
201+
)
202+
first_execution_response = first_execution["Payload"].read().decode("utf-8")
203+
204+
# the second execution should include response hook
205+
second_execution, _ = data_fetcher.get_lambda_response(
206+
lambda_arn=response_hook_handler_fn_arn,
207+
payload=payload,
208+
)
209+
second_execution_response = second_execution["Payload"].read().decode("utf-8")
210+
211+
# THEN first execution should not trigger response hook
212+
# THEN seconde execution must trigger response hook
213+
assert "x-response-hook" not in first_execution_response
214+
assert "x-response-hook" in second_execution_response

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

+51
Original file line numberDiff line numberDiff line change
@@ -1963,3 +1963,54 @@ def lambda_handler(event, context):
19631963

19641964
stubber.assert_no_pending_responses()
19651965
stubber.deactivate()
1966+
1967+
1968+
@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True)
1969+
def test_idempotent_lambda_already_completed_response_hook_is_called_with_none(
1970+
idempotency_config: IdempotencyConfig,
1971+
persistence_store: DynamoDBPersistenceLayer,
1972+
lambda_apigw_event,
1973+
timestamp_future,
1974+
hashed_idempotency_key,
1975+
lambda_context,
1976+
):
1977+
"""
1978+
Test idempotent decorator where event with matching event key has already been successfully processed
1979+
"""
1980+
1981+
def idempotent_response_hook(response: Any, idempotent_data: DataRecord) -> Any:
1982+
"""Modify the response provided by adding a new key"""
1983+
new_response: dict = {}
1984+
new_response["idempotent_response"] = True
1985+
new_response["response"] = response
1986+
new_response["idempotent_expiration"] = idempotent_data.get_expiration_datetime()
1987+
1988+
return new_response
1989+
1990+
idempotency_config.response_hook = idempotent_response_hook
1991+
1992+
stubber = stub.Stubber(persistence_store.client)
1993+
ddb_response = {
1994+
"Item": {
1995+
"id": {"S": hashed_idempotency_key},
1996+
"expiration": {"N": timestamp_future},
1997+
"data": {"S": "null"},
1998+
"status": {"S": "COMPLETED"},
1999+
},
2000+
}
2001+
stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response)
2002+
stubber.activate()
2003+
2004+
@idempotent(config=idempotency_config, persistence_store=persistence_store)
2005+
def lambda_handler(event, context):
2006+
raise Exception
2007+
2008+
lambda_resp = lambda_handler(lambda_apigw_event, lambda_context)
2009+
2010+
# Then idempotent_response value will be added to the response
2011+
assert lambda_resp["idempotent_response"]
2012+
assert lambda_resp["response"] is None
2013+
assert lambda_resp["idempotent_expiration"] == datetime.datetime.fromtimestamp(int(timestamp_future))
2014+
2015+
stubber.assert_no_pending_responses()
2016+
stubber.deactivate()

0 commit comments

Comments
 (0)