From d503fb0b3f5844fcee17b709f35b1b26d2042ec6 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Wed, 16 Dec 2020 14:01:14 +0100 Subject: [PATCH 01/44] feat: initial commit for idempotency utility --- .../utilities/idempotency/__init__.py | 8 + .../utilities/idempotency/exceptions.py | 27 + .../utilities/idempotency/idempotency.py | 97 ++++ .../utilities/idempotency/persistence.py | 505 ++++++++++++++++++ .../functional/test_utilities_idempotency.py | 336 ++++++++++++ 5 files changed, 973 insertions(+) create mode 100644 aws_lambda_powertools/utilities/idempotency/__init__.py create mode 100644 aws_lambda_powertools/utilities/idempotency/exceptions.py create mode 100644 aws_lambda_powertools/utilities/idempotency/idempotency.py create mode 100644 aws_lambda_powertools/utilities/idempotency/persistence.py create mode 100644 tests/functional/test_utilities_idempotency.py diff --git a/aws_lambda_powertools/utilities/idempotency/__init__.py b/aws_lambda_powertools/utilities/idempotency/__init__.py new file mode 100644 index 00000000000..e361b401340 --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/__init__.py @@ -0,0 +1,8 @@ +""" +Utility for adding idempotency to lambda functions +""" + +from .idempotency import idempotent +from .persistence import BasePersistenceLayer, DynamoDBPersistenceLayer + +__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent") diff --git a/aws_lambda_powertools/utilities/idempotency/exceptions.py b/aws_lambda_powertools/utilities/idempotency/exceptions.py new file mode 100644 index 00000000000..1e4a88bb8e0 --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/exceptions.py @@ -0,0 +1,27 @@ +""" +Idempotency errors +""" + + +class ItemAlreadyExistsError(Exception): + """ + Item attempting to be inserted into persistence store already exists + """ + + +class ItemNotFoundError(Exception): + """ + Item does not exist in persistence store + """ + + +class AlreadyInProgressError(Exception): + """ + Execution with idempotency key is already in progress + """ + + +class InvalidStatusError(Exception): + """ + An invalid status was provided + """ diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py new file mode 100644 index 00000000000..31ca8e57405 --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -0,0 +1,97 @@ +""" +Primary interface for idempotent Lambda functions utility +""" + +from typing import Any, Callable, Dict + +from aws_lambda_powertools.middleware_factory import lambda_handler_decorator + +from ..typing import LambdaContext +from .exceptions import AlreadyInProgressError, ItemNotFoundError +from .persistence import STATUS_CONSTANTS, BasePersistenceLayer + + +def default_error_callback(): + raise + + +@lambda_handler_decorator +def idempotent( + handler: Callable[[Any, LambdaContext], Any], + event: Dict[str, Any], + context: LambdaContext, + persistence: BasePersistenceLayer, +) -> Any: + """ + Middleware to handle idempotency + + Parameters + ---------- + handler: Callable + Lambda's handler + event: Dict + Lambda's Event + context: Dict + Lambda's Context + persistence: BasePersistenceLayer + Instance of BasePersistenceLayer to store data + + Examples + -------- + **Processes Lambda's event in an idempotent manner** + >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer + >>> + >>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store") + >>> + >>> @idempotent(persistence=persistence_store) + >>> def handler(event, context): + >>> return {"StatusCode": 200} + """ + + persistence_instance = persistence + try: + event_record = persistence_instance.get_record(event) + except ItemNotFoundError: + persistence_instance.save_inprogress(event=event) + return _call_lambda(handler=handler, persistence_instance=persistence_instance, event=event, context=context) + + if event_record.status == STATUS_CONSTANTS["EXPIRED"]: + return _call_lambda(handler=handler, persistence_instance=persistence_instance, event=event, context=context) + + if event_record.status == STATUS_CONSTANTS["INPROGRESS"]: + raise AlreadyInProgressError( + f"Execution already in progress with idempotency key: " + f"{persistence_instance.event_key}={event_record.idempotency_key}" + ) + + if event_record.status == STATUS_CONSTANTS["COMPLETED"]: + return event_record.response_json_as_dict() + + if event_record.status == STATUS_CONSTANTS["ERROR"]: + event_record.raise_stored_exception() + + +def _call_lambda( + handler: Callable, persistence_instance: BasePersistenceLayer, event: Dict[str, Any], context: LambdaContext +) -> Any: + """ + + Parameters + ---------- + handler: Callable + Lambda handler + persistence_instance: BasePersistenceLayer + Instance of persistence layer + event + Lambda event + context + Lambda context + """ + try: + handler_response = handler(event, context) + except Exception as ex: + persistence_instance.save_error(event=event, exception=ex) + raise + else: + persistence_instance.save_success(event=event, result=handler_response) + return handler_response diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py new file mode 100644 index 00000000000..660540908bf --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -0,0 +1,505 @@ +""" +Persistence layers supporting idempotency +""" + +import datetime +import hashlib +import json +import pickle +from abc import ABC, abstractmethod +from base64 import b64decode, b64encode +from typing import Any, Dict, Iterable, Optional, Union + +import boto3 +import jmespath +from botocore.config import Config + +from .exceptions import InvalidStatusError, ItemAlreadyExistsError, ItemNotFoundError + +STATUS_CONSTANTS = { + "NOTEXISTING": "DOESNOTEXIST", + "INPROGRESS": "INPROGRESS", + "COMPLETED": "COMPLETED", + "EXPIRED": "EXPIRED", + "ERROR": "ERROR", +} + + +class DataRecord: + """ + Data Class for idempotency records. + """ + + def __init__( + self, idempotency_key, status: str = None, expiry_timestamp: int = None, response_data: str = None + ) -> None: + """ + + Parameters + ---------- + idempotency_key: str + hashed representation of the idempotent data + status: str, optional + status of the idempotent record + expiry_timestamp: int, optional + time before the record should expire, in milliseconds + response_data: str, optional + response data from previous executions using the record + """ + self.idempotency_key = idempotency_key + self.expiry_timestamp = expiry_timestamp + self._status = status + self.response_data = response_data + + @property + def is_expired(self) -> bool: + """ + Check if data record is expired + + Returns + ------- + bool + Whether the record is currently expired or not + """ + if self.expiry_timestamp: + if int(datetime.datetime.now().timestamp()) > self.expiry_timestamp: + return True + return False + + @property + def status(self) -> str: + """ + Get status of data record + + Returns + ------- + str + """ + if self.is_expired: + return STATUS_CONSTANTS["EXPIRED"] + + if self._status in STATUS_CONSTANTS.values(): + return self._status + else: + raise InvalidStatusError(self._status) + + def response_json_as_dict(self) -> dict: + """ + Get response data deserialized to python dict + + Returns + ------- + dict + previous response data deserialized + """ + return json.loads(self.response_data) + + def raise_stored_exception(self): + """ + Raises + ------ + Exception + Decoded and unpickled Exception from persistent store + """ + + decoded_exception = pickle.loads(b64decode(self.response_data.encode())) + raise decoded_exception + + +class BasePersistenceLayer(ABC): + """ + Abstract Base Class for Idempotency persistence layer. + """ + + def __init__( + self, event_key: str, expires_after: int = 3600, non_retryable_errors: Optional[Iterable[Exception]] = None + ) -> None: + """ + Initialize the base persistence layer + + Parameters + ---------- + event_key: str + A jmespath expression to extract the idempotency key from the event record + expires_after: int + The number of milliseconds to wait before a record is expired + non_retryable_errors: Iterable, optional + An interable of exception classes which should not be retried after being raised, by default [] + """ + self.event_key = event_key + self.event_key_jmespath = jmespath.compile(event_key) + self.expires_after = expires_after + self.non_retryable_errors = non_retryable_errors or [] + + def get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: + """ + Extract data from lambda event using event key jmespath, and return a hashed representation + + Parameters + ---------- + lambda_event: Dict[str, Any] + Lambda event + + Returns + ------- + str + md5 hash of the data extracted by the jmespath expression + + """ + data = self.event_key_jmespath.search(lambda_event) + hashed_data = hashlib.md5(json.dumps(data).encode()) + return hashed_data.hexdigest() + + def _get_expiry_timestamp(self) -> int: + """ + + Returns + ------- + int + unix timestamp of expiry date for idempotency record + + """ + now = datetime.datetime.now() + period = datetime.timedelta(seconds=self.expires_after) + return int((now + period).timestamp()) + + def save_success(self, event: Dict[str, Any], result: dict) -> None: + """ + Save record of function's execution completing succesfully + + Parameters + ---------- + event: Dict[str, Any] + Lambda event + result: dict + The response from lambda handler + """ + response_data = json.dumps(result) + + data_record = DataRecord( + idempotency_key=self.get_hashed_idempotency_key(event), + status=STATUS_CONSTANTS["COMPLETED"], + expiry_timestamp=self._get_expiry_timestamp(), + response_data=response_data, + ) + self._update_record(data_record=data_record) + + def save_inprogress(self, event: Dict[str, Any]) -> None: + """ + Save record of function's execution being in progress + + Parameters + ---------- + event: Dict[str, Any] + Lambda event + """ + data_record = DataRecord( + idempotency_key=self.get_hashed_idempotency_key(event), + status=STATUS_CONSTANTS["INPROGRESS"], + expiry_timestamp=self._get_expiry_timestamp(), + ) + self._put_record(data_record) + + def save_error(self, event: Dict[str, Any], exception: Exception): + """ + Save record of lambda handler raising an exception + + Parameters + ---------- + event: Dict[str, Any] + Lambda event + exception + The exception raised by the lambda handler + """ + data_record = DataRecord( + idempotency_key=self.get_hashed_idempotency_key(event), + status=STATUS_CONSTANTS["ERROR"], + expiry_timestamp=self._get_expiry_timestamp(), + ) + + # Only write a record of the error to the persistent store if it is not a subclass of any of the non retryable + # error classes + if not self._is_retryable(exception): + data_record.response_data = b64encode(pickle.dumps(exception)).decode() + self._update_record(data_record) + else: + # If the error is retryable, delete the in progress record from the store + self._delete_record(data_record) + + def _is_retryable(self, exception: Exception): + """ + Check whether the exception is retryable or not + + Parameters + ---------- + exception: Exception + exception instance raised by lambda handler + + Returns + ------- + bool + Whether exception should be retried in the future or not + """ + return not any((issubclass(exception.__class__, nr) for nr in self.non_retryable_errors)) + + def get_record(self, lambda_event) -> DataRecord: + """ + Calculate idempotency key for lambda_event, then retrieve item from persistence store using idempotency key + and return it as a DataRecord instance.and return it as a DataRecord instance. + + Parameters + ---------- + lambda_event: Dict[str, Any] + + Returns + ------- + DataRecord + DataRecord representation of existing record found in persistence store + + Raises + ------ + ItemNotFound + Exception raised if no record exists in persistence store with the idempotency key + """ + + idempotency_key = self.get_hashed_idempotency_key(lambda_event) + + return self._get_record(idempotency_key) + + @abstractmethod + def _get_record(self, idempotency_key) -> DataRecord: + """ + Retrieve item from persistence store using idempotency key and return it as a DataRecord instance. + + Parameters + ---------- + idempotency_key + + Returns + ------- + DataRecord + DataRecord representation of existing record found in persistence store + + Raises + ------ + ItemNotFound + Exception raised if no record exists in persistence store with the idempotency key + """ + raise NotImplementedError + + @abstractmethod + def _put_record(self, data_record: DataRecord) -> None: + """ + Add a DataRecord to persistence store if it does not already exist with that key. Raise ItemAlreadyExists + if an entry already exists. + + Parameters + ---------- + data_record: DataRecord + DataRecord instance + """ + + raise NotImplementedError + + @abstractmethod + def _update_record(self, data_record: DataRecord) -> None: + """ + Update item in persistence store + + Parameters + ---------- + data_record: DataRecord + DataRecord instance + """ + + raise NotImplementedError + + @abstractmethod + def _delete_record(self, data_record: DataRecord) -> None: + """ + Remove item from persistence store + Parameters + ---------- + data_record: DataRecord + DataRecord instance + """ + + raise NotImplementedError + + +class DynamoDBPersistenceLayer(BasePersistenceLayer): + def __init__( + self, + table_name: str, # Can we use the lambda function name? + key_attr: Optional[str] = "id", + expiry_attr: Optional[str] = "expiration", + status_attr: Optional[str] = "status", + data_attr: Optional[str] = "data", + boto_config: Optional[Config] = None, + create_table_if_not_existing: Optional[bool] = False, + *args, + **kwargs, + ): + """ + Initialize the DynamoDB client + + Parameters + ---------- + table_name: str + Name of the table to use for storing execution records + key_attr: str, optional + DynamoDB attribute name for key, by default "id" + expiry_attr: str, optional + DynamoDB attribute name for expiry timestamp, by default "expiration" + status_attr: str, optional + DynamoDB attribute name for status, by default "status" + data_attr: str, optional + DynamoDB attribute name for response data, by default "data" + boto_config: botocore.config.Config, optional + Botocore configuration to pass during client initialization + create_table_if_not_existing: bool, optional + Whether to create the dynamodb table if it doesn't already exist, by default False + args + kwargs + + Examples + -------- + **Create a DynamoDB persistence layer with custom settings** + >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer + >>> + >>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store") + >>> + >>> @idempotent(persistence=persistence_store) + >>> def handler(event, context): + >>> return {"StatusCode": 200} + """ + + boto_config = boto_config or Config() + self._ddb_resource = boto3.resource("dynamodb", config=boto_config) + self.table_name = table_name + self.table = self._ddb_resource.Table(self.table_name) + self.key_attr = key_attr + self.expiry_attr = expiry_attr + self.status_attr = status_attr + self.data_attr = data_attr + self.create_table_if_not_existing = create_table_if_not_existing + super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs) + + if self.create_table_if_not_existing: + self._check_and_create_table() + + def _check_and_create_table(self) -> None: + """ + Check if DynamoDB table exists already, create it if not + """ + try: + client = self._ddb_resource.meta.client + table_ttl_description = client.describe_time_to_live(TableName=self.table_name) + ttl_setting = table_ttl_description["TimeToLiveDescription"]["TimeToLiveStatus"] + if ttl_setting == "DISABLED": + self._set_table_ttl() + + except self._ddb_resource.meta.client.exceptions.ResourceNotFoundException: + if self.create_table_if_not_existing: + self._create_table() + self._wait_for_table() + self._set_table_ttl() + + def _create_table(self) -> None: + """ + Create DynamoDB table + """ + self._ddb_resource.create_table( + TableName=self.table_name, + KeySchema=[{"AttributeName": self.key_attr, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": self.key_attr, "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + def _wait_for_table(self) -> None: + """ + Wait for table to finish being created + """ + waiter = self._ddb_resource.meta.client.get_waiter("table_exists") + waiter.wait(TableName=self.table_name, WaiterConfig={"Delay": 5, "MaxAttempts": 6}) + + def _set_table_ttl(self) -> None: + """ + Set TTL on table to track the expiry attribute + """ + self._ddb_resource.meta.client.update_time_to_live( + TableName=self.table_name, TimeToLiveSpecification={"Enabled": True, "AttributeName": self.expiry_attr} + ) + + def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: + """ + Translate raw item records from DynamoDB to DataRecord + + Parameters + ---------- + item: Dict[str, Union[str, int]] + Item format from dynamodb response + + Returns + ------- + DataRecord + representation of item + + """ + return DataRecord( + idempotency_key=item[self.key_attr], + status=item[self.status_attr], + expiry_timestamp=item[self.expiry_attr], + response_data=item.get(self.data_attr), + ) + + def _get_record(self, idempotency_key) -> DataRecord: + try: + response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) + except self._ddb_resource.meta.client.exceptions.ResourceNotFoundException: + if self.create_table_if_not_existing: + self._create_table() + response = {} + else: + raise + + try: + item = response["Item"] + except KeyError: + raise ItemNotFoundError + return self._item_to_data_record(item) + + def _put_record(self, data_record: DataRecord) -> None: + now = datetime.datetime.now() + try: + self.table.put_item( + Item={ + self.key_attr: data_record.idempotency_key, + "expiration": data_record.expiry_timestamp, + "status": STATUS_CONSTANTS["INPROGRESS"], + }, + ConditionExpression=f"attribute_not_exists({self.key_attr}) OR expiration < :now", + ExpressionAttributeValues={":now": int(now.timestamp())}, + ) + except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: + raise ItemAlreadyExistsError + + def _update_record(self, data_record: DataRecord): + + self.table.update_item( + Key={self.key_attr: data_record.idempotency_key}, + UpdateExpression="SET #response_data = :response_data, #expiry = :expiry, #status = :status", + ExpressionAttributeValues={ + ":expiry": data_record.expiry_timestamp, + ":response_data": data_record.response_data, + ":status": data_record.status, + }, + ExpressionAttributeNames={ + "#response_data": self.data_attr, + "#expiry": self.expiry_attr, + "#status": self.status_attr, + }, + ) + + def _delete_record(self, data_record: DataRecord) -> None: + self.table.delete_item(Key={self.key_attr: data_record.idempotency_key},) diff --git a/tests/functional/test_utilities_idempotency.py b/tests/functional/test_utilities_idempotency.py new file mode 100644 index 00000000000..a09a272dec3 --- /dev/null +++ b/tests/functional/test_utilities_idempotency.py @@ -0,0 +1,336 @@ +import base64 +import datetime +import json +import os +import pickle + +import pytest +from botocore import stub +from botocore.config import Config + +from aws_lambda_powertools.utilities.idempotency.exceptions import AlreadyInProgressError +from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent +from aws_lambda_powertools.utilities.idempotency.persistence import DynamoDBPersistenceLayer + + +class CustomException1(Exception): + pass + + +class CustomException2(Exception): + pass + + +TABLE_NAME = "TEST_TABLE" + + +@pytest.fixture +def b64encoded_picked_error(): + return base64.b64encode(pickle.dumps(CustomException1("Somthing went wrong!"))).decode() + + +@pytest.fixture(scope="module") +def config() -> Config: + return Config(region_name="us-east-1") + + +@pytest.fixture(scope="module") +def lambda_apigw_event(): + full_file_name = os.path.dirname(os.path.realpath(__file__)) + "/../events/" + "apiGatewayProxyV2Event.json" + with open(full_file_name) as fp: + event = json.load(fp) + + return event + + +@pytest.fixture +def timestamp_future(): + return str(int((datetime.datetime.now() + datetime.timedelta(seconds=3600)).timestamp())) + + +@pytest.fixture +def timestamp_expired(): + now = datetime.datetime.now() + period = datetime.timedelta(seconds=6400) + return str(int((now - period).timestamp())) + + +@pytest.fixture(scope="module") +def lambda_response(): + return {"message": "test", "statusCode": 200} + + +@pytest.fixture +def expected_params_update_item(lambda_response, md5hashed_idempotency_key): + return { + "ExpressionAttributeNames": {"#expiry": "expiration", "#response_data": "data", "#status": "status"}, + "ExpressionAttributeValues": { + ":expiry": stub.ANY, + ":response_data": json.dumps(lambda_response), + ":status": "COMPLETED", + }, + "Key": {"id": md5hashed_idempotency_key}, + "TableName": "TEST_TABLE", + "UpdateExpression": "SET #response_data = :response_data, " "#expiry = :expiry, #status = :status", + } + + +@pytest.fixture(scope="session") +def md5hashed_idempotency_key(): + return "e730b8578240b31b9a999c7fabf5f9bb" + + +def test_idempotent_lambda_already_completed( + config, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key +): + """ + Test idempotent decorator where event with matching event key has already been succesfully processed + """ + + persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) + stubber = stub.Stubber(persistence_store.table.meta.client) + ddb_response = { + "Item": { + "id": {"S": md5hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "COMPLETED"}, + } + } + + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": md5hashed_idempotency_key}, + "ConsistentRead": True, + } + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @idempotent(persistence=persistence_store) + def lambda_handler(event, context): + raise Exception + + lambda_resp = lambda_handler(lambda_apigw_event, {}) + assert lambda_resp == lambda_response + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +def test_idempotent_lambda_in_progress( + config, lambda_apigw_event, lambda_response, timestamp_future, md5hashed_idempotency_key +): + """ + Test idempotent decorator where lambda_handler is already processing an event with matching event key + """ + + persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) + stubber = stub.Stubber(persistence_store.table.meta.client) + + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": md5hashed_idempotency_key}, + "ConsistentRead": True, + } + ddb_response = { + "Item": { + "id": {"S": md5hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "INPROGRESS"}, + } + } + + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @idempotent(persistence=persistence_store) + def lambda_handler(event, context): + return lambda_response + + with pytest.raises(AlreadyInProgressError) as ex: + lambda_handler(lambda_apigw_event, {}) + assert ( + ex.value.args[0] == "Execution already in progress with idempotency key: " + "body=a3edd699125517bb49d562501179ecbd" + ) + print(ex) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +def test_idempotent_lambda_first_execution( + config, lambda_apigw_event, expected_params_update_item, lambda_response, md5hashed_idempotency_key +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key + """ + + persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) + + stubber = stub.Stubber(persistence_store.table.meta.client) + ddb_response = {} + + expected_params_get_item = { + "TableName": TABLE_NAME, + "Key": {"id": md5hashed_idempotency_key}, + "ConsistentRead": True, + } + expected_params_put_item = { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": {"expiration": stub.ANY, "id": md5hashed_idempotency_key, "status": "INPROGRESS"}, + "TableName": "TEST_TABLE", + } + + stubber.add_response("get_item", ddb_response, expected_params_get_item) + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_response("update_item", ddb_response, expected_params_update_item) + stubber.activate() + + @idempotent(persistence=persistence_store) + def lambda_handler(event, context): + return lambda_response + + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +def test_idempotent_lambda_expired( + config, + lambda_apigw_event, + timestamp_expired, + lambda_response, + expected_params_update_item, + md5hashed_idempotency_key, +): + """ + Test idempotent decorator when lambda is called with an event it succesfully handled already, but outside of the + expiry window + """ + + persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response = {} + ddb_response_get_item = { + "Item": { + "id": {"S": md5hashed_idempotency_key}, + "expiration": {"N": timestamp_expired}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "INPROGRESS"}, + } + } + expected_params_get_item = { + "TableName": TABLE_NAME, + "Key": {"id": md5hashed_idempotency_key}, + "ConsistentRead": True, + } + + stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) + stubber.add_response("update_item", ddb_response, expected_params_update_item) + stubber.activate() + + @idempotent(persistence=persistence_store) + def lambda_handler(event, context): + return lambda_response + + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +# Note - this test will need to change depending on how we define event handling behavior +def test_idempotent_lambda_exception_retryable_error( + config, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception which is retryable. + """ + + # Create a new provider + persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) + + # Stub the boto3 client + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response = {} + ddb_response_get_item = {} + expected_params_get_item = { + "TableName": TABLE_NAME, + "Key": {"id": md5hashed_idempotency_key}, + "ConsistentRead": True, + } + expected_params_put_item = { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": {"expiration": stub.ANY, "id": md5hashed_idempotency_key, "status": "INPROGRESS"}, + "TableName": "TEST_TABLE", + } + expected_params_delete_item = {"TableName": TABLE_NAME, "Key": {"id": md5hashed_idempotency_key}} + + stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_response("delete_item", ddb_response, expected_params_delete_item) + stubber.activate() + + @idempotent(persistence=persistence_store) + def lambda_handler(event, context): + raise Exception("Something went wrong!") + + with pytest.raises(Exception): + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +def test_idempotent_lambda_exception_non_retryable_error( + config, lambda_apigw_event, timestamp_future, lambda_response, b64encoded_picked_error, md5hashed_idempotency_key +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception. + """ + + persistence_store = DynamoDBPersistenceLayer( + event_key="body", table_name=TABLE_NAME, non_retryable_errors=(CustomException1,) + ) + + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response_get_item = { + "Item": { + "id": {"S": md5hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "data": {"S": b64encoded_picked_error}, + "status": {"S": "ERROR"}, + } + } + expected_params_get_item = { + "TableName": TABLE_NAME, + "Key": {"id": md5hashed_idempotency_key}, + "ConsistentRead": True, + } + + stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) + stubber.activate() + + @idempotent(persistence=persistence_store) + def lambda_handler(event, context): + raise CustomException1("Somthing went wrong!") + + with pytest.raises(CustomException1): + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +def test_create_table(): + pass From e564c63983357b4ed44e71f233acbffe313b075e Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Wed, 16 Dec 2020 15:15:12 +0100 Subject: [PATCH 02/44] fix: ensure region is configured in botocore for tests --- .../functional/test_utilities_idempotency.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/functional/test_utilities_idempotency.py b/tests/functional/test_utilities_idempotency.py index a09a272dec3..5e6c15839be 100644 --- a/tests/functional/test_utilities_idempotency.py +++ b/tests/functional/test_utilities_idempotency.py @@ -80,14 +80,19 @@ def md5hashed_idempotency_key(): return "e730b8578240b31b9a999c7fabf5f9bb" +@pytest.fixture +def persistence_store(config): + persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME, boto_config=config) + return persistence_store + + def test_idempotent_lambda_already_completed( - config, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key + persistence_store, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key, ): """ Test idempotent decorator where event with matching event key has already been succesfully processed """ - persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = { "Item": { @@ -118,13 +123,12 @@ def lambda_handler(event, context): def test_idempotent_lambda_in_progress( - config, lambda_apigw_event, lambda_response, timestamp_future, md5hashed_idempotency_key + persistence_store, lambda_apigw_event, lambda_response, timestamp_future, md5hashed_idempotency_key ): """ Test idempotent decorator where lambda_handler is already processing an event with matching event key """ - persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) stubber = stub.Stubber(persistence_store.table.meta.client) expected_params = { @@ -161,14 +165,12 @@ def lambda_handler(event, context): def test_idempotent_lambda_first_execution( - config, lambda_apigw_event, expected_params_update_item, lambda_response, md5hashed_idempotency_key + persistence_store, lambda_apigw_event, expected_params_update_item, lambda_response, md5hashed_idempotency_key ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key """ - persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) - stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} @@ -200,7 +202,7 @@ def lambda_handler(event, context): def test_idempotent_lambda_expired( - config, + persistence_store, lambda_apigw_event, timestamp_expired, lambda_response, @@ -212,7 +214,6 @@ def test_idempotent_lambda_expired( expiry window """ - persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} @@ -246,7 +247,7 @@ def lambda_handler(event, context): # Note - this test will need to change depending on how we define event handling behavior def test_idempotent_lambda_exception_retryable_error( - config, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key + persistence_store, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but @@ -254,7 +255,6 @@ def test_idempotent_lambda_exception_retryable_error( """ # Create a new provider - persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME) # Stub the boto3 client stubber = stub.Stubber(persistence_store.table.meta.client) @@ -299,7 +299,7 @@ def test_idempotent_lambda_exception_non_retryable_error( """ persistence_store = DynamoDBPersistenceLayer( - event_key="body", table_name=TABLE_NAME, non_retryable_errors=(CustomException1,) + event_key="body", table_name=TABLE_NAME, non_retryable_errors=(CustomException1,), boto_config=config ) stubber = stub.Stubber(persistence_store.table.meta.client) From c4d19badd1971e9507ec11e301709992c6713f33 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Wed, 16 Dec 2020 15:50:20 +0100 Subject: [PATCH 03/44] chore: ignore security warning for md5 usage --- aws_lambda_powertools/utilities/idempotency/persistence.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py index 660540908bf..d3afb100edb 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -147,7 +147,10 @@ def get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ data = self.event_key_jmespath.search(lambda_event) - hashed_data = hashlib.md5(json.dumps(data).encode()) + + # The following hash is not used in any security context. It is only used + # to generate unique values. + hashed_data = hashlib.md5(json.dumps(data).encode()) # nosec return hashed_data.hexdigest() def _get_expiry_timestamp(self) -> int: From 45d384bf52556719f6b40ed423670f8c3178c728 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Mon, 4 Jan 2021 15:57:58 +0100 Subject: [PATCH 04/44] chore: add debug logging --- .../utilities/idempotency/idempotency.py | 4 ++- .../utilities/idempotency/persistence.py | 27 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 31ca8e57405..d4c77c3070c 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -1,7 +1,7 @@ """ Primary interface for idempotent Lambda functions utility """ - +import logging from typing import Any, Callable, Dict from aws_lambda_powertools.middleware_factory import lambda_handler_decorator @@ -10,6 +10,8 @@ from .exceptions import AlreadyInProgressError, ItemNotFoundError from .persistence import STATUS_CONSTANTS, BasePersistenceLayer +logger = logging.getLogger(__name__) + def default_error_callback(): raise diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py index d3afb100edb..9e9d5ae8009 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -5,6 +5,7 @@ import datetime import hashlib import json +import logging import pickle from abc import ABC, abstractmethod from base64 import b64decode, b64encode @@ -16,6 +17,8 @@ from .exceptions import InvalidStatusError, ItemAlreadyExistsError, ItemNotFoundError +logger = logging.getLogger(__name__) + STATUS_CONSTANTS = { "NOTEXISTING": "DOESNOTEXIST", "INPROGRESS": "INPROGRESS", @@ -185,6 +188,10 @@ def save_success(self, event: Dict[str, Any], result: dict) -> None: expiry_timestamp=self._get_expiry_timestamp(), response_data=response_data, ) + logger.debug( + f"Lambda successfully executed. Saving record to persistence store with " + f"idempotency key: {data_record.idempotency_key}" + ) self._update_record(data_record=data_record) def save_inprogress(self, event: Dict[str, Any]) -> None: @@ -201,6 +208,7 @@ def save_inprogress(self, event: Dict[str, Any]) -> None: status=STATUS_CONSTANTS["INPROGRESS"], expiry_timestamp=self._get_expiry_timestamp(), ) + logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}") self._put_record(data_record) def save_error(self, event: Dict[str, Any], exception: Exception): @@ -224,9 +232,18 @@ def save_error(self, event: Dict[str, Any], exception: Exception): # error classes if not self._is_retryable(exception): data_record.response_data = b64encode(pickle.dumps(exception)).decode() + logger.debug( + f"Lambda raised an exception ({type(exception).__name__}). The error is not retryable, " + f"updating pesistence store for idempotency key: {data_record.idempotency_key}" + ) self._update_record(data_record) else: # If the error is retryable, delete the in progress record from the store + logger.debug( + f"Lambda raised an exception ({type(exception).__name__}), but the error is retryable. " + f"Not updating persistence store for idempotency key: " + f"{data_record.idempotency_key}" + ) self._delete_record(data_record) def _is_retryable(self, exception: Exception): @@ -395,6 +412,7 @@ def _check_and_create_table(self) -> None: """ Check if DynamoDB table exists already, create it if not """ + logger.debug("Checking if DynamoDB table exists already") try: client = self._ddb_resource.meta.client table_ttl_description = client.describe_time_to_live(TableName=self.table_name) @@ -403,6 +421,7 @@ def _check_and_create_table(self) -> None: self._set_table_ttl() except self._ddb_resource.meta.client.exceptions.ResourceNotFoundException: + logger.debug(f'Table "{self.table_name}" does not exist') if self.create_table_if_not_existing: self._create_table() self._wait_for_table() @@ -412,6 +431,7 @@ def _create_table(self) -> None: """ Create DynamoDB table """ + logger.debug(f'Creating table "{self.table_name}"') self._ddb_resource.create_table( TableName=self.table_name, KeySchema=[{"AttributeName": self.key_attr, "KeyType": "HASH"}], @@ -423,6 +443,7 @@ def _wait_for_table(self) -> None: """ Wait for table to finish being created """ + logger.debug(f'Waiting for creation of table "{self.table_name}" to complete') waiter = self._ddb_resource.meta.client.get_waiter("table_exists") waiter.wait(TableName=self.table_name, WaiterConfig={"Delay": 5, "MaxAttempts": 6}) @@ -430,6 +451,7 @@ def _set_table_ttl(self) -> None: """ Set TTL on table to track the expiry attribute """ + logger.debug(f'Applying TTL settings to table "{self.table_name}"') self._ddb_resource.meta.client.update_time_to_live( TableName=self.table_name, TimeToLiveSpecification={"Enabled": True, "AttributeName": self.expiry_attr} ) @@ -475,6 +497,7 @@ def _get_record(self, idempotency_key) -> DataRecord: def _put_record(self, data_record: DataRecord) -> None: now = datetime.datetime.now() try: + logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}") self.table.put_item( Item={ self.key_attr: data_record.idempotency_key, @@ -485,10 +508,11 @@ def _put_record(self, data_record: DataRecord) -> None: ExpressionAttributeValues={":now": int(now.timestamp())}, ) except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: + logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}") raise ItemAlreadyExistsError def _update_record(self, data_record: DataRecord): - + logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") self.table.update_item( Key={self.key_attr: data_record.idempotency_key}, UpdateExpression="SET #response_data = :response_data, #expiry = :expiry, #status = :status", @@ -505,4 +529,5 @@ def _update_record(self, data_record: DataRecord): ) def _delete_record(self, data_record: DataRecord) -> None: + logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}") self.table.delete_item(Key={self.key_attr: data_record.idempotency_key},) From 13e5b09cc336279f8553807f2d69307f8818a19a Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Mon, 11 Jan 2021 17:56:59 +0100 Subject: [PATCH 05/44] feat: add local caching for idempotency lookups --- .../utilities/idempotency/persistence.py | 49 +++++++++++++++- .../functional/test_utilities_idempotency.py | 56 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py index 9e9d5ae8009..c2151bbbad2 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -115,7 +115,11 @@ class BasePersistenceLayer(ABC): """ def __init__( - self, event_key: str, expires_after: int = 3600, non_retryable_errors: Optional[Iterable[Exception]] = None + self, + event_key: str, + expires_after: int = 3600, + non_retryable_errors: Optional[Iterable[Exception]] = None, + use_local_cache: bool = False, ) -> None: """ Initialize the base persistence layer @@ -128,11 +132,15 @@ def __init__( The number of milliseconds to wait before a record is expired non_retryable_errors: Iterable, optional An interable of exception classes which should not be retried after being raised, by default [] + use_local_cache: bool, optional + Whether to locally cache idempotency results, by default False """ self.event_key = event_key self.event_key_jmespath = jmespath.compile(event_key) self.expires_after = expires_after self.non_retryable_errors = non_retryable_errors or [] + self.use_local_cache = use_local_cache + self._cache = {} def get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ @@ -169,6 +177,20 @@ def _get_expiry_timestamp(self) -> int: period = datetime.timedelta(seconds=self.expires_after) return int((now + period).timestamp()) + def _save_to_cache(self, data_record: DataRecord): + self._cache[data_record.idempotency_key] = data_record + + def _retrieve_from_cache(self, idempotency_key: str): + cached_record = self._cache.get(idempotency_key) + if cached_record: + if not cached_record.is_expired: + return cached_record + else: + self._delete_from_cache(idempotency_key) + + def _delete_from_cache(self, idempotency_key: str): + self._cache.pop(idempotency_key) + def save_success(self, event: Dict[str, Any], result: dict) -> None: """ Save record of function's execution completing succesfully @@ -194,6 +216,9 @@ def save_success(self, event: Dict[str, Any], result: dict) -> None: ) self._update_record(data_record=data_record) + if self.use_local_cache: + self._save_to_cache(data_record) + def save_inprogress(self, event: Dict[str, Any]) -> None: """ Save record of function's execution being in progress @@ -208,9 +233,13 @@ def save_inprogress(self, event: Dict[str, Any]) -> None: status=STATUS_CONSTANTS["INPROGRESS"], expiry_timestamp=self._get_expiry_timestamp(), ) + logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}") self._put_record(data_record) + if self.use_local_cache: + self._save_to_cache(data_record) + def save_error(self, event: Dict[str, Any], exception: Exception): """ Save record of lambda handler raising an exception @@ -236,9 +265,15 @@ def save_error(self, event: Dict[str, Any], exception: Exception): f"Lambda raised an exception ({type(exception).__name__}). The error is not retryable, " f"updating pesistence store for idempotency key: {data_record.idempotency_key}" ) + self._update_record(data_record) + + if self.use_local_cache: + self._save_to_cache(data_record) + else: # If the error is retryable, delete the in progress record from the store + logger.debug( f"Lambda raised an exception ({type(exception).__name__}), but the error is retryable. " f"Not updating persistence store for idempotency key: " @@ -246,6 +281,9 @@ def save_error(self, event: Dict[str, Any], exception: Exception): ) self._delete_record(data_record) + if self.use_local_cache: + self._delete_from_cache(data_record.idempotency_key) + def _is_retryable(self, exception: Exception): """ Check whether the exception is retryable or not @@ -284,6 +322,15 @@ def get_record(self, lambda_event) -> DataRecord: idempotency_key = self.get_hashed_idempotency_key(lambda_event) + if self.use_local_cache: + cached_record = self._retrieve_from_cache(idempotency_key) + if cached_record: + if cached_record.is_expired: + self._delete_from_cache(idempotency_key) + else: + logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") + return cached_record + return self._get_record(idempotency_key) @abstractmethod diff --git a/tests/functional/test_utilities_idempotency.py b/tests/functional/test_utilities_idempotency.py index 5e6c15839be..027676a5675 100644 --- a/tests/functional/test_utilities_idempotency.py +++ b/tests/functional/test_utilities_idempotency.py @@ -86,6 +86,14 @@ def persistence_store(config): return persistence_store +@pytest.fixture +def persistence_store_with_cache(config): + persistence_store = DynamoDBPersistenceLayer( + event_key="body", table_name=TABLE_NAME, boto_config=config, use_local_cache=True + ) + return persistence_store + + def test_idempotent_lambda_already_completed( persistence_store, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key, ): @@ -201,6 +209,54 @@ def lambda_handler(event, context): stubber.deactivate() +def test_idempotent_lambda_first_execution_cached( + persistence_store_with_cache, + lambda_apigw_event, + expected_params_update_item, + lambda_response, + md5hashed_idempotency_key, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key. Ensure + result is cached locally on the persistence store instance. + """ + + stubber = stub.Stubber(persistence_store_with_cache.table.meta.client) + ddb_response = {} + + expected_params_get_item = { + "TableName": TABLE_NAME, + "Key": {"id": md5hashed_idempotency_key}, + "ConsistentRead": True, + } + expected_params_put_item = { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": {"expiration": stub.ANY, "id": md5hashed_idempotency_key, "status": "INPROGRESS"}, + "TableName": "TEST_TABLE", + } + + stubber.add_response("get_item", ddb_response, expected_params_get_item) + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_response("update_item", ddb_response, expected_params_update_item) + stubber.activate() + + @idempotent(persistence=persistence_store_with_cache) + def lambda_handler(event, context): + return lambda_response + + lambda_handler(lambda_apigw_event, {}) + + assert persistence_store_with_cache._cache.get(md5hashed_idempotency_key) + + # This lambda call should not call AWS API + lambda_handler(lambda_apigw_event, {}) + + # This assertion fails if an AWS API operation was called more than once + stubber.assert_no_pending_responses() + stubber.deactivate() + + def test_idempotent_lambda_expired( persistence_store, lambda_apigw_event, From 1efc27dfec27f03b46bdef84b3273cca2f6b7a4b Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Thu, 14 Jan 2021 16:10:36 +0100 Subject: [PATCH 06/44] feat: replace simple dict cache with LRU --- .../utilities/idempotency/cache_dict.py | 26 +++++++++++++++++++ .../utilities/idempotency/persistence.py | 23 +++++++++------- 2 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 aws_lambda_powertools/utilities/idempotency/cache_dict.py diff --git a/aws_lambda_powertools/utilities/idempotency/cache_dict.py b/aws_lambda_powertools/utilities/idempotency/cache_dict.py new file mode 100644 index 00000000000..3d71a7c406c --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/cache_dict.py @@ -0,0 +1,26 @@ +from collections import OrderedDict + + +class LRUDict(OrderedDict): + def __init__(self, max_size=1024, *args, **kwds): + self.max_size = max_size + super().__init__(*args, **kwds) + + def __getitem__(self, key): + value = super().__getitem__(key) + self.move_to_end(key) + return value + + def __setitem__(self, key, value): + if key in self: + self.move_to_end(key) + super().__setitem__(key, value) + if len(self) > self.max_size: + oldest = next(iter(self)) + del self[oldest] + + def get(self, key, *args, **kwargs): + item = super(LRUDict, self).get(key, *args, **kwargs) + if item: + self.move_to_end(key=key) + return item diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py index c2151bbbad2..96781d2b74e 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -15,6 +15,7 @@ import jmespath from botocore.config import Config +from .cache_dict import LRUDict from .exceptions import InvalidStatusError, ItemAlreadyExistsError, ItemNotFoundError logger = logging.getLogger(__name__) @@ -120,6 +121,7 @@ def __init__( expires_after: int = 3600, non_retryable_errors: Optional[Iterable[Exception]] = None, use_local_cache: bool = False, + local_cache_maxsize: int = 1024, ) -> None: """ Initialize the base persistence layer @@ -134,13 +136,16 @@ def __init__( An interable of exception classes which should not be retried after being raised, by default [] use_local_cache: bool, optional Whether to locally cache idempotency results, by default False + local_cache_maxsize: int, optional + Max number of items to store in local cache, by default 1024 """ self.event_key = event_key self.event_key_jmespath = jmespath.compile(event_key) self.expires_after = expires_after self.non_retryable_errors = non_retryable_errors or [] self.use_local_cache = use_local_cache - self._cache = {} + if self.use_local_cache: + self._cache = LRUDict(max_size=local_cache_maxsize) def get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ @@ -183,13 +188,14 @@ def _save_to_cache(self, data_record: DataRecord): def _retrieve_from_cache(self, idempotency_key: str): cached_record = self._cache.get(idempotency_key) if cached_record: - if not cached_record.is_expired: - return cached_record - else: + if cached_record.is_expired: + logger.debug(f"Removing expired local cache record for idempotency key: {idempotency_key}") self._delete_from_cache(idempotency_key) + else: + return cached_record def _delete_from_cache(self, idempotency_key: str): - self._cache.pop(idempotency_key) + del self._cache[idempotency_key] def save_success(self, event: Dict[str, Any], result: dict) -> None: """ @@ -325,11 +331,8 @@ def get_record(self, lambda_event) -> DataRecord: if self.use_local_cache: cached_record = self._retrieve_from_cache(idempotency_key) if cached_record: - if cached_record.is_expired: - self._delete_from_cache(idempotency_key) - else: - logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") - return cached_record + logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") + return cached_record return self._get_record(idempotency_key) From 546e8790fecb3dc19b571260679560753eb5c9b2 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Thu, 14 Jan 2021 16:18:35 +0100 Subject: [PATCH 07/44] feat: remove idempotent exception handling --- .../utilities/idempotency/persistence.py | 73 +++---------------- .../functional/test_utilities_idempotency.py | 48 +----------- 2 files changed, 10 insertions(+), 111 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py index 96781d2b74e..8e0b0bdfbee 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -6,10 +6,8 @@ import hashlib import json import logging -import pickle from abc import ABC, abstractmethod -from base64 import b64decode, b64encode -from typing import Any, Dict, Iterable, Optional, Union +from typing import Any, Dict, Optional, Union import boto3 import jmespath @@ -98,17 +96,6 @@ def response_json_as_dict(self) -> dict: """ return json.loads(self.response_data) - def raise_stored_exception(self): - """ - Raises - ------ - Exception - Decoded and unpickled Exception from persistent store - """ - - decoded_exception = pickle.loads(b64decode(self.response_data.encode())) - raise decoded_exception - class BasePersistenceLayer(ABC): """ @@ -116,12 +103,7 @@ class BasePersistenceLayer(ABC): """ def __init__( - self, - event_key: str, - expires_after: int = 3600, - non_retryable_errors: Optional[Iterable[Exception]] = None, - use_local_cache: bool = False, - local_cache_maxsize: int = 1024, + self, event_key: str, expires_after: int = 3600, use_local_cache: bool = False, local_cache_maxsize: int = 1024, ) -> None: """ Initialize the base persistence layer @@ -132,8 +114,6 @@ def __init__( A jmespath expression to extract the idempotency key from the event record expires_after: int The number of milliseconds to wait before a record is expired - non_retryable_errors: Iterable, optional - An interable of exception classes which should not be retried after being raised, by default [] use_local_cache: bool, optional Whether to locally cache idempotency results, by default False local_cache_maxsize: int, optional @@ -142,7 +122,6 @@ def __init__( self.event_key = event_key self.event_key_jmespath = jmespath.compile(event_key) self.expires_after = expires_after - self.non_retryable_errors = non_retryable_errors or [] self.use_local_cache = use_local_cache if self.use_local_cache: self._cache = LRUDict(max_size=local_cache_maxsize) @@ -263,48 +242,14 @@ def save_error(self, event: Dict[str, Any], exception: Exception): expiry_timestamp=self._get_expiry_timestamp(), ) - # Only write a record of the error to the persistent store if it is not a subclass of any of the non retryable - # error classes - if not self._is_retryable(exception): - data_record.response_data = b64encode(pickle.dumps(exception)).decode() - logger.debug( - f"Lambda raised an exception ({type(exception).__name__}). The error is not retryable, " - f"updating pesistence store for idempotency key: {data_record.idempotency_key}" - ) - - self._update_record(data_record) - - if self.use_local_cache: - self._save_to_cache(data_record) - - else: - # If the error is retryable, delete the in progress record from the store - - logger.debug( - f"Lambda raised an exception ({type(exception).__name__}), but the error is retryable. " - f"Not updating persistence store for idempotency key: " - f"{data_record.idempotency_key}" - ) - self._delete_record(data_record) - - if self.use_local_cache: - self._delete_from_cache(data_record.idempotency_key) - - def _is_retryable(self, exception: Exception): - """ - Check whether the exception is retryable or not - - Parameters - ---------- - exception: Exception - exception instance raised by lambda handler + logger.debug( + f"Lambda raised an exception ({type(exception).__name__}). Clearing in progress record in persistence " + f"store for idempotency key: {data_record.idempotency_key}" + ) + self._delete_record(data_record) - Returns - ------- - bool - Whether exception should be retried in the future or not - """ - return not any((issubclass(exception.__class__, nr) for nr in self.non_retryable_errors)) + if self.use_local_cache: + self._delete_from_cache(data_record.idempotency_key) def get_record(self, lambda_event) -> DataRecord: """ diff --git a/tests/functional/test_utilities_idempotency.py b/tests/functional/test_utilities_idempotency.py index 027676a5675..25936ecf484 100644 --- a/tests/functional/test_utilities_idempotency.py +++ b/tests/functional/test_utilities_idempotency.py @@ -302,7 +302,7 @@ def lambda_handler(event, context): # Note - this test will need to change depending on how we define event handling behavior -def test_idempotent_lambda_exception_retryable_error( +def test_idempotent_lambda_exception( persistence_store, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key ): """ @@ -344,49 +344,3 @@ def lambda_handler(event, context): stubber.assert_no_pending_responses() stubber.deactivate() - - -def test_idempotent_lambda_exception_non_retryable_error( - config, lambda_apigw_event, timestamp_future, lambda_response, b64encoded_picked_error, md5hashed_idempotency_key -): - """ - Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but - lambda_handler raises an exception. - """ - - persistence_store = DynamoDBPersistenceLayer( - event_key="body", table_name=TABLE_NAME, non_retryable_errors=(CustomException1,), boto_config=config - ) - - stubber = stub.Stubber(persistence_store.table.meta.client) - - ddb_response_get_item = { - "Item": { - "id": {"S": md5hashed_idempotency_key}, - "expiration": {"N": timestamp_future}, - "data": {"S": b64encoded_picked_error}, - "status": {"S": "ERROR"}, - } - } - expected_params_get_item = { - "TableName": TABLE_NAME, - "Key": {"id": md5hashed_idempotency_key}, - "ConsistentRead": True, - } - - stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) - stubber.activate() - - @idempotent(persistence=persistence_store) - def lambda_handler(event, context): - raise CustomException1("Somthing went wrong!") - - with pytest.raises(CustomException1): - lambda_handler(lambda_apigw_event, {}) - - stubber.assert_no_pending_responses() - stubber.deactivate() - - -def test_create_table(): - pass From 60fd33607a7444ab8e561c00827ee4be8f6d9169 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Thu, 14 Jan 2021 16:20:23 +0100 Subject: [PATCH 08/44] feat: remove unused logic to create ddb table - will handle in documentation instead --- .../utilities/idempotency/persistence.py | 55 ------------------- 1 file changed, 55 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py index 8e0b0bdfbee..0410a308a83 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -351,7 +351,6 @@ def __init__( status_attr: Optional[str] = "status", data_attr: Optional[str] = "data", boto_config: Optional[Config] = None, - create_table_if_not_existing: Optional[bool] = False, *args, **kwargs, ): @@ -372,8 +371,6 @@ def __init__( DynamoDB attribute name for response data, by default "data" boto_config: botocore.config.Config, optional Botocore configuration to pass during client initialization - create_table_if_not_existing: bool, optional - Whether to create the dynamodb table if it doesn't already exist, by default False args kwargs @@ -397,60 +394,8 @@ def __init__( self.expiry_attr = expiry_attr self.status_attr = status_attr self.data_attr = data_attr - self.create_table_if_not_existing = create_table_if_not_existing super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs) - if self.create_table_if_not_existing: - self._check_and_create_table() - - def _check_and_create_table(self) -> None: - """ - Check if DynamoDB table exists already, create it if not - """ - logger.debug("Checking if DynamoDB table exists already") - try: - client = self._ddb_resource.meta.client - table_ttl_description = client.describe_time_to_live(TableName=self.table_name) - ttl_setting = table_ttl_description["TimeToLiveDescription"]["TimeToLiveStatus"] - if ttl_setting == "DISABLED": - self._set_table_ttl() - - except self._ddb_resource.meta.client.exceptions.ResourceNotFoundException: - logger.debug(f'Table "{self.table_name}" does not exist') - if self.create_table_if_not_existing: - self._create_table() - self._wait_for_table() - self._set_table_ttl() - - def _create_table(self) -> None: - """ - Create DynamoDB table - """ - logger.debug(f'Creating table "{self.table_name}"') - self._ddb_resource.create_table( - TableName=self.table_name, - KeySchema=[{"AttributeName": self.key_attr, "KeyType": "HASH"}], - AttributeDefinitions=[{"AttributeName": self.key_attr, "AttributeType": "S"}], - BillingMode="PAY_PER_REQUEST", - ) - - def _wait_for_table(self) -> None: - """ - Wait for table to finish being created - """ - logger.debug(f'Waiting for creation of table "{self.table_name}" to complete') - waiter = self._ddb_resource.meta.client.get_waiter("table_exists") - waiter.wait(TableName=self.table_name, WaiterConfig={"Delay": 5, "MaxAttempts": 6}) - - def _set_table_ttl(self) -> None: - """ - Set TTL on table to track the expiry attribute - """ - logger.debug(f'Applying TTL settings to table "{self.table_name}"') - self._ddb_resource.meta.client.update_time_to_live( - TableName=self.table_name, TimeToLiveSpecification={"Enabled": True, "AttributeName": self.expiry_attr} - ) - def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: """ Translate raw item records from DynamoDB to DataRecord From ee4612449fbd8095ba0c3c7539c2bbf9cf726648 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 15 Jan 2021 14:46:32 +0100 Subject: [PATCH 09/44] fix: remove redundant code from table creation logic --- .../utilities/idempotency/persistence.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py index 0410a308a83..1884a10488b 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -419,14 +419,7 @@ def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: ) def _get_record(self, idempotency_key) -> DataRecord: - try: - response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) - except self._ddb_resource.meta.client.exceptions.ResourceNotFoundException: - if self.create_table_if_not_existing: - self._create_table() - response = {} - else: - raise + response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) try: item = response["Item"] From acab0912858252f2853225700ee14b8ed196a53a Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Mon, 18 Jan 2021 21:32:21 +0100 Subject: [PATCH 10/44] chore: move tests to own dir --- tests/functional/idempotency/__init__.py | 0 tests/functional/idempotency/conftest.py | 94 ++++++++++ .../test_idempotency.py} | 173 +++++++----------- 3 files changed, 160 insertions(+), 107 deletions(-) create mode 100644 tests/functional/idempotency/__init__.py create mode 100644 tests/functional/idempotency/conftest.py rename tests/functional/{test_utilities_idempotency.py => idempotency/test_idempotency.py} (68%) diff --git a/tests/functional/idempotency/__init__.py b/tests/functional/idempotency/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py new file mode 100644 index 00000000000..fd136bfe486 --- /dev/null +++ b/tests/functional/idempotency/conftest.py @@ -0,0 +1,94 @@ +import datetime +import hashlib +import json +import os + +import pytest +from botocore import stub +from botocore.config import Config + +from aws_lambda_powertools.utilities.idempotency.persistence import DynamoDBPersistenceLayer + +TABLE_NAME = "TEST_TABLE" + + +@pytest.fixture(scope="module") +def config() -> Config: + return Config(region_name="us-east-1") + + +@pytest.fixture(scope="module") +def lambda_apigw_event(): + full_file_name = os.path.dirname(os.path.realpath(__file__)) + "/../../events/" + "apiGatewayProxyV2Event.json" + with open(full_file_name) as fp: + event = json.load(fp) + + return event + + +@pytest.fixture +def timestamp_future(): + return str(int((datetime.datetime.now() + datetime.timedelta(seconds=3600)).timestamp())) + + +@pytest.fixture +def timestamp_expired(): + now = datetime.datetime.now() + period = datetime.timedelta(seconds=6400) + return str(int((now - period).timestamp())) + + +@pytest.fixture(scope="module") +def lambda_response(): + return {"message": "test", "statusCode": 200} + + +@pytest.fixture +def expected_params_update_item(lambda_response, hashed_idempotency_key): + return { + "ExpressionAttributeNames": {"#expiry": "expiration", "#response_data": "data", "#status": "status"}, + "ExpressionAttributeValues": { + ":expiry": stub.ANY, + ":response_data": json.dumps(lambda_response), + ":status": "COMPLETED", + }, + "Key": {"id": hashed_idempotency_key}, + "TableName": "TEST_TABLE", + "UpdateExpression": "SET #response_data = :response_data, " "#expiry = :expiry, #status = :status", + } + + +@pytest.fixture +def hashed_idempotency_key(lambda_apigw_event): + return hashlib.md5(json.dumps(lambda_apigw_event["body"]).encode()).hexdigest() + + +@pytest.fixture +def hashed_validation_key(lambda_apigw_event): + return hashlib.md5(json.dumps(lambda_apigw_event["requestContext"]).encode()).hexdigest() + + +@pytest.fixture +def persistence_store(config): + persistence_store = DynamoDBPersistenceLayer(event_key_jmespath="body", table_name=TABLE_NAME, boto_config=config) + return persistence_store + + +@pytest.fixture +def persistence_store_with_cache(config): + persistence_store = DynamoDBPersistenceLayer( + event_key_jmespath="body", table_name=TABLE_NAME, boto_config=config, use_local_cache=True + ) + return persistence_store + + +@pytest.fixture +def persistence_store_with_validation(config): + persistence_store = DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name=TABLE_NAME, + boto_config=config, + use_local_cache=True, + payload_validation_jmespath="requestContext", + ) + return persistence_store diff --git a/tests/functional/test_utilities_idempotency.py b/tests/functional/idempotency/test_idempotency.py similarity index 68% rename from tests/functional/test_utilities_idempotency.py rename to tests/functional/idempotency/test_idempotency.py index 25936ecf484..5c665b8cea1 100644 --- a/tests/functional/test_utilities_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -1,101 +1,16 @@ -import base64 -import datetime -import json -import os -import pickle +import copy import pytest from botocore import stub -from botocore.config import Config -from aws_lambda_powertools.utilities.idempotency.exceptions import AlreadyInProgressError +from aws_lambda_powertools.utilities.idempotency.exceptions import AlreadyInProgressError, IdempotencyValidationerror from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent -from aws_lambda_powertools.utilities.idempotency.persistence import DynamoDBPersistenceLayer - - -class CustomException1(Exception): - pass - - -class CustomException2(Exception): - pass - TABLE_NAME = "TEST_TABLE" -@pytest.fixture -def b64encoded_picked_error(): - return base64.b64encode(pickle.dumps(CustomException1("Somthing went wrong!"))).decode() - - -@pytest.fixture(scope="module") -def config() -> Config: - return Config(region_name="us-east-1") - - -@pytest.fixture(scope="module") -def lambda_apigw_event(): - full_file_name = os.path.dirname(os.path.realpath(__file__)) + "/../events/" + "apiGatewayProxyV2Event.json" - with open(full_file_name) as fp: - event = json.load(fp) - - return event - - -@pytest.fixture -def timestamp_future(): - return str(int((datetime.datetime.now() + datetime.timedelta(seconds=3600)).timestamp())) - - -@pytest.fixture -def timestamp_expired(): - now = datetime.datetime.now() - period = datetime.timedelta(seconds=6400) - return str(int((now - period).timestamp())) - - -@pytest.fixture(scope="module") -def lambda_response(): - return {"message": "test", "statusCode": 200} - - -@pytest.fixture -def expected_params_update_item(lambda_response, md5hashed_idempotency_key): - return { - "ExpressionAttributeNames": {"#expiry": "expiration", "#response_data": "data", "#status": "status"}, - "ExpressionAttributeValues": { - ":expiry": stub.ANY, - ":response_data": json.dumps(lambda_response), - ":status": "COMPLETED", - }, - "Key": {"id": md5hashed_idempotency_key}, - "TableName": "TEST_TABLE", - "UpdateExpression": "SET #response_data = :response_data, " "#expiry = :expiry, #status = :status", - } - - -@pytest.fixture(scope="session") -def md5hashed_idempotency_key(): - return "e730b8578240b31b9a999c7fabf5f9bb" - - -@pytest.fixture -def persistence_store(config): - persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name=TABLE_NAME, boto_config=config) - return persistence_store - - -@pytest.fixture -def persistence_store_with_cache(config): - persistence_store = DynamoDBPersistenceLayer( - event_key="body", table_name=TABLE_NAME, boto_config=config, use_local_cache=True - ) - return persistence_store - - def test_idempotent_lambda_already_completed( - persistence_store, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key, + persistence_store, lambda_apigw_event, timestamp_future, lambda_response, hashed_idempotency_key, ): """ Test idempotent decorator where event with matching event key has already been succesfully processed @@ -104,7 +19,7 @@ def test_idempotent_lambda_already_completed( stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = { "Item": { - "id": {"S": md5hashed_idempotency_key}, + "id": {"S": hashed_idempotency_key}, "expiration": {"N": timestamp_future}, "data": {"S": '{"message": "test", "statusCode": 200}'}, "status": {"S": "COMPLETED"}, @@ -113,7 +28,7 @@ def test_idempotent_lambda_already_completed( expected_params = { "TableName": TABLE_NAME, - "Key": {"id": md5hashed_idempotency_key}, + "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True, } stubber.add_response("get_item", ddb_response, expected_params) @@ -131,7 +46,7 @@ def lambda_handler(event, context): def test_idempotent_lambda_in_progress( - persistence_store, lambda_apigw_event, lambda_response, timestamp_future, md5hashed_idempotency_key + persistence_store, lambda_apigw_event, lambda_response, timestamp_future, hashed_idempotency_key ): """ Test idempotent decorator where lambda_handler is already processing an event with matching event key @@ -141,12 +56,12 @@ def test_idempotent_lambda_in_progress( expected_params = { "TableName": TABLE_NAME, - "Key": {"id": md5hashed_idempotency_key}, + "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True, } ddb_response = { "Item": { - "id": {"S": md5hashed_idempotency_key}, + "id": {"S": hashed_idempotency_key}, "expiration": {"N": timestamp_future}, "data": {"S": '{"message": "test", "statusCode": 200}'}, "status": {"S": "INPROGRESS"}, @@ -173,7 +88,7 @@ def lambda_handler(event, context): def test_idempotent_lambda_first_execution( - persistence_store, lambda_apigw_event, expected_params_update_item, lambda_response, md5hashed_idempotency_key + persistence_store, lambda_apigw_event, expected_params_update_item, lambda_response, hashed_idempotency_key ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key @@ -184,13 +99,13 @@ def test_idempotent_lambda_first_execution( expected_params_get_item = { "TableName": TABLE_NAME, - "Key": {"id": md5hashed_idempotency_key}, + "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True, } expected_params_put_item = { "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", "ExpressionAttributeValues": {":now": stub.ANY}, - "Item": {"expiration": stub.ANY, "id": md5hashed_idempotency_key, "status": "INPROGRESS"}, + "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, "TableName": "TEST_TABLE", } @@ -214,7 +129,7 @@ def test_idempotent_lambda_first_execution_cached( lambda_apigw_event, expected_params_update_item, lambda_response, - md5hashed_idempotency_key, + hashed_idempotency_key, ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key. Ensure @@ -226,13 +141,13 @@ def test_idempotent_lambda_first_execution_cached( expected_params_get_item = { "TableName": TABLE_NAME, - "Key": {"id": md5hashed_idempotency_key}, + "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True, } expected_params_put_item = { "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", "ExpressionAttributeValues": {":now": stub.ANY}, - "Item": {"expiration": stub.ANY, "id": md5hashed_idempotency_key, "status": "INPROGRESS"}, + "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, "TableName": "TEST_TABLE", } @@ -247,7 +162,7 @@ def lambda_handler(event, context): lambda_handler(lambda_apigw_event, {}) - assert persistence_store_with_cache._cache.get(md5hashed_idempotency_key) + assert persistence_store_with_cache._cache.get(hashed_idempotency_key) # This lambda call should not call AWS API lambda_handler(lambda_apigw_event, {}) @@ -263,7 +178,7 @@ def test_idempotent_lambda_expired( timestamp_expired, lambda_response, expected_params_update_item, - md5hashed_idempotency_key, + hashed_idempotency_key, ): """ Test idempotent decorator when lambda is called with an event it succesfully handled already, but outside of the @@ -275,7 +190,7 @@ def test_idempotent_lambda_expired( ddb_response = {} ddb_response_get_item = { "Item": { - "id": {"S": md5hashed_idempotency_key}, + "id": {"S": hashed_idempotency_key}, "expiration": {"N": timestamp_expired}, "data": {"S": '{"message": "test", "statusCode": 200}'}, "status": {"S": "INPROGRESS"}, @@ -283,7 +198,7 @@ def test_idempotent_lambda_expired( } expected_params_get_item = { "TableName": TABLE_NAME, - "Key": {"id": md5hashed_idempotency_key}, + "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True, } @@ -303,7 +218,7 @@ def lambda_handler(event, context): # Note - this test will need to change depending on how we define event handling behavior def test_idempotent_lambda_exception( - persistence_store, lambda_apigw_event, timestamp_future, lambda_response, md5hashed_idempotency_key + persistence_store, lambda_apigw_event, timestamp_future, lambda_response, hashed_idempotency_key ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but @@ -319,16 +234,16 @@ def test_idempotent_lambda_exception( ddb_response_get_item = {} expected_params_get_item = { "TableName": TABLE_NAME, - "Key": {"id": md5hashed_idempotency_key}, + "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True, } expected_params_put_item = { "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", "ExpressionAttributeValues": {":now": stub.ANY}, - "Item": {"expiration": stub.ANY, "id": md5hashed_idempotency_key, "status": "INPROGRESS"}, + "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, "TableName": "TEST_TABLE", } - expected_params_delete_item = {"TableName": TABLE_NAME, "Key": {"id": md5hashed_idempotency_key}} + expected_params_delete_item = {"TableName": TABLE_NAME, "Key": {"id": hashed_idempotency_key}} stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) stubber.add_response("put_item", ddb_response, expected_params_put_item) @@ -344,3 +259,47 @@ def lambda_handler(event, context): stubber.assert_no_pending_responses() stubber.deactivate() + + +def test_idempotent_lambda_already_completed_bad_payload( + persistence_store_with_validation, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + hashed_validation_key, +): + """ + Test idempotent decorator where event with matching event key has already been succesfully processed + """ + + stubber = stub.Stubber(persistence_store_with_validation.table.meta.client) + ddb_response = { + "Item": { + "id": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "COMPLETED"}, + "validation": {"S": hashed_validation_key}, + } + } + + expected_params = {"TableName": TABLE_NAME, "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True} + + @idempotent(persistence=persistence_store_with_validation) + def lambda_handler(event, context): + return lambda_response + + stubber.add_response("get_item", ddb_response, expected_params) + stubber.add_response("get_item", copy.deepcopy(ddb_response), copy.deepcopy(expected_params)) + stubber.activate() + + response = lambda_handler(lambda_apigw_event, {}) + assert response == lambda_response + + with pytest.raises(IdempotencyValidationerror): + lambda_apigw_event["requestContext"]["accountId"] += "1" # Alter the request payload + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() From 2a364fd55a9062319b00a9ce7aa5fb09e9929414 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Mon, 18 Jan 2021 21:35:08 +0100 Subject: [PATCH 11/44] chore: remove redundant code for exception handling --- aws_lambda_powertools/utilities/idempotency/idempotency.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index d4c77c3070c..4e75852c50a 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -69,9 +69,6 @@ def idempotent( if event_record.status == STATUS_CONSTANTS["COMPLETED"]: return event_record.response_json_as_dict() - if event_record.status == STATUS_CONSTANTS["ERROR"]: - event_record.raise_stored_exception() - def _call_lambda( handler: Callable, persistence_instance: BasePersistenceLayer, event: Dict[str, Any], context: LambdaContext From 4f5d52b349b4d3bf303c98253b3df41526de1844 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Mon, 18 Jan 2021 21:37:57 +0100 Subject: [PATCH 12/44] feat: add payload validation logic and functionality to use different hash functions from hashlib --- .../utilities/idempotency/exceptions.py | 6 + .../utilities/idempotency/persistence.py | 166 ++++++++++++++---- 2 files changed, 136 insertions(+), 36 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/exceptions.py b/aws_lambda_powertools/utilities/idempotency/exceptions.py index 1e4a88bb8e0..f1c214c0df9 100644 --- a/aws_lambda_powertools/utilities/idempotency/exceptions.py +++ b/aws_lambda_powertools/utilities/idempotency/exceptions.py @@ -25,3 +25,9 @@ class InvalidStatusError(Exception): """ An invalid status was provided """ + + +class IdempotencyValidationerror(Exception): + """ + Payload does not match stored idempotency record + """ diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence.py index 1884a10488b..7d622aca8ad 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence.py @@ -14,7 +14,7 @@ from botocore.config import Config from .cache_dict import LRUDict -from .exceptions import InvalidStatusError, ItemAlreadyExistsError, ItemNotFoundError +from .exceptions import IdempotencyValidationerror, InvalidStatusError, ItemAlreadyExistsError, ItemNotFoundError logger = logging.getLogger(__name__) @@ -33,7 +33,12 @@ class DataRecord: """ def __init__( - self, idempotency_key, status: str = None, expiry_timestamp: int = None, response_data: str = None + self, + idempotency_key, + status: str = None, + expiry_timestamp: int = None, + response_data: str = None, + payload_hash: str = None, ) -> None: """ @@ -45,10 +50,13 @@ def __init__( status of the idempotent record expiry_timestamp: int, optional time before the record should expire, in milliseconds + payload_hash: str, optional + hashed representation of payload response_data: str, optional response data from previous executions using the record """ self.idempotency_key = idempotency_key + self.payload_hash = payload_hash self.expiry_timestamp = expiry_timestamp self._status = status self.response_data = response_data @@ -103,30 +111,45 @@ class BasePersistenceLayer(ABC): """ def __init__( - self, event_key: str, expires_after: int = 3600, use_local_cache: bool = False, local_cache_maxsize: int = 1024, + self, + event_key_jmespath: str, + payload_validation_jmespath: str = "", + expires_after: int = 3600, + use_local_cache: bool = False, + local_cache_maxsize: int = 1024, + hash_function: str = "md5", ) -> None: """ Initialize the base persistence layer Parameters ---------- - event_key: str + event_key_jmespath: str A jmespath expression to extract the idempotency key from the event record + payload_validation_jmespath: str + A jmespath expression to extract the payload to be validated from the event record expires_after: int The number of milliseconds to wait before a record is expired use_local_cache: bool, optional Whether to locally cache idempotency results, by default False local_cache_maxsize: int, optional Max number of items to store in local cache, by default 1024 + hash_function: str, optional + Function to use for calculating hashes, by default md5. """ - self.event_key = event_key - self.event_key_jmespath = jmespath.compile(event_key) + self.event_key = event_key_jmespath + self.event_key_jmespath = jmespath.compile(event_key_jmespath) self.expires_after = expires_after self.use_local_cache = use_local_cache if self.use_local_cache: self._cache = LRUDict(max_size=local_cache_maxsize) + self.payload_validation_enabled = False + if payload_validation_jmespath: + self.validation_key_jmespath = jmespath.compile(payload_validation_jmespath) + self.payload_validation_enabled = True + self.hash_function = hash_function - def get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: + def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ Extract data from lambda event using event key jmespath, and return a hashed representation @@ -138,16 +161,68 @@ def get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: Returns ------- str - md5 hash of the data extracted by the jmespath expression + Hashed representation of the data extracted by the jmespath expression """ data = self.event_key_jmespath.search(lambda_event) + return self._generate_hash(data) + + def _get_hashed_payload(self, lambda_event: Dict[str, Any]) -> str: + """ + Extract data from lambda event using validation key jmespath, and return a hashed representation + + Parameters + ---------- + lambda_event: Dict[str, Any] + Lambda event + + Returns + ------- + str + Hashed representation of the data extracted by the jmespath expression + + """ + if not self.payload_validation_enabled: + return "" + data = self.validation_key_jmespath.search(lambda_event) + return self._generate_hash(data) + + def _generate_hash(self, data: Any) -> str: + """ + Generate a hash value from the provided data - # The following hash is not used in any security context. It is only used - # to generate unique values. - hashed_data = hashlib.md5(json.dumps(data).encode()) # nosec + Parameters + ---------- + data: Any + The data to hash + + Returns + ------- + str + Hashed representation of the provided data + + """ + hash_func = getattr(hashlib, self.hash_function) + hashed_data = hash_func(json.dumps(data).encode()) return hashed_data.hexdigest() + def _validate_payload(self, lambda_event: Dict[str, Any], data_record: DataRecord) -> None: + """ + Validate that the hashed payload matches in the lambda event and stored data record + + Parameters + ---------- + lambda_event: Dict[str, Any] + Lambda event + data_record: DataRecord + DataRecord instance + + """ + if self.payload_validation_enabled: + lambda_payload_hash = self._get_hashed_payload(lambda_event) + if not data_record.payload_hash == lambda_payload_hash: + raise IdempotencyValidationerror("Payload does not match stored record for this event key") + def _get_expiry_timestamp(self) -> int: """ @@ -190,10 +265,11 @@ def save_success(self, event: Dict[str, Any], result: dict) -> None: response_data = json.dumps(result) data_record = DataRecord( - idempotency_key=self.get_hashed_idempotency_key(event), + idempotency_key=self._get_hashed_idempotency_key(event), status=STATUS_CONSTANTS["COMPLETED"], expiry_timestamp=self._get_expiry_timestamp(), response_data=response_data, + payload_hash=self._get_hashed_payload(event), ) logger.debug( f"Lambda successfully executed. Saving record to persistence store with " @@ -214,9 +290,10 @@ def save_inprogress(self, event: Dict[str, Any]) -> None: Lambda event """ data_record = DataRecord( - idempotency_key=self.get_hashed_idempotency_key(event), + idempotency_key=self._get_hashed_idempotency_key(event), status=STATUS_CONSTANTS["INPROGRESS"], expiry_timestamp=self._get_expiry_timestamp(), + payload_hash=self._get_hashed_payload(event), ) logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}") @@ -236,11 +313,7 @@ def save_error(self, event: Dict[str, Any], exception: Exception): exception The exception raised by the lambda handler """ - data_record = DataRecord( - idempotency_key=self.get_hashed_idempotency_key(event), - status=STATUS_CONSTANTS["ERROR"], - expiry_timestamp=self._get_expiry_timestamp(), - ) + data_record = DataRecord(idempotency_key=self._get_hashed_idempotency_key(event)) logger.debug( f"Lambda raised an exception ({type(exception).__name__}). Clearing in progress record in persistence " @@ -271,15 +344,18 @@ def get_record(self, lambda_event) -> DataRecord: Exception raised if no record exists in persistence store with the idempotency key """ - idempotency_key = self.get_hashed_idempotency_key(lambda_event) + idempotency_key = self._get_hashed_idempotency_key(lambda_event) if self.use_local_cache: cached_record = self._retrieve_from_cache(idempotency_key) if cached_record: logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") + self._validate_payload(lambda_event, cached_record) return cached_record - return self._get_record(idempotency_key) + record = self._get_record(idempotency_key) + self._validate_payload(lambda_event, record) + return record @abstractmethod def _get_record(self, idempotency_key) -> DataRecord: @@ -350,6 +426,7 @@ def __init__( expiry_attr: Optional[str] = "expiration", status_attr: Optional[str] = "status", data_attr: Optional[str] = "data", + validation_key_attr: Optional[str] = "validation", boto_config: Optional[Config] = None, *args, **kwargs, @@ -394,6 +471,7 @@ def __init__( self.expiry_attr = expiry_attr self.status_attr = status_attr self.data_attr = data_attr + self.validation_key_attr = validation_key_attr super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs) def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: @@ -416,6 +494,7 @@ def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: status=item[self.status_attr], expiry_timestamp=item[self.expiry_attr], response_data=item.get(self.data_attr), + payload_hash=item.get(self.validation_key_attr), ) def _get_record(self, idempotency_key) -> DataRecord: @@ -428,15 +507,21 @@ def _get_record(self, idempotency_key) -> DataRecord: return self._item_to_data_record(item) def _put_record(self, data_record: DataRecord) -> None: + + item = { + self.key_attr: data_record.idempotency_key, + self.expiry_attr: data_record.expiry_timestamp, + self.status_attr: STATUS_CONSTANTS["INPROGRESS"], + } + + if self.payload_validation_enabled: + item[self.validation_key_attr] = data_record.payload_hash + now = datetime.datetime.now() try: logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}") self.table.put_item( - Item={ - self.key_attr: data_record.idempotency_key, - "expiration": data_record.expiry_timestamp, - "status": STATUS_CONSTANTS["INPROGRESS"], - }, + Item=item, ConditionExpression=f"attribute_not_exists({self.key_attr}) OR expiration < :now", ExpressionAttributeValues={":now": int(now.timestamp())}, ) @@ -446,19 +531,28 @@ def _put_record(self, data_record: DataRecord) -> None: def _update_record(self, data_record: DataRecord): logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") + update_expression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status" + expression_attr_values = { + ":expiry": data_record.expiry_timestamp, + ":response_data": data_record.response_data, + ":status": data_record.status, + } + expression_attr_names = { + "#response_data": self.data_attr, + "#expiry": self.expiry_attr, + "#status": self.status_attr, + } + + if self.payload_validation_enabled: + update_expression += ", #validation_key = :validation_key" + expression_attr_values[":validation_key"] = data_record.payload_hash + expression_attr_names["#validation_key"] = self.validation_key_attr + self.table.update_item( Key={self.key_attr: data_record.idempotency_key}, - UpdateExpression="SET #response_data = :response_data, #expiry = :expiry, #status = :status", - ExpressionAttributeValues={ - ":expiry": data_record.expiry_timestamp, - ":response_data": data_record.response_data, - ":status": data_record.status, - }, - ExpressionAttributeNames={ - "#response_data": self.data_attr, - "#expiry": self.expiry_attr, - "#status": self.status_attr, - }, + UpdateExpression=update_expression, + ExpressionAttributeValues=expression_attr_values, + ExpressionAttributeNames=expression_attr_names, ) def _delete_record(self, data_record: DataRecord) -> None: From a19d95570bd0860c59b47e2eeb2b9cf37516e6d4 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 19 Jan 2021 17:20:42 +0100 Subject: [PATCH 13/44] feat: optimization to reduce number of database calls, reorganize persistence layer modules --- .../utilities/idempotency/__init__.py | 4 +- .../utilities/idempotency/idempotency.py | 51 +++--- .../idempotency/persistence/__init__.py | 0 .../{persistence.py => persistence/base.py} | 166 ++---------------- .../idempotency/persistence/dynamodb.py | 159 +++++++++++++++++ tests/functional/idempotency/conftest.py | 2 +- .../idempotency/test_idempotency.py | 48 ++--- 7 files changed, 223 insertions(+), 207 deletions(-) create mode 100644 aws_lambda_powertools/utilities/idempotency/persistence/__init__.py rename aws_lambda_powertools/utilities/idempotency/{persistence.py => persistence/base.py} (68%) create mode 100644 aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py diff --git a/aws_lambda_powertools/utilities/idempotency/__init__.py b/aws_lambda_powertools/utilities/idempotency/__init__.py index e361b401340..98e2be15415 100644 --- a/aws_lambda_powertools/utilities/idempotency/__init__.py +++ b/aws_lambda_powertools/utilities/idempotency/__init__.py @@ -2,7 +2,9 @@ Utility for adding idempotency to lambda functions """ +from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer +from aws_lambda_powertools.utilities.idempotency.persistence.dynamodb import DynamoDBPersistenceLayer + from .idempotency import idempotent -from .persistence import BasePersistenceLayer, DynamoDBPersistenceLayer __all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent") diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 4e75852c50a..5d8edff5503 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -5,10 +5,10 @@ from typing import Any, Callable, Dict from aws_lambda_powertools.middleware_factory import lambda_handler_decorator +from aws_lambda_powertools.utilities.idempotency.persistence.base import STATUS_CONSTANTS, BasePersistenceLayer from ..typing import LambdaContext -from .exceptions import AlreadyInProgressError, ItemNotFoundError -from .persistence import STATUS_CONSTANTS, BasePersistenceLayer +from .exceptions import AlreadyInProgressError, ItemAlreadyExistsError, ItemNotFoundError logger = logging.getLogger(__name__) @@ -22,7 +22,7 @@ def idempotent( handler: Callable[[Any, LambdaContext], Any], event: Dict[str, Any], context: LambdaContext, - persistence: BasePersistenceLayer, + persistence_store: BasePersistenceLayer, ) -> Any: """ Middleware to handle idempotency @@ -35,7 +35,7 @@ def idempotent( Lambda's Event context: Dict Lambda's Context - persistence: BasePersistenceLayer + persistence_store: BasePersistenceLayer Instance of BasePersistenceLayer to store data Examples @@ -45,33 +45,38 @@ def idempotent( >>> >>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store") >>> - >>> @idempotent(persistence=persistence_store) + >>> @idempotent(persistence_store=persistence_store) >>> def handler(event, context): >>> return {"StatusCode": 200} """ - persistence_instance = persistence try: - event_record = persistence_instance.get_record(event) - except ItemNotFoundError: - persistence_instance.save_inprogress(event=event) - return _call_lambda(handler=handler, persistence_instance=persistence_instance, event=event, context=context) + # We call save_inprogress first as an optimization for the most common case where no idempotent record already + # exists. If it succeeds, there's no need to call get_record. + persistence_store.save_inprogress(event=event) + except ItemAlreadyExistsError: + try: + event_record = persistence_store.get_record(event) + except ItemNotFoundError: + return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context) - if event_record.status == STATUS_CONSTANTS["EXPIRED"]: - return _call_lambda(handler=handler, persistence_instance=persistence_instance, event=event, context=context) + if event_record.status == STATUS_CONSTANTS["EXPIRED"]: + return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context) - if event_record.status == STATUS_CONSTANTS["INPROGRESS"]: - raise AlreadyInProgressError( - f"Execution already in progress with idempotency key: " - f"{persistence_instance.event_key}={event_record.idempotency_key}" - ) + if event_record.status == STATUS_CONSTANTS["INPROGRESS"]: + raise AlreadyInProgressError( + f"Execution already in progress with idempotency key: " + f"{persistence_store.event_key}={event_record.idempotency_key}" + ) - if event_record.status == STATUS_CONSTANTS["COMPLETED"]: - return event_record.response_json_as_dict() + if event_record.status == STATUS_CONSTANTS["COMPLETED"]: + return event_record.response_json_as_dict() + + return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context) def _call_lambda( - handler: Callable, persistence_instance: BasePersistenceLayer, event: Dict[str, Any], context: LambdaContext + handler: Callable, persistence_store: BasePersistenceLayer, event: Dict[str, Any], context: LambdaContext ) -> Any: """ @@ -79,7 +84,7 @@ def _call_lambda( ---------- handler: Callable Lambda handler - persistence_instance: BasePersistenceLayer + persistence_store: BasePersistenceLayer Instance of persistence layer event Lambda event @@ -89,8 +94,8 @@ def _call_lambda( try: handler_response = handler(event, context) except Exception as ex: - persistence_instance.save_error(event=event, exception=ex) + persistence_store.save_error(event=event, exception=ex) raise else: - persistence_instance.save_success(event=event, result=handler_response) + persistence_store.save_success(event=event, result=handler_response) return handler_response diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/__init__.py b/aws_lambda_powertools/utilities/idempotency/persistence/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/aws_lambda_powertools/utilities/idempotency/persistence.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py similarity index 68% rename from aws_lambda_powertools/utilities/idempotency/persistence.py rename to aws_lambda_powertools/utilities/idempotency/persistence/base.py index 7d622aca8ad..2fcdb009dbc 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -7,14 +7,16 @@ import json import logging from abc import ABC, abstractmethod -from typing import Any, Dict, Optional, Union +from typing import Any, Dict -import boto3 import jmespath -from botocore.config import Config -from .cache_dict import LRUDict -from .exceptions import IdempotencyValidationerror, InvalidStatusError, ItemAlreadyExistsError, ItemNotFoundError +from aws_lambda_powertools.utilities.idempotency.cache_dict import LRUDict +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyValidationerror, + InvalidStatusError, + ItemAlreadyExistsError, +) logger = logging.getLogger(__name__) @@ -297,8 +299,16 @@ def save_inprogress(self, event: Dict[str, Any]) -> None: ) logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}") + + if self.use_local_cache: + record = self._retrieve_from_cache(idempotency_key=data_record.idempotency_key) + if record: + raise ItemAlreadyExistsError + self._put_record(data_record) + # This has to come after _put_record. If _put_record call raises ItemAlreadyExists we shouldn't populate the + # cache with an "INPROGRESS" record as we don't know the status in the data store at this point. if self.use_local_cache: self._save_to_cache(data_record) @@ -354,6 +364,10 @@ def get_record(self, lambda_event) -> DataRecord: return cached_record record = self._get_record(idempotency_key) + + if self.use_local_cache: + self._save_to_cache(data_record=record) + self._validate_payload(lambda_event, record) return record @@ -416,145 +430,3 @@ def _delete_record(self, data_record: DataRecord) -> None: """ raise NotImplementedError - - -class DynamoDBPersistenceLayer(BasePersistenceLayer): - def __init__( - self, - table_name: str, # Can we use the lambda function name? - key_attr: Optional[str] = "id", - expiry_attr: Optional[str] = "expiration", - status_attr: Optional[str] = "status", - data_attr: Optional[str] = "data", - validation_key_attr: Optional[str] = "validation", - boto_config: Optional[Config] = None, - *args, - **kwargs, - ): - """ - Initialize the DynamoDB client - - Parameters - ---------- - table_name: str - Name of the table to use for storing execution records - key_attr: str, optional - DynamoDB attribute name for key, by default "id" - expiry_attr: str, optional - DynamoDB attribute name for expiry timestamp, by default "expiration" - status_attr: str, optional - DynamoDB attribute name for status, by default "status" - data_attr: str, optional - DynamoDB attribute name for response data, by default "data" - boto_config: botocore.config.Config, optional - Botocore configuration to pass during client initialization - args - kwargs - - Examples - -------- - **Create a DynamoDB persistence layer with custom settings** - >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer - >>> - >>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store") - >>> - >>> @idempotent(persistence=persistence_store) - >>> def handler(event, context): - >>> return {"StatusCode": 200} - """ - - boto_config = boto_config or Config() - self._ddb_resource = boto3.resource("dynamodb", config=boto_config) - self.table_name = table_name - self.table = self._ddb_resource.Table(self.table_name) - self.key_attr = key_attr - self.expiry_attr = expiry_attr - self.status_attr = status_attr - self.data_attr = data_attr - self.validation_key_attr = validation_key_attr - super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs) - - def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: - """ - Translate raw item records from DynamoDB to DataRecord - - Parameters - ---------- - item: Dict[str, Union[str, int]] - Item format from dynamodb response - - Returns - ------- - DataRecord - representation of item - - """ - return DataRecord( - idempotency_key=item[self.key_attr], - status=item[self.status_attr], - expiry_timestamp=item[self.expiry_attr], - response_data=item.get(self.data_attr), - payload_hash=item.get(self.validation_key_attr), - ) - - def _get_record(self, idempotency_key) -> DataRecord: - response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) - - try: - item = response["Item"] - except KeyError: - raise ItemNotFoundError - return self._item_to_data_record(item) - - def _put_record(self, data_record: DataRecord) -> None: - - item = { - self.key_attr: data_record.idempotency_key, - self.expiry_attr: data_record.expiry_timestamp, - self.status_attr: STATUS_CONSTANTS["INPROGRESS"], - } - - if self.payload_validation_enabled: - item[self.validation_key_attr] = data_record.payload_hash - - now = datetime.datetime.now() - try: - logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}") - self.table.put_item( - Item=item, - ConditionExpression=f"attribute_not_exists({self.key_attr}) OR expiration < :now", - ExpressionAttributeValues={":now": int(now.timestamp())}, - ) - except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: - logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}") - raise ItemAlreadyExistsError - - def _update_record(self, data_record: DataRecord): - logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") - update_expression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status" - expression_attr_values = { - ":expiry": data_record.expiry_timestamp, - ":response_data": data_record.response_data, - ":status": data_record.status, - } - expression_attr_names = { - "#response_data": self.data_attr, - "#expiry": self.expiry_attr, - "#status": self.status_attr, - } - - if self.payload_validation_enabled: - update_expression += ", #validation_key = :validation_key" - expression_attr_values[":validation_key"] = data_record.payload_hash - expression_attr_names["#validation_key"] = self.validation_key_attr - - self.table.update_item( - Key={self.key_attr: data_record.idempotency_key}, - UpdateExpression=update_expression, - ExpressionAttributeValues=expression_attr_values, - ExpressionAttributeNames=expression_attr_names, - ) - - def _delete_record(self, data_record: DataRecord) -> None: - logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}") - self.table.delete_item(Key={self.key_attr: data_record.idempotency_key},) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py new file mode 100644 index 00000000000..63dfbe91dfc --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -0,0 +1,159 @@ +import datetime +import logging +from typing import Dict, Optional, Union + +import boto3 +from botocore.config import Config + +from aws_lambda_powertools.utilities.idempotency import BasePersistenceLayer +from aws_lambda_powertools.utilities.idempotency.exceptions import ItemAlreadyExistsError, ItemNotFoundError +from aws_lambda_powertools.utilities.idempotency.persistence.base import STATUS_CONSTANTS, DataRecord + +logger = logging.getLogger(__name__) + + +class DynamoDBPersistenceLayer(BasePersistenceLayer): + def __init__( + self, + table_name: str, # Can we use the lambda function name? + key_attr: Optional[str] = "id", + expiry_attr: Optional[str] = "expiration", + status_attr: Optional[str] = "status", + data_attr: Optional[str] = "data", + validation_key_attr: Optional[str] = "validation", + boto_config: Optional[Config] = None, + *args, + **kwargs, + ): + """ + Initialize the DynamoDB client + + Parameters + ---------- + table_name: str + Name of the table to use for storing execution records + key_attr: str, optional + DynamoDB attribute name for key, by default "id" + expiry_attr: str, optional + DynamoDB attribute name for expiry timestamp, by default "expiration" + status_attr: str, optional + DynamoDB attribute name for status, by default "status" + data_attr: str, optional + DynamoDB attribute name for response data, by default "data" + boto_config: botocore.config.Config, optional + Botocore configuration to pass during client initialization + args + kwargs + + Examples + -------- + **Create a DynamoDB persistence layer with custom settings** + >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer + >>> + >>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store") + >>> + >>> @idempotent(persistence_store=persistence_store) + >>> def handler(event, context): + >>> return {"StatusCode": 200} + """ + + boto_config = boto_config or Config() + self._ddb_resource = boto3.resource("dynamodb", config=boto_config) + self.table_name = table_name + self.table = self._ddb_resource.Table(self.table_name) + self.key_attr = key_attr + self.expiry_attr = expiry_attr + self.status_attr = status_attr + self.data_attr = data_attr + self.validation_key_attr = validation_key_attr + super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs) + + def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: + """ + Translate raw item records from DynamoDB to DataRecord + + Parameters + ---------- + item: Dict[str, Union[str, int]] + Item format from dynamodb response + + Returns + ------- + DataRecord + representation of item + + """ + return DataRecord( + idempotency_key=item[self.key_attr], + status=item[self.status_attr], + expiry_timestamp=item[self.expiry_attr], + response_data=item.get(self.data_attr), + payload_hash=item.get(self.validation_key_attr), + ) + + def _get_record(self, idempotency_key) -> DataRecord: + response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) + + try: + item = response["Item"] + except KeyError: + raise ItemNotFoundError + return self._item_to_data_record(item) + + def _put_record(self, data_record: DataRecord) -> None: + + item = { + self.key_attr: data_record.idempotency_key, + self.expiry_attr: data_record.expiry_timestamp, + self.status_attr: STATUS_CONSTANTS["INPROGRESS"], + } + + if self.payload_validation_enabled: + item[self.validation_key_attr] = data_record.payload_hash + + now = datetime.datetime.now() + try: + logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}") + self.table.put_item( + Item=item, + ConditionExpression=f"attribute_not_exists({self.key_attr}) OR expiration < :now", + ExpressionAttributeValues={":now": int(now.timestamp())}, + ) + except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: + logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}") + raise ItemAlreadyExistsError + + def _update_record(self, data_record: DataRecord, check_for_existence=False): + logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") + update_expression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status" + expression_attr_values = { + ":expiry": data_record.expiry_timestamp, + ":response_data": data_record.response_data, + ":status": data_record.status, + } + expression_attr_names = { + "#response_data": self.data_attr, + "#expiry": self.expiry_attr, + "#status": self.status_attr, + } + + if self.payload_validation_enabled: + update_expression += ", #validation_key = :validation_key" + expression_attr_values[":validation_key"] = data_record.payload_hash + expression_attr_names["#validation_key"] = self.validation_key_attr + + kwargs = { + "Key": {self.key_attr: data_record.idempotency_key}, + "UpdateExpression": update_expression, + "ExpressionAttributeValues": expression_attr_values, + "ExpressionAttributeNames": expression_attr_names, + } + + if check_for_existence: + kwargs["ConditionExpression"] = "attribute_not_exists(id)" + + self.table.update_item(**kwargs) + + def _delete_record(self, data_record: DataRecord) -> None: + logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}") + self.table.delete_item(Key={self.key_attr: data_record.idempotency_key},) diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index fd136bfe486..1375e1ddc19 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -7,7 +7,7 @@ from botocore import stub from botocore.config import Config -from aws_lambda_powertools.utilities.idempotency.persistence import DynamoDBPersistenceLayer +from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer TABLE_NAME = "TEST_TABLE" diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 5c665b8cea1..0e755cf03f8 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -1,5 +1,3 @@ -import copy - import pytest from botocore import stub @@ -31,10 +29,11 @@ def test_idempotent_lambda_already_completed( "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True, } + stubber.add_client_error("put_item", "ConditionalCheckFailedException") stubber.add_response("get_item", ddb_response, expected_params) stubber.activate() - @idempotent(persistence=persistence_store) + @idempotent(persistence_store=persistence_store) def lambda_handler(event, context): raise Exception @@ -68,10 +67,11 @@ def test_idempotent_lambda_in_progress( } } + stubber.add_client_error("put_item", "ConditionalCheckFailedException") stubber.add_response("get_item", ddb_response, expected_params) stubber.activate() - @idempotent(persistence=persistence_store) + @idempotent(persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -97,24 +97,17 @@ def test_idempotent_lambda_first_execution( stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} - expected_params_get_item = { - "TableName": TABLE_NAME, - "Key": {"id": hashed_idempotency_key}, - "ConsistentRead": True, - } expected_params_put_item = { "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", "ExpressionAttributeValues": {":now": stub.ANY}, "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, "TableName": "TEST_TABLE", } - - stubber.add_response("get_item", ddb_response, expected_params_get_item) stubber.add_response("put_item", ddb_response, expected_params_put_item) stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() - @idempotent(persistence=persistence_store) + @idempotent(persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -139,11 +132,6 @@ def test_idempotent_lambda_first_execution_cached( stubber = stub.Stubber(persistence_store_with_cache.table.meta.client) ddb_response = {} - expected_params_get_item = { - "TableName": TABLE_NAME, - "Key": {"id": hashed_idempotency_key}, - "ConsistentRead": True, - } expected_params_put_item = { "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", "ExpressionAttributeValues": {":now": stub.ANY}, @@ -151,12 +139,11 @@ def test_idempotent_lambda_first_execution_cached( "TableName": "TEST_TABLE", } - stubber.add_response("get_item", ddb_response, expected_params_get_item) stubber.add_response("put_item", ddb_response, expected_params_put_item) stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() - @idempotent(persistence=persistence_store_with_cache) + @idempotent(persistence_store=persistence_store_with_cache) def lambda_handler(event, context): return lambda_response @@ -202,11 +189,12 @@ def test_idempotent_lambda_expired( "ConsistentRead": True, } + stubber.add_client_error("put_item", "ConditionalCheckFailedException") stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() - @idempotent(persistence=persistence_store) + @idempotent(persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -231,12 +219,6 @@ def test_idempotent_lambda_exception( stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} - ddb_response_get_item = {} - expected_params_get_item = { - "TableName": TABLE_NAME, - "Key": {"id": hashed_idempotency_key}, - "ConsistentRead": True, - } expected_params_put_item = { "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", "ExpressionAttributeValues": {":now": stub.ANY}, @@ -245,12 +227,11 @@ def test_idempotent_lambda_exception( } expected_params_delete_item = {"TableName": TABLE_NAME, "Key": {"id": hashed_idempotency_key}} - stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) stubber.add_response("put_item", ddb_response, expected_params_put_item) stubber.add_response("delete_item", ddb_response, expected_params_delete_item) stubber.activate() - @idempotent(persistence=persistence_store) + @idempotent(persistence_store=persistence_store) def lambda_handler(event, context): raise Exception("Something went wrong!") @@ -286,16 +267,13 @@ def test_idempotent_lambda_already_completed_bad_payload( expected_params = {"TableName": TABLE_NAME, "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True} - @idempotent(persistence=persistence_store_with_validation) - def lambda_handler(event, context): - return lambda_response - + stubber.add_client_error("put_item", "ConditionalCheckFailedException") stubber.add_response("get_item", ddb_response, expected_params) - stubber.add_response("get_item", copy.deepcopy(ddb_response), copy.deepcopy(expected_params)) stubber.activate() - response = lambda_handler(lambda_apigw_event, {}) - assert response == lambda_response + @idempotent(persistence_store=persistence_store_with_validation) + def lambda_handler(event, context): + return lambda_response with pytest.raises(IdempotencyValidationerror): lambda_apigw_event["requestContext"]["accountId"] += "1" # Alter the request payload From 0ef52f994a1e594599cdc3384f74529cb356e06a Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Wed, 20 Jan 2021 11:11:07 +0100 Subject: [PATCH 14/44] chore: type corrections --- .../utilities/idempotency/persistence/base.py | 4 ++-- .../idempotency/persistence/dynamodb.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 2fcdb009dbc..62df9bb036d 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -37,9 +37,9 @@ class DataRecord: def __init__( self, idempotency_key, - status: str = None, + status: str = "", expiry_timestamp: int = None, - response_data: str = None, + response_data: str = "", payload_hash: str = None, ) -> None: """ diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index 63dfbe91dfc..b483064bee3 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -1,6 +1,6 @@ import datetime import logging -from typing import Dict, Optional, Union +from typing import Any, Dict, Optional import boto3 from botocore.config import Config @@ -16,11 +16,11 @@ class DynamoDBPersistenceLayer(BasePersistenceLayer): def __init__( self, table_name: str, # Can we use the lambda function name? - key_attr: Optional[str] = "id", - expiry_attr: Optional[str] = "expiration", - status_attr: Optional[str] = "status", - data_attr: Optional[str] = "data", - validation_key_attr: Optional[str] = "validation", + key_attr: str = "id", + expiry_attr: str = "expiration", + status_attr: str = "status", + data_attr: str = "data", + validation_key_attr: str = "validation", boto_config: Optional[Config] = None, *args, **kwargs, @@ -68,7 +68,7 @@ def __init__( self.validation_key_attr = validation_key_attr super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs) - def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: + def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord: """ Translate raw item records from DynamoDB to DataRecord @@ -87,7 +87,7 @@ def _item_to_data_record(self, item: Dict[str, Union[str, int]]) -> DataRecord: idempotency_key=item[self.key_attr], status=item[self.status_attr], expiry_timestamp=item[self.expiry_attr], - response_data=item.get(self.data_attr), + response_data=item[self.data_attr], payload_hash=item.get(self.validation_key_attr), ) From d128b0a10f129edf4a2f596b1af2a66e88260d18 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Thu, 21 Jan 2021 09:33:56 +0100 Subject: [PATCH 15/44] chore: add more logging statements --- .../utilities/idempotency/idempotency.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 5d8edff5503..a5aaf91c02e 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -58,9 +58,17 @@ def idempotent( try: event_record = persistence_store.get_record(event) except ItemNotFoundError: + logger.debug( + "An existing idempotency record was deleted before we could retrieve it. Proceeding with lambda " + "handler" + ) return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context) if event_record.status == STATUS_CONSTANTS["EXPIRED"]: + logger.debug( + f"Record is expired for idempotency key: {event_record.idempotency_key}. Proceeding with lambda " + f"handler" + ) return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context) if event_record.status == STATUS_CONSTANTS["INPROGRESS"]: From 4caa52c07f98378006983e60e9c9556950d8df59 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 26 Jan 2021 09:19:42 +0100 Subject: [PATCH 16/44] fix: Use variable for ddb attribute name --- .../utilities/idempotency/persistence/dynamodb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index b483064bee3..e551c558273 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -116,7 +116,7 @@ def _put_record(self, data_record: DataRecord) -> None: logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}") self.table.put_item( Item=item, - ConditionExpression=f"attribute_not_exists({self.key_attr}) OR expiration < :now", + ConditionExpression=f"attribute_not_exists({self.key_attr}) OR {self.expiry_attr} < :now", ExpressionAttributeValues={":now": int(now.timestamp())}, ) except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: From d89fcee41838932f4dfe0197283afc12d55e3e51 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 26 Jan 2021 14:54:05 +0100 Subject: [PATCH 17/44] chore: clarify docstring for abstract method --- aws_lambda_powertools/utilities/idempotency/persistence/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 62df9bb036d..80a41489935 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -396,7 +396,7 @@ def _get_record(self, idempotency_key) -> DataRecord: def _put_record(self, data_record: DataRecord) -> None: """ Add a DataRecord to persistence store if it does not already exist with that key. Raise ItemAlreadyExists - if an entry already exists. + if a non-expired entry already exists. Parameters ---------- From ed9e0c2175ce110f7ab6bcfe27974c03220ed5c9 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 26 Jan 2021 15:07:59 +0100 Subject: [PATCH 18/44] feat: Refactor to cover corner cases where state changes between calls to db --- .../utilities/idempotency/exceptions.py | 6 ++ .../utilities/idempotency/idempotency.py | 32 +++++++++- .../idempotency/test_idempotency.py | 61 ++++++++++++++++++- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/exceptions.py b/aws_lambda_powertools/utilities/idempotency/exceptions.py index f1c214c0df9..893ce3324e6 100644 --- a/aws_lambda_powertools/utilities/idempotency/exceptions.py +++ b/aws_lambda_powertools/utilities/idempotency/exceptions.py @@ -31,3 +31,9 @@ class IdempotencyValidationerror(Exception): """ Payload does not match stored idempotency record """ + + +class IdempotencyInconsistentStateError(Exception): + """ + State is inconsistent across multiple requests to persistence store + """ diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index a5aaf91c02e..9444a6442a1 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -8,7 +8,12 @@ from aws_lambda_powertools.utilities.idempotency.persistence.base import STATUS_CONSTANTS, BasePersistenceLayer from ..typing import LambdaContext -from .exceptions import AlreadyInProgressError, ItemAlreadyExistsError, ItemNotFoundError +from .exceptions import ( + AlreadyInProgressError, + IdempotencyInconsistentStateError, + ItemAlreadyExistsError, + ItemNotFoundError, +) logger = logging.getLogger(__name__) @@ -50,6 +55,25 @@ def idempotent( >>> return {"StatusCode": 200} """ + # IdempotencyStateError can happen under normal operation when persistent state changes in the small time between + # requests. Retry a few times in case we see inconsistency before raising exception. + max_persistence_layer_attempts = 3 + for i in range(max_persistence_layer_attempts): + try: + return idempotency_handler(handler, event, context, persistence_store) + except IdempotencyInconsistentStateError: + if i < max_persistence_layer_attempts - 1: + continue + else: + raise + + +def idempotency_handler( + handler: Callable[[Any, LambdaContext], Any], + event: Dict[str, Any], + context: LambdaContext, + persistence_store: BasePersistenceLayer, +): try: # We call save_inprogress first as an optimization for the most common case where no idempotent record already # exists. If it succeeds, there's no need to call get_record. @@ -58,18 +82,20 @@ def idempotent( try: event_record = persistence_store.get_record(event) except ItemNotFoundError: + # This code path will only be triggered if the record is removed between save_inprogress and get_record. logger.debug( "An existing idempotency record was deleted before we could retrieve it. Proceeding with lambda " "handler" ) - return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context) + raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") + # This code path will only be triggered if the record becomes expired between the save_inprogress call and here if event_record.status == STATUS_CONSTANTS["EXPIRED"]: logger.debug( f"Record is expired for idempotency key: {event_record.idempotency_key}. Proceeding with lambda " f"handler" ) - return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context) + raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") if event_record.status == STATUS_CONSTANTS["INPROGRESS"]: raise AlreadyInProgressError( diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 0e755cf03f8..89501ab6fb6 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -1,7 +1,13 @@ +import copy + import pytest from botocore import stub -from aws_lambda_powertools.utilities.idempotency.exceptions import AlreadyInProgressError, IdempotencyValidationerror +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + AlreadyInProgressError, + IdempotencyInconsistentStateError, + IdempotencyValidationerror, +) from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent TABLE_NAME = "TEST_TABLE" @@ -281,3 +287,56 @@ def lambda_handler(event, context): stubber.assert_no_pending_responses() stubber.deactivate() + + +def test_idempotent_lambda_expired_during_request( + persistence_store, + lambda_apigw_event, + timestamp_expired, + lambda_response, + expected_params_update_item, + hashed_idempotency_key, +): + """ + Test idempotent decorator when lambda is called with an event it succesfully handled already. Persistence store + returns inconsistent/rapidly changing result between put_item and get_item calls. + """ + + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response_get_item = { + "Item": { + "id": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_expired}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "INPROGRESS"}, + } + } + expected_params_get_item = { + "TableName": TABLE_NAME, + "Key": {"id": hashed_idempotency_key}, + "ConsistentRead": True, + } + + # Record repeatedly changes between put_item and get_item + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", copy.deepcopy(ddb_response_get_item), copy.deepcopy(expected_params_get_item)) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", copy.deepcopy(ddb_response_get_item), copy.deepcopy(expected_params_get_item)) + + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return lambda_response + + # max retries exceeded before get_item and put_item agree on item state, so exception gets raised + with pytest.raises(IdempotencyInconsistentStateError): + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() From 700092753e4e69560408abbaf618d1632aeef54a Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 26 Jan 2021 15:08:35 +0100 Subject: [PATCH 19/44] chore: correct stubbed ddb responses for test case --- .../idempotency/test_idempotency.py | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 89501ab6fb6..c15a879e9c5 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -181,22 +181,15 @@ def test_idempotent_lambda_expired( stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} - ddb_response_get_item = { - "Item": { - "id": {"S": hashed_idempotency_key}, - "expiration": {"N": timestamp_expired}, - "data": {"S": '{"message": "test", "statusCode": 200}'}, - "status": {"S": "INPROGRESS"}, - } - } - expected_params_get_item = { - "TableName": TABLE_NAME, - "Key": {"id": hashed_idempotency_key}, - "ConsistentRead": True, + + expected_params_put_item = { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, + "TableName": "TEST_TABLE", } - stubber.add_client_error("put_item", "ConditionalCheckFailedException") - stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) + stubber.add_response("put_item", ddb_response, expected_params_put_item) stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() From c4856fdd0386460b329711907cf4b5b8466ba31e Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 26 Jan 2021 15:09:05 +0100 Subject: [PATCH 20/44] docs: add first of a few seq diagrams to support documentation --- docs/content/media/idempotency_expired_seq.png | Bin 0 -> 128003 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/content/media/idempotency_expired_seq.png diff --git a/docs/content/media/idempotency_expired_seq.png b/docs/content/media/idempotency_expired_seq.png new file mode 100644 index 0000000000000000000000000000000000000000..aff92704bd0b8a08f0f1c856dad25202998c4f02 GIT binary patch literal 128003 zcmdqJWmJ@H+cvBSI`q&W-5@Q}9TJj)3JB6AptN)iLw9#6sYu5ldRf8*6 zaLvHKGst!DlifQr~V1pfB9vy zB1bWLMfGog{y!hZxQfvRHpSH3|JNnK-%y(6U;F!A{`0lse()94KY9wO0?(DHnbq35l~uae{mmV?iqZ7#O^WCtD$wm&g%3H{M^E*N?!vd7m}S!7y>Q_^O2Y?_GiJ1~ zPp=pb;X&MiG<^mSHKK&B4f*YSI;a;+PJe!JI2Og3Y0TD2Y~l6kZqKk#H^Xk{&n02Y z_o2^QJ%4?xAT0_yG4b8+qq$}`Gr{DymNH&z(XAV27JRnelgz4u8kPC+g*~s}@reYf z-H818Hs<%wWm20DiNwy;^=c!zM^lD+c>M&cekM*;W$*a&9AdVDk6tlH=qdbe8s6$G zKE`*cLte+6EVv4_zlvsUriKynDg5X-L=^_?(gmM17DXuhIUBQUefx;U+!qsJHS@%W zc;Q*=6U0g^pR~Y9R`-P+;_}y4PYib42+o90!=5#s-;)~~) zoYjcfOtnd;2{PiV-M6x=-G=QK6lVS&U)mYs^Udm({I?=xb`h_(r_rD8yXqy{lbZ$a z$63JXT{}eb-B9?LaTU@82C|so%fq6myVNEe9Y+&dc8$l%f1R63=Bs#Fzv`Q9@imI^ z@p%wa%RTEk4u$xHf(XkG+m@ccTDyM>3r2*}(_qkS}D) zqQMuZ;}v>Ek;j_IGONmNqx*V^0b|GS8gkRn4Lu|>aP}nGhRM8r7Zb}cyjyRIW8auW zT^?k0!`n7;`KsG@-#ttp?=-!&?}qf;Nsw5IUa}wL^Zb}CV)ZIPJQgL0Q_J<&VU*KT zM3#O1@SYmDkQAFfAu>XuKBNCn8qKQ*g$V{lREKV?Hx!%ONQUrvUzfWQxr&!=HqV&z zz4Lpy1N_y3C>;6r5=JPEn|E0bVZVbW+}mG$qpw$IbPUJEYsn0;`2H=|B(=Fx{ez^S!!3t&1w(u{fN&3 ztzXC&LO`JPp;R}|0h`I+LZ@^cX_xWv5&j28A2Vb3KB`r33NCGkscWbAg+!3W^R{*A zU1o6?n~^LyW;|M^Ca14-+Wzw_PSL(_d~Os-eHvxktX{izB7=dYmjNgpna zcpiv=ev-eBCT-|N4k-7}1G8RQ-JbGN^ zNRnk_?#DUmy&)X5!qi_RFOrqUI>p+1{QWcE#pTa#(yxU9&jVkWhx|US`&OQJU6zyf z)u&hLQE@_DlKxsE-Bo!a_v}(!{3{Ax#M&IUFaH*lkNPMzPe@pZ>>*XS2taY-5RRQP zU+zWit)=Pp+mpRR=F6O-!&e!Qk)+H_3!mo@M)MA`f};w5F5I^3KlC@le(gJj3b1lU zpvIz@gK57q6M+-`HqlSH+cE;8qCpUet_L9|#Pv &_NH(k$=@RBiC_8^N!4%V-|3 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 literal 0 HcmV?d00001 From aed4a7b6c2d155f0b1a44329c27ffa1fcf8a8a67 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Thu, 28 Jan 2021 10:21:31 +0100 Subject: [PATCH 21/44] feat: use boto3 session for constructing clients to allow customization of credentials --- .../utilities/idempotency/persistence/dynamodb.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index e551c558273..49d710424e3 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -15,13 +15,14 @@ class DynamoDBPersistenceLayer(BasePersistenceLayer): def __init__( self, - table_name: str, # Can we use the lambda function name? + table_name: str, key_attr: str = "id", expiry_attr: str = "expiration", status_attr: str = "status", data_attr: str = "data", validation_key_attr: str = "validation", boto_config: Optional[Config] = None, + boto3_session: Optional[boto3.session.Session] = None, *args, **kwargs, ): @@ -42,6 +43,9 @@ def __init__( DynamoDB attribute name for response data, by default "data" boto_config: botocore.config.Config, optional Botocore configuration to pass during client initialization + boto3_session : boto3.session.Session, optional + Boto3 session to use for AWS API communication + args kwargs @@ -58,7 +62,8 @@ def __init__( """ boto_config = boto_config or Config() - self._ddb_resource = boto3.resource("dynamodb", config=boto_config) + session = boto3_session or boto3.session.Session() + self._ddb_resource = session.resource("dynamodb", config=boto_config) self.table_name = table_name self.table = self._ddb_resource.Table(self.table_name) self.key_attr = key_attr From 2047d3467d52a90021c49a93f201061e05094da3 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Thu, 28 Jan 2021 10:34:38 +0100 Subject: [PATCH 22/44] chore: move cache dict implementation to shared dir --- .../{utilities/idempotency => shared}/cache_dict.py | 0 aws_lambda_powertools/utilities/idempotency/persistence/base.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename aws_lambda_powertools/{utilities/idempotency => shared}/cache_dict.py (100%) diff --git a/aws_lambda_powertools/utilities/idempotency/cache_dict.py b/aws_lambda_powertools/shared/cache_dict.py similarity index 100% rename from aws_lambda_powertools/utilities/idempotency/cache_dict.py rename to aws_lambda_powertools/shared/cache_dict.py diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 80a41489935..9726c8b96a4 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -11,7 +11,7 @@ import jmespath -from aws_lambda_powertools.utilities.idempotency.cache_dict import LRUDict +from aws_lambda_powertools.shared.cache_dict import LRUDict from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyValidationerror, InvalidStatusError, From 8a054cb5a5b7e36433ad1df765a8027e6a3ba9cc Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Thu, 28 Jan 2021 17:37:12 +0100 Subject: [PATCH 23/44] chore: refactor with improvements for readability, variable names, and exception handling --- aws_lambda_powertools/shared/cache_dict.py | 6 +- .../utilities/idempotency/exceptions.py | 18 +- .../utilities/idempotency/idempotency.py | 212 +++++++++++++----- .../utilities/idempotency/persistence/base.py | 66 +++--- .../idempotency/persistence/dynamodb.py | 14 +- .../idempotency/test_idempotency.py | 43 +++- 6 files changed, 250 insertions(+), 109 deletions(-) diff --git a/aws_lambda_powertools/shared/cache_dict.py b/aws_lambda_powertools/shared/cache_dict.py index 3d71a7c406c..79175d7b1a8 100644 --- a/aws_lambda_powertools/shared/cache_dict.py +++ b/aws_lambda_powertools/shared/cache_dict.py @@ -2,8 +2,8 @@ class LRUDict(OrderedDict): - def __init__(self, max_size=1024, *args, **kwds): - self.max_size = max_size + def __init__(self, max_items=1024, *args, **kwds): + self.max_items = max_items super().__init__(*args, **kwds) def __getitem__(self, key): @@ -15,7 +15,7 @@ def __setitem__(self, key, value): if key in self: self.move_to_end(key) super().__setitem__(key, value) - if len(self) > self.max_size: + if len(self) > self.max_items: oldest = next(iter(self)) del self[oldest] diff --git a/aws_lambda_powertools/utilities/idempotency/exceptions.py b/aws_lambda_powertools/utilities/idempotency/exceptions.py index 893ce3324e6..1d7a8acab1f 100644 --- a/aws_lambda_powertools/utilities/idempotency/exceptions.py +++ b/aws_lambda_powertools/utilities/idempotency/exceptions.py @@ -3,31 +3,31 @@ """ -class ItemAlreadyExistsError(Exception): +class IdempotencyItemAlreadyExistsError(Exception): """ - Item attempting to be inserted into persistence store already exists + Item attempting to be inserted into persistence store already exists and is not expired """ -class ItemNotFoundError(Exception): +class IdempotencyItemNotFoundError(Exception): """ Item does not exist in persistence store """ -class AlreadyInProgressError(Exception): +class IdempotencyAlreadyInProgressError(Exception): """ Execution with idempotency key is already in progress """ -class InvalidStatusError(Exception): +class IdempotencyInvalidStatusError(Exception): """ An invalid status was provided """ -class IdempotencyValidationerror(Exception): +class IdempotencyValidationError(Exception): """ Payload does not match stored idempotency record """ @@ -37,3 +37,9 @@ class IdempotencyInconsistentStateError(Exception): """ State is inconsistent across multiple requests to persistence store """ + + +class IdempotencyPersistenceLayerError(Exception): + """ + Unrecoverable error from the data store + """ diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 9444a6442a1..e4bd0ae166d 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -2,18 +2,23 @@ Primary interface for idempotent Lambda functions utility """ import logging -from typing import Any, Callable, Dict +from typing import Any, Callable, Dict, Optional from aws_lambda_powertools.middleware_factory import lambda_handler_decorator -from aws_lambda_powertools.utilities.idempotency.persistence.base import STATUS_CONSTANTS, BasePersistenceLayer - -from ..typing import LambdaContext -from .exceptions import ( - AlreadyInProgressError, +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, - ItemAlreadyExistsError, - ItemNotFoundError, + IdempotencyItemAlreadyExistsError, + IdempotencyItemNotFoundError, + IdempotencyPersistenceLayerError, + IdempotencyValidationError, +) +from aws_lambda_powertools.utilities.idempotency.persistence.base import ( + STATUS_CONSTANTS, + BasePersistenceLayer, + DataRecord, ) +from aws_lambda_powertools.utilities.typing import LambdaContext logger = logging.getLogger(__name__) @@ -55,33 +60,88 @@ def idempotent( >>> return {"StatusCode": 200} """ - # IdempotencyStateError can happen under normal operation when persistent state changes in the small time between - # requests. Retry a few times in case we see inconsistency before raising exception. - max_persistence_layer_attempts = 3 - for i in range(max_persistence_layer_attempts): + idempotency_handler = IdempotencyHandler(handler, event, context, persistence_store) + + # IdempotencyInconsistentStateError can happen under rare but expected cases when persistent state changes in the + # small time between put & get requests. In most cases we can retry successfully on this exception. + max_handler_retries = 2 + for i in range(max_handler_retries + 1): try: - return idempotency_handler(handler, event, context, persistence_store) + return idempotency_handler.handle() except IdempotencyInconsistentStateError: - if i < max_persistence_layer_attempts - 1: + if i < max_handler_retries: continue else: + # Allow the exception to bubble up after max retries exceeded raise -def idempotency_handler( - handler: Callable[[Any, LambdaContext], Any], - event: Dict[str, Any], - context: LambdaContext, - persistence_store: BasePersistenceLayer, -): - try: - # We call save_inprogress first as an optimization for the most common case where no idempotent record already - # exists. If it succeeds, there's no need to call get_record. - persistence_store.save_inprogress(event=event) - except ItemAlreadyExistsError: +class IdempotencyHandler: + """ + Class to orchestrate calls to persistence layer. + """ + + def __init__( + self, + lambda_handler: Callable[[Any, LambdaContext], Any], + event: Dict[str, Any], + context: LambdaContext, + persistence_store: BasePersistenceLayer, + ): + """ + Initialize the IdempotencyHandler + + Parameters + ---------- + lambda_handler : Callable[[Any, LambdaContext], Any] + Lambda function handler + event : Dict[str, Any] + Event payload lambda handler will be called with + context : LambdaContext + Context object which will be passed to lambda handler + persistence_store : BasePersistenceLayer + Instance of persistence layer to store idempotency records + """ + self.persistence_store = persistence_store + self.context = context + self.event = event + self.lambda_handler = lambda_handler + self.max_handler_retries = 2 + self.idempotency_key: Optional[str] = None + + def handle(self) -> Any: + """ + Main entry point for handling idempotent execution of lambda handler. + + Returns + ------- + Any + lambda handler response + + """ + try: + # We call save_inprogress first as an optimization for the most common case where no idempotent record + # already exists. If it succeeds, there's no need to call get_record. + self.persistence_store.save_inprogress(event=self.event) + except IdempotencyItemAlreadyExistsError: + # Now we know the item already exists, we can retrieve it + record = self._get_idempotency_record() + return self._handle_for_status(record) + + return self._call_lambda() + + def _get_idempotency_record(self) -> DataRecord: + """ + Retrieve the idempotency record from the persistence layer. + + Raises + ---------- + IdempotencyInconsistentStateError + + """ try: - event_record = persistence_store.get_record(event) - except ItemNotFoundError: + event_record = self.persistence_store.get_record(self.event) + except IdempotencyItemNotFoundError: # This code path will only be triggered if the record is removed between save_inprogress and get_record. logger.debug( "An existing idempotency record was deleted before we could retrieve it. Proceeding with lambda " @@ -89,47 +149,79 @@ def idempotency_handler( ) raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") + # Allow this exception to bubble up + except IdempotencyValidationError: + raise + + # Wrap remaining unhandled exceptions with IdempotencyPersistenceLayerError to ease exception handling for + # clients + except Exception as exc: + raise IdempotencyPersistenceLayerError("Failed to get record from idempotency store") from exc + + self.idempotency_key = event_record.idempotency_key + return event_record + + def _handle_for_status(self, event_record: DataRecord) -> Optional[Dict[Any, Any]]: + """ + Take appropriate action based on event_record's status + + Parameters + ---------- + event_record: DataRecord + + Returns + ------- + Optional[Dict[Any, Any] + Lambda response previously used for this idempotency key, if it has successfully executed already. + + Raises + ------ + AlreadyInProgressError + A lambda execution is already in progress + IdempotencyInconsistentStateError + The persistence store reports inconsistent states across different requests. Retryable. + """ # This code path will only be triggered if the record becomes expired between the save_inprogress call and here if event_record.status == STATUS_CONSTANTS["EXPIRED"]: - logger.debug( - f"Record is expired for idempotency key: {event_record.idempotency_key}. Proceeding with lambda " - f"handler" - ) raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") if event_record.status == STATUS_CONSTANTS["INPROGRESS"]: - raise AlreadyInProgressError( + raise IdempotencyAlreadyInProgressError( f"Execution already in progress with idempotency key: " - f"{persistence_store.event_key}={event_record.idempotency_key}" + f"{self.persistence_store.event_key_jmespath}={event_record.idempotency_key}" ) - if event_record.status == STATUS_CONSTANTS["COMPLETED"]: - return event_record.response_json_as_dict() + return event_record.response_json_as_dict() - return _call_lambda(handler=handler, persistence_store=persistence_store, event=event, context=context) + def _call_lambda(self) -> Any: + """ + Call the lambda handler function and update the persistence store appropriate depending on the output + Returns + ------- + Any + lambda handler response -def _call_lambda( - handler: Callable, persistence_store: BasePersistenceLayer, event: Dict[str, Any], context: LambdaContext -) -> Any: - """ - - Parameters - ---------- - handler: Callable - Lambda handler - persistence_store: BasePersistenceLayer - Instance of persistence layer - event - Lambda event - context - Lambda context - """ - try: - handler_response = handler(event, context) - except Exception as ex: - persistence_store.save_error(event=event, exception=ex) - raise - else: - persistence_store.save_success(event=event, result=handler_response) - return handler_response + """ + try: + handler_response = self.lambda_handler(self.event, self.context) + except Exception as handler_exception: + # We need these nested blocks to preserve lambda handler exception in case the persistence store operation + # also raises an exception + try: + self.persistence_store.delete_record(event=self.event, exception=handler_exception) + except Exception as delete_exception: + raise IdempotencyPersistenceLayerError( + f"Failed to delete record with idempotency key: {self.idempotency_key}" + ) from delete_exception + raise + + else: + try: + self.persistence_store.save_success(event=self.event, result=handler_response) + except Exception as save_exception: + raise IdempotencyPersistenceLayerError( + f"Failed to update record state to success with " f"idempotency key: {self.idempotency_key}" + ) from save_exception + + return handler_response diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 9726c8b96a4..e89dc1b20d6 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -13,9 +13,9 @@ from aws_lambda_powertools.shared.cache_dict import LRUDict from aws_lambda_powertools.utilities.idempotency.exceptions import ( - IdempotencyValidationerror, - InvalidStatusError, - ItemAlreadyExistsError, + IdempotencyInvalidStatusError, + IdempotencyItemAlreadyExistsError, + IdempotencyValidationError, ) logger = logging.getLogger(__name__) @@ -51,7 +51,7 @@ def __init__( status: str, optional status of the idempotent record expiry_timestamp: int, optional - time before the record should expire, in milliseconds + time before the record should expire, in seconds payload_hash: str, optional hashed representation of payload response_data: str, optional @@ -93,7 +93,7 @@ def status(self) -> str: if self._status in STATUS_CONSTANTS.values(): return self._status else: - raise InvalidStatusError(self._status) + raise IdempotencyInvalidStatusError(self._status) def response_json_as_dict(self) -> dict: """ @@ -116,9 +116,9 @@ def __init__( self, event_key_jmespath: str, payload_validation_jmespath: str = "", - expires_after: int = 3600, + expires_after_seconds: int = 60 * 60, # 1 hour default use_local_cache: bool = False, - local_cache_maxsize: int = 1024, + local_cache_max_items: int = 256, hash_function: str = "md5", ) -> None: """ @@ -130,26 +130,26 @@ def __init__( A jmespath expression to extract the idempotency key from the event record payload_validation_jmespath: str A jmespath expression to extract the payload to be validated from the event record - expires_after: int - The number of milliseconds to wait before a record is expired + expires_after_seconds: int + The number of seconds to wait before a record is expired use_local_cache: bool, optional Whether to locally cache idempotency results, by default False - local_cache_maxsize: int, optional + local_cache_max_items: int, optional Max number of items to store in local cache, by default 1024 hash_function: str, optional Function to use for calculating hashes, by default md5. """ - self.event_key = event_key_jmespath - self.event_key_jmespath = jmespath.compile(event_key_jmespath) - self.expires_after = expires_after + self.event_key_jmespath = event_key_jmespath + self.event_key_compiled_jmespath = jmespath.compile(event_key_jmespath) + self.expires_after_seconds = expires_after_seconds self.use_local_cache = use_local_cache if self.use_local_cache: - self._cache = LRUDict(max_size=local_cache_maxsize) + self._cache = LRUDict(max_items=local_cache_max_items) self.payload_validation_enabled = False if payload_validation_jmespath: self.validation_key_jmespath = jmespath.compile(payload_validation_jmespath) self.payload_validation_enabled = True - self.hash_function = hash_function + self.hash_function = getattr(hashlib, hash_function) def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ @@ -166,7 +166,7 @@ def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: Hashed representation of the data extracted by the jmespath expression """ - data = self.event_key_jmespath.search(lambda_event) + data = self.event_key_compiled_jmespath.search(lambda_event) return self._generate_hash(data) def _get_hashed_payload(self, lambda_event: Dict[str, Any]) -> str: @@ -204,8 +204,7 @@ def _generate_hash(self, data: Any) -> str: Hashed representation of the provided data """ - hash_func = getattr(hashlib, self.hash_function) - hashed_data = hash_func(json.dumps(data).encode()) + hashed_data = self.hash_function(json.dumps(data).encode()) return hashed_data.hexdigest() def _validate_payload(self, lambda_event: Dict[str, Any], data_record: DataRecord) -> None: @@ -219,11 +218,16 @@ def _validate_payload(self, lambda_event: Dict[str, Any], data_record: DataRecor data_record: DataRecord DataRecord instance + Raises + ______ + IdempotencyValidationError + Event payload doesn't match the stored record for the given idempotency key + """ if self.payload_validation_enabled: lambda_payload_hash = self._get_hashed_payload(lambda_event) if not data_record.payload_hash == lambda_payload_hash: - raise IdempotencyValidationerror("Payload does not match stored record for this event key") + raise IdempotencyValidationError("Payload does not match stored record for this event key") def _get_expiry_timestamp(self) -> int: """ @@ -235,7 +239,7 @@ def _get_expiry_timestamp(self) -> int: """ now = datetime.datetime.now() - period = datetime.timedelta(seconds=self.expires_after) + period = datetime.timedelta(seconds=self.expires_after_seconds) return int((now + period).timestamp()) def _save_to_cache(self, data_record: DataRecord): @@ -303,7 +307,7 @@ def save_inprogress(self, event: Dict[str, Any]) -> None: if self.use_local_cache: record = self._retrieve_from_cache(idempotency_key=data_record.idempotency_key) if record: - raise ItemAlreadyExistsError + raise IdempotencyItemAlreadyExistsError self._put_record(data_record) @@ -312,9 +316,9 @@ def save_inprogress(self, event: Dict[str, Any]) -> None: if self.use_local_cache: self._save_to_cache(data_record) - def save_error(self, event: Dict[str, Any], exception: Exception): + def delete_record(self, event: Dict[str, Any], exception: Exception): """ - Save record of lambda handler raising an exception + Delete record from the persistence store Parameters ---------- @@ -334,14 +338,14 @@ def save_error(self, event: Dict[str, Any], exception: Exception): if self.use_local_cache: self._delete_from_cache(data_record.idempotency_key) - def get_record(self, lambda_event) -> DataRecord: + def get_record(self, event: Dict[str, Any]) -> DataRecord: """ Calculate idempotency key for lambda_event, then retrieve item from persistence store using idempotency key and return it as a DataRecord instance.and return it as a DataRecord instance. Parameters ---------- - lambda_event: Dict[str, Any] + event: Dict[str, Any] Returns ------- @@ -350,17 +354,19 @@ def get_record(self, lambda_event) -> DataRecord: Raises ------ - ItemNotFound + IdempotencyItemNotFoundError Exception raised if no record exists in persistence store with the idempotency key + IdempotencyValidationError + Event payload doesn't match the stored record for the given idempotency key """ - idempotency_key = self._get_hashed_idempotency_key(lambda_event) + idempotency_key = self._get_hashed_idempotency_key(event) if self.use_local_cache: cached_record = self._retrieve_from_cache(idempotency_key) if cached_record: logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") - self._validate_payload(lambda_event, cached_record) + self._validate_payload(event, cached_record) return cached_record record = self._get_record(idempotency_key) @@ -368,7 +374,7 @@ def get_record(self, lambda_event) -> DataRecord: if self.use_local_cache: self._save_to_cache(data_record=record) - self._validate_payload(lambda_event, record) + self._validate_payload(event, record) return record @abstractmethod @@ -387,7 +393,7 @@ def _get_record(self, idempotency_key) -> DataRecord: Raises ------ - ItemNotFound + IdempotencyItemNotFoundError Exception raised if no record exists in persistence store with the idempotency key """ raise NotImplementedError diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index 49d710424e3..cc7eb9f20bb 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -6,8 +6,11 @@ from botocore.config import Config from aws_lambda_powertools.utilities.idempotency import BasePersistenceLayer -from aws_lambda_powertools.utilities.idempotency.exceptions import ItemAlreadyExistsError, ItemNotFoundError -from aws_lambda_powertools.utilities.idempotency.persistence.base import STATUS_CONSTANTS, DataRecord +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyItemAlreadyExistsError, + IdempotencyItemNotFoundError, +) +from aws_lambda_powertools.utilities.idempotency.persistence.base import DataRecord logger = logging.getLogger(__name__) @@ -102,15 +105,14 @@ def _get_record(self, idempotency_key) -> DataRecord: try: item = response["Item"] except KeyError: - raise ItemNotFoundError + raise IdempotencyItemNotFoundError return self._item_to_data_record(item) def _put_record(self, data_record: DataRecord) -> None: - item = { self.key_attr: data_record.idempotency_key, self.expiry_attr: data_record.expiry_timestamp, - self.status_attr: STATUS_CONSTANTS["INPROGRESS"], + self.status_attr: data_record.status, } if self.payload_validation_enabled: @@ -126,7 +128,7 @@ def _put_record(self, data_record: DataRecord) -> None: ) except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}") - raise ItemAlreadyExistsError + raise IdempotencyItemAlreadyExistsError def _update_record(self, data_record: DataRecord, check_for_existence=False): logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index c15a879e9c5..c45d1ce23c6 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -4,9 +4,10 @@ from botocore import stub from aws_lambda_powertools.utilities.idempotency.exceptions import ( - AlreadyInProgressError, + IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, - IdempotencyValidationerror, + IdempotencyPersistenceLayerError, + IdempotencyValidationError, ) from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent @@ -81,7 +82,7 @@ def test_idempotent_lambda_in_progress( def lambda_handler(event, context): return lambda_response - with pytest.raises(AlreadyInProgressError) as ex: + with pytest.raises(IdempotencyAlreadyInProgressError) as ex: lambda_handler(lambda_apigw_event, {}) assert ( ex.value.args[0] == "Execution already in progress with idempotency key: " @@ -274,7 +275,7 @@ def test_idempotent_lambda_already_completed_bad_payload( def lambda_handler(event, context): return lambda_response - with pytest.raises(IdempotencyValidationerror): + with pytest.raises(IdempotencyValidationError): lambda_apigw_event["requestContext"]["accountId"] += "1" # Alter the request payload lambda_handler(lambda_apigw_event, {}) @@ -333,3 +334,37 @@ def lambda_handler(event, context): stubber.assert_no_pending_responses() stubber.deactivate() + + +def test_idempotent_persistence_exception( + persistence_store, lambda_apigw_event, timestamp_future, lambda_response, hashed_idempotency_key +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception which is retryable. + """ + + # Stub the boto3 client + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response = {} + expected_params_put_item = { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, + "TableName": "TEST_TABLE", + } + + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_client_error("delete_item", "UnrecoverableError") + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + raise Exception("Something went wrong!") + + with pytest.raises(IdempotencyPersistenceLayerError): + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() From 523535fc747b2d70e975130e68e3eccf5816c0d2 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 9 Feb 2021 17:18:43 +0100 Subject: [PATCH 24/44] chore: remove dead code, rename variable for clarity, change args to kwargs in function call --- .../utilities/idempotency/persistence/base.py | 6 +++--- .../utilities/idempotency/persistence/dynamodb.py | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index e89dc1b20d6..84e74f81832 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -305,8 +305,8 @@ def save_inprogress(self, event: Dict[str, Any]) -> None: logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}") if self.use_local_cache: - record = self._retrieve_from_cache(idempotency_key=data_record.idempotency_key) - if record: + cached_record = self._retrieve_from_cache(idempotency_key=data_record.idempotency_key) + if cached_record: raise IdempotencyItemAlreadyExistsError self._put_record(data_record) @@ -363,7 +363,7 @@ def get_record(self, event: Dict[str, Any]) -> DataRecord: idempotency_key = self._get_hashed_idempotency_key(event) if self.use_local_cache: - cached_record = self._retrieve_from_cache(idempotency_key) + cached_record = self._retrieve_from_cache(idempotency_key=idempotency_key) if cached_record: logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") self._validate_payload(event, cached_record) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index cc7eb9f20bb..8d5cef68f34 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -130,7 +130,7 @@ def _put_record(self, data_record: DataRecord) -> None: logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}") raise IdempotencyItemAlreadyExistsError - def _update_record(self, data_record: DataRecord, check_for_existence=False): + def _update_record(self, data_record: DataRecord): logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") update_expression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status" expression_attr_values = { @@ -156,9 +156,6 @@ def _update_record(self, data_record: DataRecord, check_for_existence=False): "ExpressionAttributeNames": expression_attr_names, } - if check_for_existence: - kwargs["ConditionExpression"] = "attribute_not_exists(id)" - self.table.update_item(**kwargs) def _delete_record(self, data_record: DataRecord) -> None: From 834db1c37063659082ad7a28b2f8797f9ea8f914 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 9 Feb 2021 17:20:39 +0100 Subject: [PATCH 25/44] chore: improve test coverage, refactor fixtures --- tests/functional/idempotency/conftest.py | 65 +++++- .../idempotency/test_idempotency.py | 220 ++++++++++++++---- 2 files changed, 227 insertions(+), 58 deletions(-) diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 1375e1ddc19..1ac37565684 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -58,6 +58,54 @@ def expected_params_update_item(lambda_response, hashed_idempotency_key): } +@pytest.fixture +def expected_params_update_item_with_validation(lambda_response, hashed_idempotency_key, hashed_validation_key): + return { + "ExpressionAttributeNames": { + "#expiry": "expiration", + "#response_data": "data", + "#status": "status", + "#validation_key": "validation", + }, + "ExpressionAttributeValues": { + ":expiry": stub.ANY, + ":response_data": json.dumps(lambda_response), + ":status": "COMPLETED", + ":validation_key": hashed_validation_key, + }, + "Key": {"id": hashed_idempotency_key}, + "TableName": "TEST_TABLE", + "UpdateExpression": "SET #response_data = :response_data, " + "#expiry = :expiry, #status = :status, " + "#validation_key = :validation_key", + } + + +@pytest.fixture +def expected_params_put_item(hashed_idempotency_key): + return { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, + "TableName": "TEST_TABLE", + } + + +@pytest.fixture +def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_validation_key): + return { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": { + "expiration": stub.ANY, + "id": hashed_idempotency_key, + "status": "INPROGRESS", + "validation": hashed_validation_key, + }, + "TableName": "TEST_TABLE", + } + + @pytest.fixture def hashed_idempotency_key(lambda_apigw_event): return hashlib.md5(json.dumps(lambda_apigw_event["body"]).encode()).hexdigest() @@ -69,26 +117,23 @@ def hashed_validation_key(lambda_apigw_event): @pytest.fixture -def persistence_store(config): - persistence_store = DynamoDBPersistenceLayer(event_key_jmespath="body", table_name=TABLE_NAME, boto_config=config) - return persistence_store - - -@pytest.fixture -def persistence_store_with_cache(config): +def persistence_store(config, request): persistence_store = DynamoDBPersistenceLayer( - event_key_jmespath="body", table_name=TABLE_NAME, boto_config=config, use_local_cache=True + event_key_jmespath="body", + table_name=TABLE_NAME, + boto_config=config, + use_local_cache=request.param["use_local_cache"], ) return persistence_store @pytest.fixture -def persistence_store_with_validation(config): +def persistence_store_with_validation(config, request): persistence_store = DynamoDBPersistenceLayer( event_key_jmespath="body", table_name=TABLE_NAME, boto_config=config, - use_local_cache=True, + use_local_cache=request.param, payload_validation_jmespath="requestContext", ) return persistence_store diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index c45d1ce23c6..7e60c478c59 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -14,6 +14,9 @@ TABLE_NAME = "TEST_TABLE" +# Using parametrize to run test twice, with two separate instances of persistence store. One instance with caching +# enabled, and one without. +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_already_completed( persistence_store, lambda_apigw_event, timestamp_future, lambda_response, hashed_idempotency_key, ): @@ -51,6 +54,7 @@ def lambda_handler(event, context): stubber.deactivate() +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_in_progress( persistence_store, lambda_apigw_event, lambda_response, timestamp_future, hashed_idempotency_key ): @@ -88,14 +92,75 @@ def lambda_handler(event, context): ex.value.args[0] == "Execution already in progress with idempotency key: " "body=a3edd699125517bb49d562501179ecbd" ) - print(ex) stubber.assert_no_pending_responses() stubber.deactivate() +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_in_progress_with_cache( + persistence_store, lambda_apigw_event, lambda_response, timestamp_future, hashed_idempotency_key, mocker +): + """ + Test idempotent decorator where lambda_handler is already processing an event with matching event key, cache + enabled. + """ + save_to_cache_spy = mocker.spy(persistence_store, "_save_to_cache") + retrieve_from_cache_spy = mocker.spy(persistence_store, "_retrieve_from_cache") + stubber = stub.Stubber(persistence_store.table.meta.client) + + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": hashed_idempotency_key}, + "ConsistentRead": True, + } + ddb_response = { + "Item": { + "id": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "INPROGRESS"}, + } + } + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return lambda_response + + loops = 3 + for _ in range(loops): + with pytest.raises(IdempotencyAlreadyInProgressError) as ex: + lambda_handler(lambda_apigw_event, {}) + assert ( + ex.value.args[0] == "Execution already in progress with idempotency key: " + "body=a3edd699125517bb49d562501179ecbd" + ) + + assert retrieve_from_cache_spy.call_count == 2 * loops + retrieve_from_cache_spy.assert_called_with(idempotency_key=hashed_idempotency_key) + + assert save_to_cache_spy.call_count == 1 + first_call_args_data_record = save_to_cache_spy.call_args_list[0].kwargs["data_record"] + assert first_call_args_data_record.idempotency_key == hashed_idempotency_key + assert first_call_args_data_record.status == "INPROGRESS" + assert persistence_store._cache.get(hashed_idempotency_key) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_first_execution( - persistence_store, lambda_apigw_event, expected_params_update_item, lambda_response, hashed_idempotency_key + persistence_store, + lambda_apigw_event, + expected_params_update_item, + expected_params_put_item, + lambda_response, + hashed_idempotency_key, ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key @@ -104,12 +169,6 @@ def test_idempotent_lambda_first_execution( stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} - expected_params_put_item = { - "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", - "ExpressionAttributeValues": {":now": stub.ANY}, - "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, - "TableName": "TEST_TABLE", - } stubber.add_response("put_item", ddb_response, expected_params_put_item) stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() @@ -124,54 +183,60 @@ def lambda_handler(event, context): stubber.deactivate() +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) def test_idempotent_lambda_first_execution_cached( - persistence_store_with_cache, + persistence_store, lambda_apigw_event, expected_params_update_item, + expected_params_put_item, lambda_response, hashed_idempotency_key, + mocker, ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key. Ensure result is cached locally on the persistence store instance. """ - - stubber = stub.Stubber(persistence_store_with_cache.table.meta.client) + save_to_cache_spy = mocker.spy(persistence_store, "_save_to_cache") + retrieve_from_cache_spy = mocker.spy(persistence_store, "_retrieve_from_cache") + stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} - expected_params_put_item = { - "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", - "ExpressionAttributeValues": {":now": stub.ANY}, - "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, - "TableName": "TEST_TABLE", - } - stubber.add_response("put_item", ddb_response, expected_params_put_item) stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() - @idempotent(persistence_store=persistence_store_with_cache) + @idempotent(persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response lambda_handler(lambda_apigw_event, {}) - assert persistence_store_with_cache._cache.get(hashed_idempotency_key) + assert retrieve_from_cache_spy.call_count == 1 + assert save_to_cache_spy.call_count == 2 + first_call_args, second_call_args = save_to_cache_spy.call_args_list + assert first_call_args.args[0].status == "INPROGRESS" + assert second_call_args.args[0].status == "COMPLETED" + assert persistence_store._cache.get(hashed_idempotency_key) # This lambda call should not call AWS API lambda_handler(lambda_apigw_event, {}) + assert retrieve_from_cache_spy.call_count == 3 + retrieve_from_cache_spy.assert_called_with(idempotency_key=hashed_idempotency_key) # This assertion fails if an AWS API operation was called more than once stubber.assert_no_pending_responses() stubber.deactivate() +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_expired( persistence_store, lambda_apigw_event, timestamp_expired, lambda_response, expected_params_update_item, + expected_params_put_item, hashed_idempotency_key, ): """ @@ -183,13 +248,6 @@ def test_idempotent_lambda_expired( ddb_response = {} - expected_params_put_item = { - "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", - "ExpressionAttributeValues": {":now": stub.ANY}, - "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, - "TableName": "TEST_TABLE", - } - stubber.add_response("put_item", ddb_response, expected_params_put_item) stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() @@ -204,9 +262,14 @@ def lambda_handler(event, context): stubber.deactivate() -# Note - this test will need to change depending on how we define event handling behavior +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_exception( - persistence_store, lambda_apigw_event, timestamp_future, lambda_response, hashed_idempotency_key + persistence_store, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + expected_params_put_item, ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but @@ -219,12 +282,6 @@ def test_idempotent_lambda_exception( stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} - expected_params_put_item = { - "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", - "ExpressionAttributeValues": {":now": stub.ANY}, - "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, - "TableName": "TEST_TABLE", - } expected_params_delete_item = {"TableName": TABLE_NAME, "Key": {"id": hashed_idempotency_key}} stubber.add_response("put_item", ddb_response, expected_params_put_item) @@ -242,7 +299,10 @@ def lambda_handler(event, context): stubber.deactivate() -def test_idempotent_lambda_already_completed_bad_payload( +@pytest.mark.parametrize( + "persistence_store_with_validation", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True +) +def test_idempotent_lambda_already_completed_with_validation_bad_payload( persistence_store_with_validation, lambda_apigw_event, timestamp_future, @@ -283,6 +343,7 @@ def lambda_handler(event, context): stubber.deactivate() +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_expired_during_request( persistence_store, lambda_apigw_event, @@ -306,18 +367,19 @@ def test_idempotent_lambda_expired_during_request( "status": {"S": "INPROGRESS"}, } } + ddb_response_get_item_missing = {} expected_params_get_item = { "TableName": TABLE_NAME, "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True, } - # Record repeatedly changes between put_item and get_item + # Simulate record repeatedly changing state between put_item and get_item stubber.add_client_error("put_item", "ConditionalCheckFailedException") stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) stubber.add_client_error("put_item", "ConditionalCheckFailedException") - stubber.add_response("get_item", copy.deepcopy(ddb_response_get_item), copy.deepcopy(expected_params_get_item)) + stubber.add_response("get_item", ddb_response_get_item_missing) stubber.add_client_error("put_item", "ConditionalCheckFailedException") stubber.add_response("get_item", copy.deepcopy(ddb_response_get_item), copy.deepcopy(expected_params_get_item)) @@ -336,24 +398,22 @@ def lambda_handler(event, context): stubber.deactivate() -def test_idempotent_persistence_exception( - persistence_store, lambda_apigw_event, timestamp_future, lambda_response, hashed_idempotency_key +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_persistence_exception_updating( + persistence_store, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + expected_params_put_item, ): """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but lambda_handler raises an exception which is retryable. """ - - # Stub the boto3 client stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} - expected_params_put_item = { - "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", - "ExpressionAttributeValues": {":now": stub.ANY}, - "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, - "TableName": "TEST_TABLE", - } stubber.add_response("put_item", ddb_response, expected_params_put_item) stubber.add_client_error("delete_item", "UnrecoverableError") @@ -368,3 +428,67 @@ def lambda_handler(event, context): stubber.assert_no_pending_responses() stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_persistence_exception_deleting( + persistence_store, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + expected_params_put_item, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception which is retryable. + """ + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response = {} + + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_client_error("update_item", "UnrecoverableError") + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return {"message": "success!"} + + with pytest.raises(IdempotencyPersistenceLayerError): + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize( + "persistence_store_with_validation", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True +) +def test_idempotent_lambda_first_execution_with_validation( + persistence_store_with_validation, + lambda_apigw_event, + expected_params_update_item_with_validation, + expected_params_put_item_with_validation, + lambda_response, + hashed_idempotency_key, + hashed_validation_key, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key + """ + stubber = stub.Stubber(persistence_store_with_validation.table.meta.client) + ddb_response = {} + + stubber.add_response("put_item", ddb_response, expected_params_put_item_with_validation) + stubber.add_response("update_item", ddb_response, expected_params_update_item_with_validation) + stubber.activate() + + @idempotent(persistence_store=persistence_store_with_validation) + def lambda_handler(lambda_apigw_event, context): + return lambda_response + + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() From 41d559e3eeaa4ccdad7daaaacf831d64e9d53ed4 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Tue, 9 Feb 2021 18:07:41 +0100 Subject: [PATCH 26/44] chore: skip tests using pytest-mock's spy for python < 3.8 due to issues with lib --- tests/functional/idempotency/test_idempotency.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 7e60c478c59..b4f1a38acba 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -1,4 +1,5 @@ import copy +import sys import pytest from botocore import stub @@ -97,6 +98,7 @@ def lambda_handler(event, context): stubber.deactivate() +@pytest.mark.skipif(sys.version_info < (3, 8), reason="issue with pytest mock lib for < 3.8") @pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) def test_idempotent_lambda_in_progress_with_cache( persistence_store, lambda_apigw_event, lambda_response, timestamp_future, hashed_idempotency_key, mocker @@ -183,6 +185,7 @@ def lambda_handler(event, context): stubber.deactivate() +@pytest.mark.skipif(sys.version_info < (3, 8), reason="issue with pytest mock lib for < 3.8") @pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) def test_idempotent_lambda_first_execution_cached( persistence_store, From b4490b93954020d35051cd6e99e48f1040fabb13 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 12 Feb 2021 17:47:17 +0100 Subject: [PATCH 27/44] chore: update test fixtures to use jmespath --- tests/functional/idempotency/conftest.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 1ac37565684..174bb49a2b9 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -3,6 +3,7 @@ import json import os +import jmespath import pytest from botocore import stub from botocore.config import Config @@ -43,6 +44,11 @@ def lambda_response(): return {"message": "test", "statusCode": 200} +@pytest.fixture +def default_jmespath(): + return "[body, queryStringParameters]" + + @pytest.fixture def expected_params_update_item(lambda_response, hashed_idempotency_key): return { @@ -107,8 +113,10 @@ def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_vali @pytest.fixture -def hashed_idempotency_key(lambda_apigw_event): - return hashlib.md5(json.dumps(lambda_apigw_event["body"]).encode()).hexdigest() +def hashed_idempotency_key(lambda_apigw_event, default_jmespath): + compiled_jmespath = jmespath.compile(default_jmespath) + data = compiled_jmespath.search(lambda_apigw_event) + return hashlib.md5(json.dumps(data).encode()).hexdigest() @pytest.fixture @@ -117,9 +125,9 @@ def hashed_validation_key(lambda_apigw_event): @pytest.fixture -def persistence_store(config, request): +def persistence_store(config, request, default_jmespath): persistence_store = DynamoDBPersistenceLayer( - event_key_jmespath="body", + event_key_jmespath=default_jmespath, table_name=TABLE_NAME, boto_config=config, use_local_cache=request.param["use_local_cache"], @@ -128,9 +136,9 @@ def persistence_store(config, request): @pytest.fixture -def persistence_store_with_validation(config, request): +def persistence_store_with_validation(config, request, default_jmespath): persistence_store = DynamoDBPersistenceLayer( - event_key_jmespath="body", + event_key_jmespath=default_jmespath, table_name=TABLE_NAME, boto_config=config, use_local_cache=request.param, From 43b72e73d8d11ccd820cedf1348e2883e411c2fd Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Sat, 13 Feb 2021 18:21:50 +0100 Subject: [PATCH 28/44] docs: first draft of docs for idempotency util --- docs/diagram_src/idempotent_sequence.puml | 29 ++ .../idempotent_sequence_exception.puml | 18 + docs/index.md | 1 + docs/media/idempotent_sequence.png | Bin 0 -> 74622 bytes docs/media/idempotent_sequence_exception.png | Bin 0 -> 46647 bytes docs/utilities/idempotency.md | 323 ++++++++++++++++++ mkdocs.yml | 1 + 7 files changed, 372 insertions(+) create mode 100644 docs/diagram_src/idempotent_sequence.puml create mode 100644 docs/diagram_src/idempotent_sequence_exception.puml create mode 100644 docs/media/idempotent_sequence.png create mode 100644 docs/media/idempotent_sequence_exception.png create mode 100644 docs/utilities/idempotency.md diff --git a/docs/diagram_src/idempotent_sequence.puml b/docs/diagram_src/idempotent_sequence.puml new file mode 100644 index 00000000000..76c85942796 --- /dev/null +++ b/docs/diagram_src/idempotent_sequence.puml @@ -0,0 +1,29 @@ +@startuml +'https://plantuml.com/sequence-diagram + +participant Client +participant Lambda +participant "Persistence layer" + + +group initial request +Client->Lambda:Invoke (event) +Lambda->"Persistence layer":Get or set (id=event.search(payload)) +activate "Persistence layer" +note right of "Persistence layer":Locked during this time. Prevents \nmultiple Lambda invocations with the \nsame payload running concurrently. +Lambda-->Lambda:Run Lambda handler (event) +Lambda->"Persistence layer":Update record with Lambda handler result¹ +deactivate "Persistence layer" +"Persistence layer"-->"Persistence layer": Update record with result¹ +Client x<--Lambda:Response not received by client +end + +group retried request + +Client->Lambda: Invoke (event) +Lambda->"Persistence layer":Get or set (id=event.search(payload)) +Lambda<--"Persistence layer":Already exists in persistence layer. Return result¹ +Client<--Lambda:Response sent to client +end + +@enduml diff --git a/docs/diagram_src/idempotent_sequence_exception.puml b/docs/diagram_src/idempotent_sequence_exception.puml new file mode 100644 index 00000000000..7470cdd1c4e --- /dev/null +++ b/docs/diagram_src/idempotent_sequence_exception.puml @@ -0,0 +1,18 @@ +@startuml +'https://plantuml.com/sequence-diagram + +participant Client +participant Lambda +participant "Persistence layer" + + +Client->Lambda:Invoke (event) +Lambda->"Persistence layer":Get or set (id=event.search(payload)) +activate "Persistence layer" +note right of "Persistence layer":Locked during this time. Prevents \nmultiple Lambda invocations with the \nsame payload running concurrently. +Lambda-->x Lambda:Run Lambda handler (event). Raises Exception. +Lambda->"Persistence layer":Delete record (id=event.search(payload)) +deactivate "Persistence layer" +Client<--Lambda:Return error response + +@enduml diff --git a/docs/index.md b/docs/index.md index 2415a668ec1..cddf3182695 100644 --- a/docs/index.md +++ b/docs/index.md @@ -152,6 +152,7 @@ aws serverlessrepo list-application-versions \ | [Validation](./utilities/validation) | JSON Schema validator for inbound events and responses | [Event source data classes](./utilities/data_classes) | Data classes describing the schema of common Lambda event triggers | [Parser](./utilities/parser) | Data parsing and deep validation using Pydantic +| [Idempotency](./utilities/idempotency) | Idempotent Lambda handler ## Environment variables diff --git a/docs/media/idempotent_sequence.png b/docs/media/idempotent_sequence.png new file mode 100644 index 0000000000000000000000000000000000000000..92593184abbfb79cc673af07bfbb019bf6c06812 GIT binary patch literal 74622 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�W*NJiVcf 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 literal 0 HcmV?d00001 diff --git a/docs/media/idempotent_sequence_exception.png b/docs/media/idempotent_sequence_exception.png new file mode 100644 index 0000000000000000000000000000000000000000..4cf065993dd0ca3818941e1e80af17f4f7ed56f7 GIT binary patch literal 46647 zcmce;WmHvN_dcwkba%IibazOHfPgegBMs6iNF6$*yHh|~N#W2U-3UmBbT{t;@B4Y~ z=T~FAU;bYlj&t_eYp=OxT-P<{CRj;P8Wo8M>A`~ssIoE=Di0n!5`FOC;Sd50_yp+< ztLcLWA0Eg`h^o5kZl%Gy;&c#qvQP$K#0(;GWu4AhKCeHbYC4@udA#sa`C%S@G&7T< zWDymeuO?1T$ikPeU1|Nee50ep|ucxP;FVkmi-+Riq z&*na-YzjT?{3c7q%G|mBghq6MQKHHy)d!IcMx7I-Iioc{l{q{Urv@&IB&Y-O*7|Q6mK}Geo?F85y_6OLZ0&7B&|sf)zs|zkMOd zNr;m8P=)cM*ba`iotL*9GBs?t9ziN_vOVLnK1kUoVLFiB!RdH=^Zxi zLoZ~gzG(%$9piafLfUe>R(?R>(p9JeRWuC^@C?aO%w6H0c-sq3u(=K-tCQJ znE0esWj;bHFCK_S%K!f2sK#<)6e^09hC}XS$0o%>8T`PMGFfd2MLx*xods%DFHC+G zlTPi{OdSgi4GklsybuY^knBDt0lv`@Rt&;BylgG5{%`&S@M~kNtqO2(@bE@x+F$Kg zE{=b@Y)#q$a|{kD{cl?-G~k6NjQAf`&V($G81t%lKI-fsX!56lNewVl59eT|?DQdm zI7BVT{159}5cG>esQ(9hVZDaFuCKOS>Ak()gBDC@=d$;U!aen(X7#d$LePb;9gD|z z2b-^*`I-?l$~^O!LZaE+5oI5R2_olNap?DFa^7t(@QX#_;9%NF0lMXUvuLUIiUFwr zxBKaA^Gw}<`_a0GvcXJkq&)4&U>4~z{pAT$CyLWrf8F#rRu|YIi4bPcnJ5lreYGT_ zg;u4pOxm>gZx^#P{-y|)HXqmwvrt3hJI8z|RE4qion_5{DyG)td!YBtd0wq{;|w%V z`t#Q`fi=s}mWIS0RuZK?y&C<_(vo#0g#*{N z=V+lam#bphbgngrU6?~u>kzh25V?f%P_|nQc#Kh2e_mMkx0IS=#d+;7(_`NL@Xcha zj$0bKxv=EnRxQ;ww^p|2vF_KWyLW}4CdkH29WHJ1hYwbI?~f}Sg}uGD3NpqOQzv(q zd+@O&1;|UaIB;>x-prI+b1*w^443IPY4@cIY3tnETl(cA6(}pPEALUCSTqY4ir4DR z)!}yz)7tk}`vQa<7p)vNuU)KHT&q1!o1p{il_uCVlIqsm)8?}cJOXx9nmWduBdaJ7 z_WQ%ov`B37z&D8BKR#}D-AA@A8pg5~zBrj#nN;eE2(Ol;Um`JncO*fM9pUUs#&zKc4r(=xFm-Yu+?fj#l;xN(IN?H}4F$)Q?YM&5Ep* zF_}JJhoVb6@6ItYT16IXLm_IguvHpGDhErE^U7SlW0yzf0qAE%u1n$WkCB$qkmw8K z;r;vyLSKv?tp4!nGzdg5SLLD#|8oE*Tz!7wP3O|HA>{*bOk zH=GRdQTza}=ehCKWw*g)ZsSe8W0s(seYK^Ao)f8)Ot*WVq~*ALlikv{Oi}Al{DBtx zmD}YI+vX{32E&j-r7Qxu742R^`RRBja&9yI^jOvuE=xRrH~BMo6j#`5(egd&(3N=&%{w#i)v-BBmSQp|6rAKwl9dJD=4f`e| zO$4VLs3c18OQXf8O9}y3Lt{cbP`w-Jz~Z3QZzhXYIB4GWN}lTR{=q?ecDLfs+1WiP ztp-jO0%-(Q5D5***{{XLY){Q85VxGx+SoU8aUnUd-SmSG3oTzcqWZDGXhS1~yguxY zL=0m+IS}#olK)DreV=C6lLDwp%?a$t;5dRc{U_+HZ>D*#2K-cz z4sVaF$iyF8Jhy%clm8IQmq-jo-Sd>I3%Dqtsk~n5Q#{rk@=AS6r#veF1 z--AZ?G9ir|0>grA>|Ol&vwmX_6^zMs!AtMnc&1m)o_xw$w>FCy&r^il4swok?#prD zq*)^NQ(uMn6oYC>?U8$>qFKDDUc@tI_NIGZT9@oG3=qhSSP+=Ox}I!l6_%2i zD?o$v|?5KpbYEk zrpw4I6O(dVC;K4nwuo@D(>4@6bqiVsASW8QA)kv)LyvuUc&&`(^tnu)0TNEPb*22p zqA;H3k7SbtH&Ev7aks-q1)Tf1l%cswBvSgh!l;M8S`EzLt~8sECNp znJ&t}SjlNZD*$99$XSQXSptSF5(zmVDU2Ek&n_WjLqb}>xem&(4A#+$dpexAH*@-L zZ+M<-NhhbzyTnZqVF%Sik^Hf@)J{%kl+D=A@UK13sz z!!x%QRxwZPOByj^V`XjV%~v31)EJ_T08T^j$7v`eKtfvG*zj2|Rx4@zqCvmF*|$FQ zMj^hWbN~Fn#Nqo_hs%;pGp@cYafZF`7L`{exM?^^s9Y$NQqMt&CcK{NUHln3Tx-L1 zdv$*1YU%xJxrDVwBzl-kSa7(-n|L3q_GsgM+)I^Oen0DdQ>P|J-3@Os7!!ka}mb4B2 z1S8^;??1}A_(pbd6G5V~$38B(I6@A3b@3ao+V58v{KCd`O>m_z)4y^z*D176UNGmA z>_?($D)Z?KnYe&}Z9;)sU~_?D>uFAL-qL>k=y)B`@lua+{niJu<0Tqe7d1puf{Dh9 z?Ow%rv2XA8L~ym5oIS6b%_-EfOaBTrD81<5{LsklaP@=ZSJ(Ye<(&FV)#h5}@doqn z_f)~K$e{gvDwVHl?(1P`t*ZGnqIRA^J5%IIMqQx-3Ap`o(f0J%L|NG#BnA*|dWaQ( z&L<%hGPzH)xa2!RI+PWz+NC7?T|GP&CRCk9iqCBGu4W}uG=F|xfYlPcgj$XLD>F(H zt+x6>6PZ0d8{-NjRP(Qct1Z6AD{wx$ku`OK`{;bSlO>-tY=9SYI*fo?<$a5#1^*sW zU^Ue?J(j1Xu%kxw_Qvy&>A2QCd+qd%N%6xWJ~hQ8b_B&Bp)J>&tFOziz8f~B)P~)$ z0Lplx)deW^;w=NH_Mxd&(=q<^OB3)82tKHi(vn8(ZvNo5lzqCCQ8Ho&T!a8#H<6m!rFVDmT8I? z;+ShdrROyg-CI(|`BCH@IBtWx+>S_t-*Je3d1_0ISHm$GfLzYJ-I=rYfuggmM%G3^4I?jp7bPGU_pSdd1IUV|V+`0Ur% zM;dCQhJTxdcG!N*99T;}9)7fWU#ck>`?7Vqri7);J1#P2RB>?n6w0W5gm)mOr?=bu zUMDM>&8YtIE;2;FdYxA+q8uJmjl60gz&Ocs(l(o3M{X`c&w|b<22cMZF zXxM%wd1f2ZX}^#yw7E ziFTCA#N;Q@QVL)!QZv;z8W?;2!&N?HgZ#MrDaS z1AUJZMx5QiBITSu0Gjw+yo5~qKdBh;UcJ{yvUq;4h%+iA z4z2<3@>sy0|pk9WoL5fxL*k3B}MgH17nb*i8}Wc=n2 zHqzBg@VN9V%ROc+a}m{z9u2xpy-Ms_Rd2V3a;CzG;q&DQ{qtj0)<#|yto}%W4M9li z>Q6h&5InB{Y3uCac(a4H`QJ3vYB00*O!vDE*C(RpX%5o}x>xzESnq9ZS*{EXna(Nn z!LhI!&k1>OvuJacyY^Gk1{!tg3R%kw_`Tn2WtAc7lj%MKl^&!U*Rii?X2F0~(tiI< zn}>Z!`A7v&me08IW>Ll;pQ6X+fgIAV?z|gu(zrTa`n#LQRSxjMFDBooli2h8Uf-o^ z_obg-w<||)?$dm>L-Fgn9ifja25{5dMiqaDh9Z%ax%X|;#HP^}s#&TZisBa4pzL4D z3f=mNHy>v9n3f7YThw`IzQ2A0giPg@qg_sA;EOc?=e!>4ILdgGRT6(hF|jrviVfTu@WCfDf+uI% zi{FFG>6#1A>fs7_f$oxer!4vWrJiX9fXX2o3X6)3g;i-g-wZ@8poG0uLwei*A#JxY zG=;Xcjfy{y!7&Z`_T?K86SIJrXw^!X7)8_W1K|ToS(bR9^Ulw&Ks@u*(8%Zg{A!YG zi^3UOz+-!w>lh=7=Ed=59Evv`}SN|W~?umS;Z zplF6X6;*XMvnuEhWmWFn?D1dPn;|YxA!wM;IOR1pC#R}R_pb#V*9V*5UpnXhW63*x z@PA$MXT1~heBa^PK!)&pBE3;+#GY0b=W=q^hu&k45fPn{{@ySYItDfz+aC7(l1A2Z zI`Ga?`l(uUJ#4=?6aC-8(bAaPWg6_IHG1C)H>XaNy@A$oa#EhJgkB@7#C7O7iHiBA zwUrsbSENi%0ok%B@B_`WU_zf0*_g9x^gE&R8*i0TjL|o9R>Z%pK||{z)(@9LzgS-X z0R#5i8I3J8>e7eq?(V_C!QT_*CCG~?+FqdSPp9Nwoz8n(;Q!er9QC<*BDHh`3BRMv zwgM}epsPN6QZlD`Hgga$P^H6(d73>=%jzj)r>f0IE_YkXPq6L{VMr5`0qIHR0+7gj zZ0->?CFh+t5o-s9g{dYyd8SeJdjIq^gUd1=9OC^CpwGeM_z$vjnD+Oj@*Vz2k@eJB z2Ffx}floIIGU(GhPPXhE90a_sz6{IWoBi&D?VdMUCJMOhoSmJ?C$USuiCRw;bi-=k zHtxoVYjHo`boxE6#)o*n6*dI8VODB3>wr;4eCY9-@%i6 z|5%Aej;fRTLP3U??_o)KGghe5v!%{(29?~P(9jnkbUjXYNb&G!87#T_+5(U(E_miW zsf8Yp(!HtY)b9TH6vzW3agy{v`w6hnDA!fvFD)x8IIOds-V--V8{&bzwebdH>BLM2KhAqyDsW3?J9a&# z^v_kjcN0G!&anOj(<073pxZ|Wx%?hya#-!dWl$$!HIA6UIo za@?^d4;PxF)7|;5wXR6=1STCk82zL5q3+oNg_L+oafsDa<@ND|UUsD%G*e4MLu2e~ zC?UsNiVbp3GYsu@pxbW_r1HJe5=D%Kb1yua)Sfk1gF>O8_#$GmyX^dYDSV#my1(e< z<)!lB{n3!L=KIOd29pg^;Y3*j8^d`H;MBZt1m!5h;ZO)ZAjQ$EB^xg+Km!dsL~hPt zZ%*etc2~HA3DbZhQ$EAJcl;ZRHks_Gf9 zNC*joOD6!YcHb%o=K^e7s(E#NI$y!-b>&(?#~D^oum(CQvFT4<#0N?0OvFe^NIWm7 zu^5wEaj|y;#TT<7Dn>FPcU49_7Tupe zelQ6NzGg%U!DHH7SlBzQ9kLLRl6)p-UV%FAs# zLU6})*Q88A>X4BOxX%^27^ocP!!#R+$jGlO5_Rfq+}z!{ZGLJp#)GmY5sX#I9s&ac zvz(m?xS!d=&#w;3EL`&7Y!222cGuPl>LVYckO@v2qdLl~VZUzin#FlzH+APIGUr5a)fQvDKYpMd zP}9&nl5|+>kCWWl*#RzZuRNG7iL5H%uqr?L0`(OrWz#j5DW7JaP)T&_*f!PB^Mf>@ zJoe~ihOh#7vXOj+ysm{!XMS|IkJQp=VpG(Z;Pa3>$y+E|QdHougCI3^Sek+V>qKJ3% zuz4VGEryqNysu{!*MO%7_vZwLY?cSrKro+%LeR;}4g~o4L4^=gp}#Lapn6EHi4z-T z835;u2ks*lfOLLwL4U3P^NU?!qX86dgU@Lb$6wA54z)e|ApM4z#|DQ^i9)Sdb*9cX z;kaD43CTPf2(~)fVjY87;>vn+`Md9&1ky6tjNmS~4cZ>%y;@X)rV6<9<6N(jw1QQB z&DU5a^zJUNgBph*TlA->fl8IuE(ZMo;wy!*aPGJL>CSAU+u_Ce`Hu_{a)naeraPkw z3k$d0T$B`My4Tv9nVCh9@Tn9imNw3sfN+fx>IlWxtFgenO5w33W7Yp)B^tncNm+gt zGF(w#+As3{q8cRDz0=JxOAuR{E_01;)KXy>d1zc=O45@FCg0;pdEY$zz-a(e9wc=i!!GDPMug$uy=JhAR;!_&HpTNKEL#;?0ntw(pv@p zuL}SCV1yO1Yvp^wSQ)`xei90Q&Sml=`51~WqI&LBW)lZYIM?K%u|3u`?|sXpD}4wO z1*mA^9nbVWe55+J?mf_d)iK*Kt*6;|+|tET_M7_NK0PoNiMf=ZTM9gHvEpIpLe+q9 ziBeWulgH`k<7d(Hv@CvMKGrWzr-PLp+YI$c4-sw87m$ZrZf{CXXrBQYrogqT z;NzZH=NHwVTUypRmL|ITQg@{@h7n*R#c)Al*qJRgSZ^0x^Y6%g_cb*v%!d<&d|D>v zQ65;pu>@;N#k|CpY3%Xae*h_zTmu_Y;b4@5;pAC>#fHs-%_*-%lzJ2K4QbU@!pq{& zxX37(SsS1U4VYr7#P=B;U=i5k>>t$ToYa%2+jGsw*G2j_uGnI!1OSEw*|A1J-0aBS zZl?=$^$*?M4>sS@DU=ue&^l*95FNlQ<+~`8YI{xu`A98`)!tyJD~mtT+scevcTu<- zXz(AHDU~Si!_<}*DcD%;&1?4m08H&}(J*g6N{@l3+-f2J2lfI*^={)>*w6H9^E?!s zrV`cufxHN0K)=BZ;bB#SrbkIa_@4m;YH?KY)Ck}f)!tJCe=r97C~N!Iv(stoz<*x! zf1*VHKMRMU{3qUQ77QgI{EtWfKe68bPpJ6UZvX3|VlqgppigL#fC}&mIT6vt*}jp} z!{Db>)YNQk6Xkju_?qIkKA~K}@7isDe(_PbLyF4DLUR73H2xS^y8xeJ-BL&kdY@RgS zH8lwd2}M5*2n2)4Dx!2?gT%8mIUm^-5~vrJ%$rE6``i00=?p~9BK3l0wN-$ zhpc7*Ec>i!-t{YVDT zFC-?<>wf%}*^=HtMb@FK)oEiY|t9^F%8kFBnm>C*r)K__OxCk0G z6NT$TtQEZPDron)P;kD4hJFXA!8pJN7Jjua=@8gp7=%@O_npCPJr95IbbkcBpc3Z6 zci^eO;{)m^A`U@8CF10D+Kk;BNEd2&x6o$x$lG$Pcr0HbW&O9L5%}fI0p9ED>rgzV3!eZ@OE4`l2z=sg)nML>1j~uCR)AC#szsbORn<&J zNd#R#zeYhrD^o4PI>C9b`&r-TNW|-kpri%hC_qedq$B$iSjg)`a5DtmsOTF{rY)PB zo6E||Y8VSo6njiF-m3#x7e>ed2L~s1^18`gP0OO@(3jl%szR^DOQ{>oShNnci=G1q zH+e=6pvlEhjx;PB%5%ZKGy#n>P(6PGkZ+)q#9`VQ-Nua2GRj(eLf3oZ1BI7>ao5b1 z!9*!)41YvU;gJk_Mo(XoIaV5Wgn&|yFKN^j(NTYwovl!xSA283I@_lsChGKZogv5g z?7Te<>W@(iy=viSuWR=>$V1-o-dAibR|m-SnfXV+#o5>ajqx<2W@C3>bQsQ+mGPqm zY=$`qEv6KBD&^_L9uys)Nn5p0iE{Y|DD|R4fXiq%xjS+Cwt$$ca}eNCceJw;C4-=Y zswvSM)B&v9i_{GnQUu2drj8;9WUz59TORZ@Wn}1q;y%|O zh!T9VgnRWV^f{NCt1AVi6gfqBRGcA0#RzKx^PApeE`}J}BWK`cW(AHL!Znoa3 z;@D-*O}Z>p99H=uQ?h`IjiWw@qj!dam{^m)?Up(heno&&x0f7s`66A5_8Nc*PfyRx zsn2mAK0I)Rpc2#0fMy`J<70PTqfV4US{@#rES>ge4KDNVHEQ89$v>M@`cxBj8r&Th zi9m%PT|iBr|<+&uh0$vR`vI{>*n!7-OGl2qxO!D7}bhn8;@21&v_yjyLWI9 zgWiWrln)BLGg(#x5OYRF1S zF~aW{M6}ru2T)FA7VqodQG3A`UCAr_pJ)JpON=ug@jKeCdvE;~&k z@RU>ltT02xpjQCi_43n4kc&j{oAkz>11>mbvOo0FPHhq_mnRK+U5^F9Y)dq5Hs>jH zeUufX7C>H{Hb)FnHhSE6~R*jWIAuUvKsIBXkr z`Zosln|ngBWZWBzNcOc*8p`;l9%ma+BRM4Q;OlH^lcdI*!_+kXrClhz zKdE)#72V2U!$|msjh8MM*2!^tUL0f+#G#@QbE7P-mABkrh0>$uJZ)5?gEt0nx{wcm zXp_g=HO3g#dzqC-qh)W1wPl#GnB4P4L3O;Bzyi|GwkFESE2&m+SzTYTE0sEWov$RkqbkJ#h6=iko_VOnUi`Jbp4)crwNnVi&UGrg z3DPEAhFJbED{w97os*da(1k;WdO&r_aXNZJ%I}DQJ6&rX+Nfn*A*OhzA>jn?k5akY z9HX59>9kW7DhTL=Zj-wil0~ui?ajedvob$Fxd|cv5jN9ku^O^3H~~;LXz_D&cZRyi zy+1k1k?cf6VV`0citFYhm;^D2F=Yk<`pfZ*vkzowh4>dk*^H<7qJUog)U3@JW_529 z=so#>U;?BhW-X51WC#$dK3W9rp*q>wEikZbXPN>rFsdC)1!BBe4<6uiSfyYPb1!BN z3PD0Zc%J-3*u&Lzxi85<&2f2(SZG@R*EgTSociyCvc##-Ky>_2elGdgC-_#ApDF1Z zzJ3i3^?qcxy9SP08GfqZs8D6$t~%pfH-`ede6?GG6r7H%72(zkfS_0BVXmz~rvw_X=V8}Mi(7^jeDNC%9&=Idzxj-y$H^xkfk99R zqY8^cV($}C(W?S=-NAB+T=fzwkr|}EL^ko1tPmaHw*%=+c*m9mtMashQ#Q@$NQ3GYQxCPMX#NZNw2!!qGR{D28KTx{jDC%4z987(a$!$%*V zzD&k3MbFlqgJ}k`isZ4zolzVu2@s{wvs427C z3e37!)R7wabiBakB_)<%vNo0KV37} zrRQ~S0xFYCRxacYNinE@3i7ww`_&3cfTZ&mAUgsi9jZd}t~(FFbifeYwk9hy6r`np zb`6(kl-sskJKx@11M#MFu~0e3_^}YtTM+7L{7%y#Cjj4C9mv>SU8S|14h|{sqWUd4 zO^=&)GJn1(+6l%Vbw2C20wIM>X)!-q(=73O0?Xy)rH6;d%XGo>%m8qN-wU2fH#Hg~ z(~*nPVkAfcoxuhQLDj_63H)^q3URih@s)Bs{BgD-kQ!KH4uX5Au$B>h*Z?XZ<_0si zEfS=5XPx21Pjx&))AT>MEYPqmi2ZX0z!@ z`wbsSYR+*peUmZz80amj;N=9GEHGwr0P-GC+o)_%Cwx;;lCn2VwtaylpCjWWnymI2 z_wuO@2~3u+HD$RPej`o9wZCBx7`-`QCu0KQy~eezFtecB!K0>nFcMq;9A3!|gfyN~ zSZzejqo&IF=F*_ydSMF$eQ04iHxC;=UoOT8z?dPPMdsgTgMyd)@;K>?$;C%S*3tQAgr&< zaKB=Wz7?9+sYT}OGu0xp+DDsCEIx4gA$Ps9|9A^kgD2oA z1DL7;o?Tt-Djk!xHCz6l79=+op-Or}<{;|C3@K0@0zr=t4o+maH^Y-CTR6wcV1YsPEk>tT2@vodd^+1 z7GRoV`NWM(>S|(jczSjkPu1D{2Q!e*WEO!Cb>AqATJHA~VYWRkZR#lvQDxC#s_U|P zahkm8MUZ7cp^RgxBi$DcnubvlkUO|@*DLKwHgzH2LNFt<`ilMbqlemhy#CCmbf{+Dv=Xv4^H@DZo%s*A}`O?*Qe+g&-~_M)@8mZ z6l}=g@7}A-!HQX+i!T#?qPw`+oIU;AfLxt@`Nix~6_cAWH$Q zEb46KqSZ)xI;_qCsBm{KcPD_{$vYK-4VTZihacYG2q+go8=)b(|0%G*-7n4VK14wS z-vhNK`>sLD#(Vd@7(y`IzgMsMvwHtszxB@pk^26}N8m1h8m(?%BmM*=|7sPZ|Gn6s zzU&%h1QG4x!V(DgAv4b8@v^Opo2|Z@#3$JUPuTut<9=}xuCRB*-~uKACCleWUuio3 zy+9B?>S5O+v&P9Oa5B!k>x#nAC^q=tC?UdW?I=La7jB6EqXW8uD;*BId@wPW%s7b@ z%o7P&MM!LBc_Y|6fhu}u3aJl75UHFeQHZF1Ed{p|Xyna>lR&zv%F4N%8K*W((kSa0qjBGfeVUkIG^HXXS-0mt8)uv^MgOyJl5lAkQ>e#lvcekv%RPw*ru_ z0k;1zyrBebe;*|-4xcVCp|KI{kz&VVMf%=ss#1TV>`5{Y%de#+pAPq9ED5Wwp-*IA zmJI=^t@!di?)rLbk|&&KDuE5S?4KzCvOq2kNKj*M zg8ivMU-a5?0#ZyQeZcSS^*+{SevXKEIL82-)alVi&C{1ageXx-ZcF0R^`dH z&Zju46nULL1s-|czMt4_{vC#1pm;1M0FUOV${#S>$VX!N=8OIu<`#?8+tt1aW<4?* z1_bf7idXAbwbrZsB}B?&;7zV*-op>^28;F2@*oxUSx;YfX7>kU3&oWh5F{{~2h|MT z4v5(1%%-xEXnp#sLJHROKt`M)Q%YUPp|t z<~=c5zfKPiQ4sbH_xB6c80zT2C^j(5g-AJ9%;@vL8E1Q+VIk z*D=o4l$4g2n{JNUJVt&ilQIq@uGf6_XOk1GRd2^cZ7cB9MV;rG-z%GtQ`mlvLF;p_@A9bJ7{zn>13OlohsDXY&RjL=q?XeV|y04v*4~`NptT zfO>_~FN|%;5Mz5Mr&c{rmS4BqbsD{Ve962U_|L}zq6Fz zG~6!tOfGhCQ161_@>b18M{|k;#b-WcuL*risdXf?NeuV`4pvjtqHv~T9)5-fHe|N_ zE?>jGJ(Q}lo=LuSBY%bJCrGfdb#eS7YkX&plrmKt)sO%DuwcHbfb;~79hU4!NfiN= z6yf1Vfx2=6RFwW|b5WTu+m3UaPh`<}a>s~C1@Jm3x^psx1ajGdgfz`9NtPkxp}ijO zeM9JLAc}>KE@57-n+G%{_ruk9B+^^IN_C(e^dK9GzYc!U@@|V>y)VKq^u|&^e@vBV zb+UqN-85fb7o9vg^3w^yRPd8eFqi>obTfh97<9(!3J7m33FiRZ6HvObsV*!{NU`;>l^0+Z4aB)F)RxPf1^pIqhMGG=<6Ojil~j)? ziGF^m2%sAyqrR*dEqG}j+xyBBkQ~C$P&_V-P=(}W>33AB?NpEM!V`lYSkL1}@>hSk zz*4OdoOG=!U90b#J}JCISF=9tD*PX75K*gEy~)df5)XPdDcsi6b+&pkGBRdn>(`g3 zMfYz^V};X4@l`rx3Cp)?_8i_#>hI8#~N9i&SbQ!nVD#>4nRcA&N{w&o&w6%44 zrVWOn!WUu~+9;Aj@CO*>I+__+{KfZDaNK(~eJ;1_gx9%qF(F`b63vV{h{afj0O&WEd^sxK!;i$rM;2_c=B@!n3~ z2eh$^-hqTMBy8Uu6e%pQgeGVtqc?6fd+xo40$@N)ibvy=JW>Lha#^>L&0IZvBaUJUA z2(9Vr_F`-W?-)f<2M&CEYJ`b}!P=|i0^T|Bd@rQN;JmJ+8f$U-CKBR9Dl}aq_SgX> z=RJyI@0WPSv!<^~ETEwT+LT|Mw~7A3$bfZ|9f*jFH?}KG8<-LNwp_T*UnooG zFd)Y7p^#Ovo=L2vHY_oy)s$g#x02fhT|O;mn8kxU^T_CHO<#rd*VwquJ5~+vvd+_) zSG3YV-7+4qIs9UF`lbMa>HO=tG-gjNJq`XxqPHO2OBjP1486`40zqT;FLX+c$QAab z$Y{3S>{g&$ovEOty6xBc17_qr zr{C%@dEJmaFu4RV?prsJhKr?(Ce%?DCfQmaDyu}tWU-|MqcC4SQLvA?PO$E3+dNSz zZop+AngPJN9npdQCy2+qkp)P#yZB80K{HON=vf@Y>Ee#F!8CV`TjP6Fhm~FonH<9o zc9Hi)+veXdnDRl3kFk|mm*m42>xnX%smhxJel~+e?qb_!w;C3?k(~xPsHV_6IgdU4 z*o+L*g*L(($)4_92qGF91pAgw_r-BCntSA$tArUPX&xw1D`=Ob&qD=8cWk;NwF-ItW# zLU4L=JzW!^sOHC=5Y!(X;aiw2ytL?CPI*nDMIu5&1m zE;f3~A*y`?0~`uOE@rqg2smTKaXN0yk9*vwDpx&3hVze~tB1TUeeQ#jEt7&2y7rLM zQvG=|3g%R;lIS2-w`jm}6pJrT*})jyjF>y zKGLdkCl~(N5&M9Mw(UT!7wd^`qW}<}NI)a=M*>R|2}ucLOqov_jeHcJWx?(XK)Zlgbj6_Ubc{;VjFCK zJKj`7B3lP~j7nDnQ@$;s9XEt>!e(GL5^P=iEouP;`=C-A6iz1u7Cj-uvz{pIt` z7rVHp3dLFOyp^m7F(8eFk?TD!IvLIjZy#}@X4GO#o^GgXa&{<&cix_xwOMT6H0o59 zY7VMCoA=feY*)u=I)95B8sF(AI`T5bFz$2?x!%?8LoNEtj0nlcN6!7J z^@}UN3o|`ecUOO?b7$E@0#G}(0XocG18%rSB3Rf=wgEyKp(hZP@`V(4d2uFZ7|nqd z$}wOQ9UnbG#cA+MU`Cao-fet^%5HSLneVRK1CtjLu9*J%)z8nl)`y@R?9s6d`-%C% zdps2iM00{j3vaz3hyY{EqP&%_3gF(KuMn>{?%B>xBgJ{&zPV0>H=nGpd!vdj&ZJr-vL*$jh$DlCq&c-F-evNl?zJDel*Zk>-mIDe3$Ss095tG%k^0GVv@ z;oX>z8{mTCFGyx8pjR;1W={b}ee0P6R70}+d2o1(L}Lq3nUi=cE;_@RnlFl>p!_s9 zrzf$yy=lJMti>i`A|wRqeE)uI9dKltSC+H&N}XZ4w8+B3WSZ4ZF7W^Lj>|T^YoG6w zt(frt#=e*1tvK&Rl&}X}C_s#|2hVahWriXXL9}|g+dx169a9gFi2vQ~4cf`Pvmu7Z zfiYt}{rtzm5yiC4fSNWasR(LPJR_rSGeSjZo-5Cvl->SexzONKv#*lLG*1RE=k^So zk?|y6?O7K&_q;4tWd3`FTryC95ytBspK-%8;4PVRRvnJx=Omrvv8gKz;J0RVFvl;> z-f2Q$;0RWw7&6rCGndt&43$q*LM?>-@oZzPL|mtaetB~f z=H>N;n=#Va zRnz0!(!zsQzv%`$*|7QpIahXxd2_jUrKI4{?J!@wes|FjYoFrfV6doqZDha8wlM^{-Ry%_qSL%`9@jZN;!i?^V| zBwSpI3@tNw!|9tZA#iz;G?P$aY|3MFBpHL3xpy!0cPjN6LXs2Pwj+RJv=8DblPmJkCG}f@Vfcx(c)#Ilx5hl zwcD&i3ikxVVEYEuIBJh#3RIkHkiMnEi({G7o~n2!J+t5fFI~q)4O5m%Sl%Vd^LZUx zwYWTbAemhpJqu5puFS*5>SBHqlC%1&wOo;;!!cH3F8ED<@))s}uyd8kci%k<<#_f7 zmk8+gM}P{H>dxXQP`YtF;|#lIF6tY1HS9nkRG}{FSj|M)*Sf;@-qJiAk9+x}-rfXZ zIZ5zMt%0}$imcC)CH5CKVPP#R$YBB*oLdf4l8xK z`LZarPCM6ZF>rJB$AsEwIh&KHM=@ud-E3$1Gx?}(seMu3(`nv-&Q)8om#+{#sO4UB zLQj*Q$90IjA%Fkq(M?M0iCV(cj@+%IZV61?$osbPK=TsFxBE&A$=jaNAv z1&=N^pmiTidbB-cMe)&lPMwV^I!)dP0jz6$ss%+-krxj=#gmJoq-W_BEMg~L8TMTJwb;+ZAGg`cktm1N+)Iw4(b}Xh-yGU^ zBM3$Qz3W0`d$`DNd0&q&K(3O<@j|R|{Ilwc25n~2w#iMuDHz2TWdK&tK+U{W(CXBV z>+-F^(exM2)ngn=u8em}mZZgeClvCQWI@DCJY8)tPJ<_<0uFYj-bWiQvD{s!2FXPy zra}3&**te=u9HNRe)v&vsr%_g&Tqt$I<)_1}!G`uWtSK_{}W^MQ0ra(SQe?y2pb=uCK&1rRV(kJXy zQb{9^$yyc+0EW;ygkKrL+jA;3gg-;jjNh7?@ha=_MfO}+`=0WsmW(G`RD5InXnLK* zlnu|kNrkQdR{BQ0IW^?Xdp`w3bq;ZetN$5 z@uA4VGXOjE+DoV`JUsN@u~Ti>Y|9m-Jc8Zn*fpuWveYSA*ov{9uLO z3oJcg{)vg(jrM`e6o~W$|GYmXDI-cHK8_yMQ5UvrOQt(g|GV~xr_{iTOr(yypHq4@ z5=njaM>e@pry3zLSU5PCols>H1;bTR=D*}e z9vt&4E7x6C=DH}e<`b^-C8l8E+%n7B%VJ}{!YzJSW`w-K~F5t9zpGENTE|+boLbSa3mE`SU@;^WG z(`Id5ow(=bw3dMBGn|LANt~&An0E?IgC-iq>ftiUi?$j_x^(%#tXpUINmQ$UYy!C&DX2fpJ6=p2X>Ad;_{!2EKncetGHB z`4#lec)W%V+N4AYaTmG|J}tmz*zm2g5t>uQ2T<*@x;-h-ea=IOHCr-VW>G?n!xIF- zL^jPbH|oGs)ALPWKN6ERGKePL4v+F%(@-7w^h-(qWSM2o`-{rNS#K;CQCFH=%h z&VK8gw}rP)2T1r=s4gf1>f%bR3!ebhWj%iigLrQH@WoDvn`e z{nqNJTkOx@r!F3Mq^zY8Iwk3s#q`IZ&=F0wG(Bx5-TD0YeGkBJ%8XqF1*@NR1eyyg z({IS0T}1VX?J@Ug6(T*_v}DYuUn$a{qkInpZjQ=mZr+9gF8n&=(P?dhkSwI746blp zdRKr!$NVP0G#ZC=z@(;J>12O?2n1%B*^TP>c}*I3nY$vKxVA@l_Z3C;Mobr2)ZTIE zwRWV6j;iqHQOsfz$iLBtYuB`=2ZhQkQpo%9RKOiH`9;O`H=RAq`-VZC_L$_23?ejQ4jG!bw+0qmTYn&n``57vL)+>fG{glE zwe|#~Kd+h>5fOZOm#3CAzX!c{CF}(V4XOiLIld z^_e6jm!+iJH}5FSj$;zIOJmm8kNfzDj=jH`>7xj}sb9;&w-cF(k~~D{IfD{fYEe*p z?Z-&)@v0gtx?VIrAFN_dAFr|-U4fzrEs@V!)rIVzR{SpWyo5SeIg)8^znfq<$zr_M z6pBmz`Ga6rEQ`7@V`Jx8$z&~GJ(M*TYd(B zo*BH^n0PH&waGT^kz$NaO-+NY>-Or#2 zR=k9JlIC~lr~_F*FAw7-n{eIAK-{Bnx@Q;UPev}G<@%_(2FcB4EX=g)>J|58%1Ld_ z@{;XRW?#h*BBZl2X>4pyvLCtsysXB>#27uqKS*J(_KH30@_1I6IlH;s(G9^O<3>>| z^XE=7_kZ5K{Kj11+0xmci0C|FBQ`{keX3l*gIc&0ob1Sw3HP6>b z@xnqcSFZZOkEW);LCPU(Uc)}a@?I_<_nX{2=Xk_VQDlXhd4A{W3A&gzXD|2H;+ekr=u%_$C;Gst70VEp3dz3~evBNe%6??dNj-vcb) z+WwBA_-y#p@I+;NvkV2r?*(%RVhJZTKu)0)Sk6f%aZc`CiYJSD1`goTW>L}Ok z^zUSse~SK;@vGtS_pA^~{^Z9ax#~-tO+GSr7WUVs3iSo#K9m({X0fHg<~?jWYh)jS z;LE?De93X+m(-q|POA^+u- zF6Ih8&Aln8Lj!Cz|Mg}Y*8SY$15O4TGY_5owfFW#+-A;OAD=!S!HL|IIK%bYXaFHK zzl{mX>+JUlHKriW%>ZF0yjl-VA$L2=xfGeXr~Q-6tDob`7Af(m@LQq&Tp4AB_mqfg z-0Y)&xfP*bFS+H?*A&D;w*V%#U30*(pm3*_m~nV1)h8kbwoH<#gQ<{`&+=T^2F7hsnT=ku^X*Y;w zl+I>)AEL=<@r+x8g$uIkZ2J7s$=C3-OYcJic_00pTopiGKQE?|ecqm{ zxL%le<0aFR;{oR~nUKuPJMZF{qeQ|>4L&$zyrbQoQ5Uz?fwH=QfIcK2U7DK>j|Ik_ zYOmKJ_+zs$(dU9Mr!|+!pszBJvfR#NUdh9qxdTAuK(m3VF`5BjTIIW+XzDD#E=Lg+ zYqJn;1x~eSuwIEpa6Ai%27wIPR95K|-NO0So;0!V`UyjMv0ZI#*{cMVRBT6NRYImAUBe z(}^)JxW?O!RjUC?{EFQGxXwPz4uiIu_z1<(SbcQNCdCywHeBr9Ou6Qj;gDt+P%qDz z6!$g5)1jL?eywuBERLt_87-Ag}#*Qf`RY zI!;uRTxp8134pM5LDzoEy2W6A~5>J%qd%Kxe1lEq|D%#ZO^1m_!Y*X^@i zD=NDCkQ94oVfbnU{T1Yt2S|AagU%^{dZYW=S+9nN`zb}lVu>K zzRp*BrAgLC?@({My6cBY{ULe|KW4%A$vhnmWHVjKNx%4>!(k2~JRAQ7!SX z(`rCt%3B~Bm^xa_x+<{sy^rhtSnHt--h!Ryz0P0oo%@`?t@6@n!pjER|KW+wz1S-4mM{&S`0Gp3gS%LirMazNgB`_hq!= zK^h_9Pj-i={ta8fHO(-&)&dl7s*`Rni8^L5q#D=OD{u5H6y3cm#T8m1IdW|(aI!3!z zRR5s4R(Js18_eGBcpbwJ6)5jd&-Jm#f1NPI7SdgPcjwk{N9>iEJR}MxBF^CSVkv{m z%S^#ikM9eBA$-^4%F*#3Zj{JUppgN6MFAC0rkyiY16>g<79~QCPSdQ&w50_jC37;#e)647GH0Xb z#cx0~ncC@0RJ|Qu=>6^W#-s3JQ@tV{{bb(!c1)T99=Wqm`)A)p2sT;61tMw0DtjVG z-m>;Z(5n*0u!Xx4y=BW(B+3q_rO*6GBc(wS^Fu|lpid?HayS~HG}E_v^Sjq&7*Q;}stt zy$TkkQd^iHwJn)YjC|QHL9w97%t;n6 zZG7u?Ou^lsw#U069~?07eRSA&*py5<<5?QY9J#ZCp9Z*zHUw{tSbtq1x4DDpV6d^` zwg#>Z&6Cbm^DTLVw_!x0H=y0DOuUM{oknC@D)=oU`2Q6ZNc-F$jY9al@-XJ7en z-?=VPUDsyTu9*F*%6q%Pdy4n=#t(vrs*<0B#PMkcMIZNa72TESJ~elt z90H`O;^OB+dPUx3IV*Zm@h6P7H0o&w226K$Z`aKSftthM>F;ZR+E50br(Fe2D{ z1J+_g&?PER24PZlN|XbE&Kqe5SLuOqhl-=N>8RjXx{XV~thB>EVWDH(BFv(|pqohM zu9|JfX~}V(bEY%WM!C}#UK7vgbo=#hN5j+Q!42hsya&{jGN#cK7(@@D0aZEa3-n)S zg{@-Y3D|q{p4@quX-X(eKxJ?ziE4AtK8&zQCx4Zvt4TZe#kFE`5<=pW{U@74ce~9i zYz7}zJ)_ldukQI(YX9yu_jMy$x5uPzV#@~y`!un~uZ(K?jOz~_tvM5-2QuX>wYG-fKVhs>naL=K(F!%u#Jl5n1iZISsmnJ_en3EvpLcKm%RM3I>?jw!TMyMP z?gqWPqn(xb!>@^~SbArkV3^6Jt~y`@pliB|9HhH4c`I&vmsy75EvMHfRWDQ(y*=qGujtR{HxZ5X=4O4Co5N2T=}6D|xSdPGXAo`hM?s zxp|WmeB0J91RNjJ4)jk+7b0Yg4;0%&Ek{hu!gcM6qwFRPgDVN*2Dn)N{DDI(cTgK2BiEn!zj+Eb6n>1#-Ew3OFis zJ-DI%Gd|*xWE^H&)1y?yFL#?xwG`IhMv`sSc4pjK3{My9>SBLFVsj*-NZ~bnDJZjE z3cX-sb(F)#gIyQ&6Ys&It(q$wxQIEXvf*5P+hj>av#{Tel z8n*Y~tM}cc8zSzjQ#q$tc)I>p1}BkUYg~+f_KJ%Kjo?$;+fgjPx;s77Po|J6dh`{_ zL_Zo%ES4;a@{hPY)^~r;M0+?IwH|0tjEUx5G_&mp`}i>gv%#l^^0)qFPibS_07h7q zH$|4&INdjYDzKus1^V`LFx|jlDR`6aalF$#-|_An{sQ`y+%ZhmkW`!Bk-hI7?297w z+VHO(V?Fy>KdZ^w!L?Sz=6ANws&|ql{tcD7Du?Qow1?3tK{T&sUY*NF<4UIbl8D8tt-3^^J*T9I%xQ0SA^6&|3I z-@Q(sYap98q%79^6j-a_PxOoJ9Gq$M(ooTPmZ`l-BzO?-ZunEXKt!d;`|;0sRtA?9 z)ui>GRq{LDx11sPUYX0c#_nxV;XEh$IgSYq!wT+OH=QxkX1GLmt!R)LEXDmZ#vn$> zP9v2_*V?0~VOR0}E>(SOh?KsYYPsXRSFqW|=_VglaMa1(Er`2;cq7+apzrtXuqW#Y z61x3s(JNL`%pdPj>lTU^-t#8IzWZT)YTWxyyg%ucogLfZOu62L*WCaa9xtSKX=KTZ zzqi>P%hyip(XYap9Q_LC)D*C~zm@tCOA`}>=>h+n_r##S_ng;Xc5#3Uh~(d3hau4f zb?6O9yl1fN4fr{A4;UV;ZnX>_ho-~qRQKL*mjg*Wp2T@9%yj3y4LV!In=1-85|kp&9nRI&^x_QSc%ATz{RO z^4pF0|3rNH^pKw}NK}kWj4Sv)W3y*~CKD@bD2#>5pV8{gGgW&n{;ON!jCOT%Z_l{F zdrwOm%gdX~`#x^7V!#$!ma?NG7u2(p{>*fyCISr|o$jZg_ii3EVNOTi{Qo>nObj7_ zAvc=OSCnj4uv8uq6;)*N3^bZ!iKAlE%`P=BKIKMZz;3@B$p%g3FcGc9e@2HF$pHw% zw#)vHAv>CzJ&TWQ4zT;HeWL~0zELKG?6{D|!p4MWY@PqBd4LM}hbezG894rr zH}Usz8Pz3?ke`nkvbWdF1-=$J?0@0_r1)RxSLk8*>)HN127J(8!~EwhuolYD&TmFo zMt>f~AmIEEN4ABGAlpLz8i?~pcpk-z3#5Ga;9uI+(4T+*{!ds4d)PUt&{RB-ukBsJ z7Z4ce)C!EkrORfKKx3oYf@U`dfvhwzIB8%nE;CJ3yn*~tWkT@$L5vrm-?UZ=0X>p? zLT3KJ(ws4Xj>6+0vJ05bf>b7czpBKW6RzI#oO<~%z4vj^7N`#W4DCv~+0W8SrlBf0 z4H{ZZDWKXi>>$sHbpmp1Zx7n};E}-RrT&L)C#XSGiP4BAE<U<((SwaV{O?j^mSvejDb>U^5fl}cV2$V2FMJbt(CKOmL+3iQ*& z>E?;>ZaL2?=GQpRe<6R;SbZPf4&pDOPSZg{HI7r#l~-!t4w0|@v&4F_>D?YZdQ{~# z*O>_Y?zUpwD15hfiA5+t5`{l>dt>^>lfMN*B4?-OZbUe}Trl+d z)JtrJ(PHqW&_?=;=GOB3#s<8}3wiSJBb8njWpg2*ZvjcJG&&iKIZy&G*jWWL^d@MWaLL?YKV?n3z9_O|kC@di({x2E6(q?ND5q{9i)2%v;L zAkO1@vcCrERSO-l7lmM1)!7WPr3G{6#}U0XrMHOz^^lBUP^OU7jb~Oh1YR-7J{0P( zuXzFI55&~WJhC&*zQ4EM$usR4dsM$A4?XNwNSK1G4viuL2FIHDEiV z4;y|>_Xh1kr-@#NL8IGg!n&t$<^(puhm|i8Wd#d4{=6W{YT|XfxUL|#^Ymx)#Z?yds;Y)A=3NX z63r}(F;y)HR6wWFVfC<+z2s-;vN`Lu8?3B~i5HoWVo8~D5uKXgmVs3DNL&Ur5>UQ@ z&e%B^QKdM>YOOIf+o1C^43u z!XhW~ZN7Nt+0T3sw%L^Q{aE31w6)Xg+%nBk~bLvGuEQeB0=9{Cu>KJ@)u6x zxb8u@#&zzCq96<;XT7*a=A(llL|8tR^^}_asA{_p8KSfzugLQ>_Qt+CAI$@3i$^LP z!Oa5Q#Lub43U~|#xI*3ZijA(PYi7we2Xd`{7N6oC<{A@W1koV{wJZ3_Kd}h&!XCm# zo|fWkt+4n^Y>&~)6}k=DMr_np{&0UY?8m)!lY8&BK7L258C<~#lfZXGDKXO1j&S0g zlXW);%iZM{TjzMQ4=PU<{YTZxFcN1oY=tx}AmI~&d&r(bYzA9@$O3)2Tl@sqt4BTI zQ}%7NUxy}?p$tay4_*>IeV`sok1on~g_}Fm<_^sr<7qg^B3J|k$u##=I2*FJ>z*Xx z(vp694jsho*6C~v#O-MIp^^Jr8OB8phn0eerbQ;AfM)%*p?#Y`_d81pc@Q&49(`{ zs3kSQz0T9E*dZ$ihNB4|%RsWJ78ee^_uHU+a4{WP-aB)wl1mzol`S1>>2H0SF)1fH zn>w@q{@(gl@|Tca-OnBU{8*U}?a@Woe{XkjGK>v1`nCIrw~dop{e6K{)-N7WhoOA6Gm$q z(Yw`-Z)VEA)VVCEfj$UQTd@Ga04P9zSHJ58n!EPF5W+5iah!R3q_eH!V{<%Xbma@) zeQPHXFz7x$k7eJYMqb2!krAiLPLZQqsE3bW!GA@nwkJ!DAEaz^Rk2lwKJYVo=07o) zX_+6rmU;^RP1IkYR+p<7i&|EuJe`z!=oflVHxRn&7$mB~oDsXP2 zDumsrl31=AgFhn@D!r8SgDZ5Ue^SK`MCFLIe1SjT__lx#TMvTQnegwy&VQi$Z$ z=PPd?n{^r?kTS9O9uQba^N`W(arc#)%FH%Dwdgm03E{%+SG?DQqIR5Y3paIIj2No5 z)MVzKQ3*L4^m5UwdaRATRd)=rO6IZb{8w$H>&+BamGFneehdbYT-fEB#7xki4o_mKWvk z02L*zoZELmV{Wk>gdkj3M|!t-DKN$|B%h+P4K67mXfs@^*GuiOqHX-xDdeg9kuo%P z-Z!+t-jA!c7D+NZXWMQ`ggj>9_M<+x^S|`(fmA64uEJk~x!Z`v&-$9Z?&}cC{ z4(9;wSgW*4(R;kg5p^CLW>?*w6j>!-y25r}TcmAL34&QAwDV9O_GzIhjQ&BN3MZY0 zWK0oHXmBqF0#@Kng-C0%m$CKDd-3)CEf+;&`?+5G{T52)2{FjQ%v)j&fGbb`bEsEf z1{3sDKmTbUHoCEYr##dhvwYUy-ORrx7#kZ~sx_LS;8Yld<-IQ0fy__&QrQ;>muJ7G ze7N5nFoG^3&0D~TT>^S%MW278%`mwe4sn6rp5#M!5r(!!nU-m-ax7A_v3eL@t?nUIzdcmf9=_SE6;cWHCkqUT$-&W`5&_}~Cq|~ZhQ~KSe zGF~%yCw(y&i|U)+oI;Geg&@7$S`JZv_WJ}Mu~rVEfGstaU8JMD)X21vc=9|cB0b5~ zLm*QQhStvs*ZVo881g8W6Qtm-wlNMnPY#PR8?`gCA+1N%LKOmds+Z*M{*4SMmqd`flnzV7zi&3$3=Lh^IG3v7ra1L8hhP$2Hbfhydq=dMMYPx z+%&9ow0baQ%UzP&&vX-19%&0>u$|9j8&)b{4xfK&!e@28c6)xr>gt%h7+kP%Ap7_B zi)n~{=8is z4(awHW(fPHoM&&H$0I8I05+3GA7UNyiwY4G7^bcejuMIff|Hy_$e?J&4SJR!!eCN; zMe-~IAMQRjkR^YfC(XLUM!q#di^W|w3SxiQmzimK;=@QmS0_nwa%Nqy8A91yl^5$E zG7J5zCU2ylS(Jx|A$6)n*>K&)AV`KI0ODjHC?|vF)e9YV3XJ;WxKjfxD)FH$3!xIMTc~4fWA>ahj-V6hU!-&wrU=@rxMgt0V}=- z28K>AUO~fQvT*{`u4Ty}qR-ba`2nc`Q8%4#!y|mB3;b1&dSooHyJ0COD15@C@rmia z1}AfSFNi7lf5Zw2G!XXOq&2${BtqOn8z9BMS{yDtjzJJWH#hp>5sj#ZS^0Ge44XGF zqCBItkABpHaF*UtTJgh27U|shP(pdcARJ;S)1_G8bso(5`JI8JV^+*CY#vOxkJ?gcrM0Lo5V)Ty%c|Qcx&T3?MFA|~f zc51qOjr!>mD|J&kBNa09z#z%RN4{_uQgPkC;GU54aZpoRgrs`iijeh^g=AUJ&F#iC zh|e>8cu`L^u&{=zROl$5zWTa=KX9kE6Ep~`j(TMsrES;<#Q9(=#OtD2pbKHCB~oaL zrA5C*ubqr2u8P)_26Z~Ol~RzxxJ-X3AM|8nJtME-URsObY>fHPnW;nl|h>X8;IwdJ5v8;b83eN8_;1rah&ZSYKUmPfP8jhYvj!z6eD;a2nm22^gmuZ z*G~EW;b@_{4jAcmrKSR_4n0{{ozUzjqE$~5RS%giI)^X3I^*6Ft$s|90s;P}O7O^a zkUdYobdI2v#RiuF{z>PA>OFsKb8Sl(Po!?O_h2{9XsLQ|jjb3rEo2_2*J zKLe2z>hbH=58Y%!Gn&7_5<->My5Hp9Geght>bCS>OJdQb|4(cVi7EaCasJm^y}q7} zl>6Wf$offRl_6jBC)2XGGwAzHPQRpvLbsgC5LXya;EmHV}NA33nj?KtptfE+Un%Edh5DdGmB;<0t9CceQjPZ)zdunb_8w_H zUlde_#{yf2LmPDIcOTMxd(ix6rQ{~^4C}9S9D!z$;(t7^_u0U=r$iC)CtPDl$T|Pt zDM!Y)=wE^jjR($BK{q^1;e4i$$n1*%CiiybGu~q92S)jkgwm@7f6q1{aS8XOWCh<& zkW%si#TyteSR-K=^u%1zTG!1XbpDTdO~xU6$^#1_`k(L2Y=tFoBm8nvWKHGVqbEFo z3}gKLlNk#_1-Y+s1?;85W(@gv)%fq7unoC@MwHPXkQ?w`y`f?z#^S&0EdT|=f@_}^ zLBkHad|oN|e+*e8OOtT2qZd*EV4}f``hE8ER8Nc56RuJ zg)P!2V#=|8Z|LrEQ0pfEsCq~sMHpZSVgdUi2+vz3OifL#jsQ7abg?QmU=F*)*?2g8 zt6MbdfI{OchkPviZ_vVBxttk&268BZt)L>fbrytu8J}7-PjwBT7Avc~A#=aKH*m}% zno|mcDEK!#-e@@3-DG(n)Kydf92wWefEP;TYrTbxgCzy>fxrUpjU@L!3-0ft2aqpEiVXoe zZJ<-md9OZC^nB|6ErI}w^x+SRh2>=U-Y(w_p6LUi3`qHdvC1U?jT0t|f@=Vk0|i~1 zYBDG`!2`6OfnFgA1j|PtckAEhd%C4i)djp!NV1-9uM8FfBF#G|unong`;!nPy}s=9 zGW)^>zHKF0DIM`AwE)VF$oXFL0|7|5x+kGG!4r(n-$Z;||Iq-r%`8NLbX5R8j%CBc)LV4>KS}fMfM9oJr-1-S#BqnKKM%E z(y%r9@HG+}=8IMRaA2DL{`Tc@ens%X0u^kV8-zkm(*+L9FS3w`647Y|KqjCYc-0GO zOyd%(6R+}a2JoR3@M!-=!c^%nVR(R+^}Q3PdK@tG!^N!Vdw##%jLIQg--q;z%?+$Q z1lM6%Lag>&IKyE&q8;mO3b&F#~{S%$hL-TwX;z zxoX3GEP9@`N<7}TTdf0S+Qj}Pn@fVAneOor4Ki)NR|28^d^@V**B8&MNYjE*76&2X zl}YJhz7+I8Mu;sCS-SeG00k!=AT?^~qG5zAfTnjc2s{M%!arjtQU9J2;1W>3g~nji z3<&vvj`kM1YXPKGilvu>h0Bx=NC+T(^1dVip~`KsScf32x!;1rxw|~5lK$}4(KW*W zQM74Rwuj$R^RCc~2FEiy{WRt`zoWz9qHtQr-P1J|O5MV>n zBX+V{!8$JcC?hzp^!=N0{t4|w>j0&|pSw=zAPj4gNZuqmp(I9JgDDHW);kZ${NC1^qL+%y-@W`TPIZ$VX*X?84vYq^rZ|uI$sSL>)Q&R zco)40x}oHeh)Gij)uDXV^SJH2tY!cgaHJyQ0-A8B_3Up9G)d4-CyMUut_0;a2$E&| z*@dE>3J%DXTYMhKlBK^eP(=BGfFhC8gj(~}D?5J!yseeDD(kPG#nTdy6D~|N`I)3b zO(wtv1aQl*$0E~y%N@W#+nMS0Kj3goEwb8AHa;m26UEARy0rYwAM&`~1)lZB4%~43@WKG|`7U7d!m&#nfK4FEt z&(CXXgenHBubC<2eJhhNJpuI!I0GoDKnuLZunFi{a_I=0JT~q@uRptPK{gMKkTV+j z@%|cnO8bSYZ^)23;F{@h+#!IagF4qsFLe*}uqxGp`~{2kGQr<=xInjSNedN=oX82J zK!1P6kB-ek%22N)WK@7yDwy`8Ee zu07Db!;BYjaq5_qO-GTbnIIZVI{L*$A_-|52?>crlsX0(Ma57%3eK`vs5gGD0dl$a z>j#Ej3;?#sYO+;WB}36l+j6ANgHsednj)==ZtmO)Uu@CM*OK$PU}h#b!O`e*dg#Om z*OeN-CJFy}Sp~FlJ*>n{gz?PZ`YreHca)ib+Jsgc)k^Zn?l`F_!4?zO*#7yez2g4_ zFiE8&5d z^G4RHPWezJEFBQ7^4H(R=tip_5DN#C1%(yt{Bmdhd@ujD_m)m*wk%FisFVkV&24aMt&L zhh4sQE-Aa-7t;b$u$;hBhnJXRIdicNa*P?jPGY+kTTFz_fCQ$k;tBK9B!P4NvB0kix8clF+ecE|f=|3fm`9H7gh!3(GO+jBebz zhf}C!t><{ib6?`KgDXeWe6&A5^xX4o{yB%nhj|T173D5*zMz7q|iC zeEHDtgq${lJB8j{4g3p={TbW?VrVYoHe_BtegJG4=1x})-;-gR7J3q3dz~xDUoM=a zH$Cl(fd*ZgCW3bR8z5(}-3MAduo!oQeq|VxnL)6qol*<&b)HzZJOvj zuj`}eU;Xt`7W{?)f9l#ODq@MhdA>>U078vT+KTN(wb669b%OlKfv|v zlSg5JehJcM@A&YL_9y$D@@#|-f_7Inklo43u2wODoCro=15XE>0+WX(wz!(8s2O}e zyr_m!ApX~PPP-v+P=HSdS%DpZWIulV7{gX!PLu%dJ?zw&aEA+NxO4vO5pjcT7V>yA zKBxXv`_hG(lhP)1i~7UEGvqZ5j`+h=DNr1%`~mh!ns{yms?70VU2%y8D}NJeCsI<0 zaETYn%o>Wclq5~L4mW%~$ptz<5mj-W9mDqK)fDE)@bK~{+!Ka)gYd#hPMBl zmTCU5OcGdfM^My^ilAWFm)1bH%yiCCd7+l%&WACrHhemN)tHlCleQq95v_`+YjWz6 z%OD5bCml&QQ#xPM%Ux=H#-IG$&TO)_mI>0ONf}&o?e4iC}&7a{k z$TcOsVUS^uq-!iY#3k!cU&SGMF9@h>16cK7JwjXG{zm_SqJ7Exz!J`VU2CY_ye~E# zzgInM;%Cgff|F;jp%G*8QG#Av3amhTT+3?(yfZ8uYagUd-KeZMLO1`j^U+jMh?w$Y zw1BEdA?{NxlV!Ro)xHjOGfnhq9yp8CPm;O?8F90^jRn-N*!+_SWFk{>F>loCTs>qv zIPuHW^lT>_@r)Q-3X1b!Wn#sdT+sP0dFCFA(>bnG$9ULNKr}*DXy!M2qAamS7o14GZoo4{YVI_Ma zLoT>Kf~nIRQ75&J-8k-UhwIZhjshW7|J=rmKOhL;oG6r2td}$voWZM9ydQtH6dYH! z=U$lTX_rdEQ8u_DyM}0Pj?WMywJd5VIY_p}q%IE4Lu!zpLq&AhC^R%Q0J>t%w#XSH z&@eHknHkjdXyTP0&LA{3Ypb0npwDB`xg~M-+OV}D+RtjW_^7#mq~#X3i1d* zC~fn=A|_@z=X)|nTilLQP=4SWuB^ec6R+QT7UGvY=d|Ax8D&1^3ixx9;b0)4v>63l zA2*09j~?xI$KU!bd-W~QS%WNmuZ37!Mh=nYl)SHT+JLX1JRlYknjx^D9Bv$j<8Q`? zhZx!f0Ei1^nq6`Pk@75Zd+!$|5N2N`B+Q@@2I=nyfDN@0N3SEk7_x!bSZZ)pQ=X73PihD<&-6g$Y^Z0ROBe|yooaaq7WCEKf z8+|X@Oh(ZZ4T16oPzLRNa5|0T++hleAw$BUf2gt42gUbH^h&o*6H3Cp@BG};_RX_{z*MxUADF%{EMNVeEClSRpOnH~jYQJkm1V67IG|fK!8D^+hY|M$!U#6n1U-d5=j5U zas9va|41Oqis26-S{DC5e>prLVKj2zA^$^q`d_2$Ls@BPZ5G{$l#zLgijF}*?E~KA zPsIPcPVtoPsckac9O`aR0o?#Tg!E@9=PJDBL$nJG&M-_yCNAJpz@vJ4f)>5q*#pJ%QZ~!A2v%S8(z=t|Xj75-KGG zh?mWH^%ii3H1Tvv4uOUtA4&Gqk6ArEQXV3(V18XJTwK{HV1pjQyFs4HxZ=TAq&Xf9 zDQPeRzjYrPb?Oq5vGEw`=nSM;nO~ZAz!s}gDv*MpZ+j|)CR0jd6=IP}FVK>N7=8?@ zk_nqoLM}0?(Ml;O2)axo=Isi;9F7TKt7f|cbnrKNU}hq?RfOr4{$zg?va3cIG5nAjsK3TD+L=v4kyFp*>3zdf zBrr6WsIP;a0Tyq-?$S!>xygz|&_+Ih(;&FKXsLBv49x?_3liYqrLba}?#2hSK-Ul( zc#Qy;Cc{AxZD0TncRD4kUu7aXm)OEkRT5ZH!sdp;z97RZ8!box5YNew{pHuIHQ8r99dM4G7_pXv{=f zPNIiOY4|)#o(`Dmstvs@XiJB5_hrCE=T~z zC2Su|boAznVWyNu%X{uAng6HZVg2|OY~c3FEW^wvE`VE72n!ci&yhhf z$qj=|B!nAyHIAjSW`{GC}4VeNL8S;%LuuW0qU!s!m zrAmjVv$@4 zI5T2Fa8x`Sg5=+f#5rKPiNBw1~M^gMCXPcB8QG6HNP zMBc;C6ao&bSZ5Z!knc?8YrK*EzWL6eOq!?l4N^yuK?`ynRpW`-(OVy!+lnshKGPPs zdksY_)h1Xyo9{NHWWXSNS7-_+rEajlzdtxCK~JO+TTYt2nH`}9P_08$5RzvueU%CWn{J=3%qkfQ zP!?TDfEliQe0p*E4tQNle}@8y#3-F8Dc@}f__B-Mi{-wI{sXxf!EF6AJCY}=-YgL8^i6H`J`8VqSgXvPzsTE z%Po{%{-h=*juUcuFxy+i_bw0eC%{1mN!;N^!i7pp{TLOW4M}IDJKoI32B(X}IET@^ zUU9V>y?clYJYeTVrVG8OOgZUA;g!KeH%Vf9`iRNMLQ{duq(N;R0~H5-uZ){hAeio9 zS>d@gt#M;aL=h++3c&>8hm!nJy5WilEVvov3u=dk*h@n9q3?%o3kN?ii16e-@+w{A z#r0fB^Pqb5w$S%vO~6hsU#qRVn;->iMIUZGAhat%L+Ujx0PIlc-uRZ5XuLrkKzAR9 zdx7P(PaMn=(j&Ys7=F;Eb?xl!nk|Qv_}EwP`cn3^lyyR1Lu}R z#PO$OB1_-7xn*Aq?C(0S9p-^vaM&)o$JyWH=SM~28~U;F$%!!U<*3wXN;GNkD*nlkLAZP9oVtXl`cMNFoCZ1F1iY1WXe!l-MP7QIxq>+kP%@B8(7Z|H! zOC0lwED*6^(YZ7KuuXp4EU*Dg&_Y?$0i@k&z_LG9Y%( zXhBL4L_|O(hLV(&Zd9aES`mYg5|II z_X5pR;!6yAWMTpeGMOy#;1YDq=%*=LR1;f5w#yNOS}amVOG-@C7d?&yh~ZJOU~NUa5Z?O&NHnrDKt7 zJwb$ZxcuV%Yw0JyqXznJlZc72ey{a{Y|BS?eMLfIVqydWT;=yG{bla1SenQuRqywq z-7Q=&5C+s%9?+j%aQ&aTpT2D1XE;7_NrSyNNs9fQn%EbFQh8u6u&TW_ z_>4{4I54N?U10+IJ+*wb>ixZ)cjR5^hN+_0uw^!dw@i<}A@oV&+r~Q$d`K)<0?Dbz zRQ5n*FLGWs%ZYFpu{b{qj59dyhu0e*$uzwpY`72-a9m(?YAU(YP|ZnKdiA^aC%uH> zVG}DgzjjExDj?U-zi*&A^z-^C50H&%{HK9_@r@00`_Y*RQc{n#U}?&7p229=!?Ubn zg~D)PwJhbdYy~v^&_%9rgK&k>)s9VpQ;@2wKW~d zu={?6E>v5r=Y|+}_}#0jKM=i>SN61bVXRosu;^t_X_b!9lX)(2RyLqZP-r$NPWfRc z`e2hwG{JDA`<%<_y8Y;K+K`M`ezU}GAt9E#QU5G%w6bi}G={swT7S~h^M34)EA}uY z4m(W8jM*;P^fzXHLYj7iS*i#c>< zjJ0lEpeppb*)#N6mMRIL5jh*=Ebpg@;+Rif`q3?Qzs}A*IZ=Xu zg%?$e)m%gje`+rCu*@B@{rK#sx-oj8yb7MnZ5NJYN1773td8c`zg=qH!7$LA(f3jP z?4iWGU^5uaLQ(vrFF5)$v`VU^8ymP`BQM!MMTUh<0!!&Q5+8L~(1SMlKZ*|jcz=k? zqAV$nYMjRp9Xl+wX6`bxaz;hizD*rIK$au%qlZg z$p~`YQf>Yk>x`Es?}m}I*{NV@B-I$9ir<+BfJSY_&2dBx-?I;C+vdNpaW-tNB8ArV z3JWi>0?QkJ2R!yFQrQ$!y{LP6`L$6*?RusCugyc_6@QEKaMlbeU+1pS^qcGwK2BxE z%nFmIzM^~<6hrecs~(Kr^D57m{&*q|JTbq9BAR?B)LEeMC5<}-FL3v#mX> z_>JI^Pse&!T_q|bkgx7>CGtNZOx)H_uV5lQFEiT7BSBIOK0TIm3h5|Xe;7Q^jF17~ zYJ2i*mfAl&C{9SUIYGP+s%jzc3PDx{&trHTO!9Pp6ngVQCFbZ&vyQ==-p1~S`}Gof z3yT3#ZOA3UWJrh|c@bON+uJ)kiISd1LyT-)|LMIcb5Sv~$I+o7AuY3=sZh%ciLNwz zQp!2DO(t4u^QH4_G{YRSk8Byeh_cB1Z=f69=Q_ZtG()_Bum`djV0Mata$Qj&^-s%s ziyEQ5BTDH;7Wgs?LrHn^!KwF!0>Kq8dO*~IujQTl4{!2se#I9?&nK&A^yk|TR(hX= ze#J@3E9>zFY5Kp~l3W=uFS*8O?>u5{!!>WB7yN2e>?oHsltjYRPsIE}+;x&RJfY$6 zH0Q;L&)a32HoiSvxi5!xbinCu%=|B?D3~WDq2v32KK6LJm9;+Gs*UUcP&uR*I`bC# zfGBa*ZPUrzE6beG+b%eQ$#37=NGDbAgt=Cr{>FSvX^9s79@D}eW^TkAW~}3xw`pLvR6Ct*C6uvup8-H2hC^? z5)v<{lLiWWR4x~Ga~5Jwq|p9^nC)IdbLuv*sVAsZ+te&i2MHPZSRU+e)$k=*L1zo! zuYB82YIy4=Z_ap4OHRu{K*7UaodOM}UeRXq5my3h+oxqc)?UxLZ)4%h^^0+TQoBIc75rLP3c6Dl+%UJTV&Dj%QpY{q_Vv{D)|^6pKC)0lQxCaH8jos0CD4-Huv=b>riwhaO~IIbI3*E+>Ymm5HF$NfNXdJu(G_ z$b{BQ)5My8?28@}@sz%Zc)kLM*-*&d%!l@Gc-DBQ%O$9MTa9{p->@=)cN+&x9iZ$;bll3KD7nr67< z{(5_Ff7_N1^;$c5|7XO5)k(cT!E4tTeoNxxAWnef>U?Hl>4PmNzCg<2QuX*;^fwt_ zntJDlYtvd2RpdWYG@tymX^wl>qwF5@a^$UTcd&7`MVtfSbAqi5;~~b$FZG{P!%a37 zqErzVzPYTPVx(7^+1<)5(g|O3il;M2W%~IlA-QdwH|A{&S?b6n%Z|UU9kpidsbEPr z+)<8FpgYuIYyv7_tXp@bknA&!!c26D=S3e`=t7@1#*m9fyIW(mQPwBJDk#2v&`lKr z(1rEa8-di1r*xJE@BLi-zBV&tqufegdp6emBf%TnXt-CeIhITEBw_7pJDsTsRfL2$ zf#zmf9}ZPDJ|{h&6+z$$g|F%{A96(yg>GgKUi8ij}=?6OcyvpDk#u2 zj*XC!%`{C^jT6q4^76$Q?Jq>Lq0#6^ ze%t%Y+e~HLZ9<%S+%v7R_@*J}j#bft>iTj!pYovx#_;soETqfb)-=cZ3$sHQe^H&$ zDx%xh6ji$M;@QO;>6<-S#=F~dQBZ~@&9pGy3u0U)bh&7)F-E5KBSptBmw{}U9@=fw z6b*pFo!Vf!I`23R8sqe2} zyf-UqAW8IDEYoT5^NA^l=)E)S5rrH$-f8^~-&Xe0`OO+HN~2Z2Z>w?c zp~8<*YrV8>zF85I3I$)rYl{Q& zB62~euI!gkPHFJ{c3amah)^gpOO&|RDP95g$kjF3XJ1qDvd69i6*RBkvduls2bkEP zsdJ=??Pj|ieu%c$FRX9!HBj1gM4%CdqhQtMsJU#ETX%Ft+{G<%-sjahQ zI*|?MD#h^-D2-*17F^>!@*A9FHOO(rSi#)BA(Aw=@;R^&(<(wbqaQN;Prl=O>i_{4 z@eK6_GC4-%5q_0mkvUz^IUYbq_~juL@g;{^KQ2($Up7gfjg z?dQ~%o0>@j=S~XlTG&R zd<^4=CrkUAh(qm+A3uXq@(YK?8Z@e3PSribVP^5RlAcT6LX(N-xUl|V{jH#3*+B&X zlME`thdv0aDaIQjnnr@l-1|No26`p!ep4d}KfBz6i{`e9el*qPhB1tS4y}L=(`)*m z<56vCDxCE4JKAw_BpszYJ9UC?&iOpu@7*>f6@s~o#O@0;)&x-%KX%EN4}!te)Jjp8 zgLu8$4*jA-N$S?Cv2^MBx|Cxaup&M|^Q5n9llrq=`-cp<(-Y0{d$r821BKZ#=jjx9 zoXHzR4f0jr-aLLh(kg(@hL$7z0rgWA2*h=m1eoP-pF(M;p~8w;QxXJ2__&6au0HBJ zJ1H{}$`XGjy>D!j`6(HJ`(mNxxSq=gb$-@Euh%aSMMSc`x}g@(Qj%f+BHufLuJ&9! zDs1!N?&dyUh+NFZ)C2S3>(JpAy8&X>w2wnzGW1vgP=D39^mbvM9J4~BEv-w|W0PUx z-9+GE8#2YCDluH!oG`OOXBUog82UWC7nG6x%YK8{%iAhP?sAL;F7}apntaPp&YJ5u z{EQ6$a!iO(o}FecGNzAWezGY|#@B=0K3*-n#&&sBlk*}giMNB|jjR}c-xRU?-mdW| zXu!!?$L~0Qx>u~XkyIVtxI@4%;@wJPjBX0$hm+5#z>gzsvfIC&Xd!zTf{@dZ;M(^5 z9+3_LgC&Hu#!_=gguJQUVh=C6AcODdRkPZ0RRMLl-3Jp(YMhdvj#$gKypoGTg9!y}u|m@%S-^K|`8$UpMA%OrT4Hb!ULmcu7OsfvYiD@!lD1wY;Rm19F3%Q|mqmWJ zrUF_83EC%&W^P#U2454bRU$*S@$76g$xs%>toWg<2TSr+3WAPt+eAY)1%;Amy8v!D z_^{dny2b@e}$I?1@U^K)<17T({Tzt0=pF|e>MYKhyfwMo= zoK`tJu@J9^Va#k3#{b30C9Z)~DRV3ADjt0LDHqh?k9H|gNX4E*_MKmhm7+W-VvIcs z{dx!P;hAWng$=?VPn5>w+uKL!*5y2szk9I-6pCZ%oa&>on)Q|#{s8WdugFHBAvey6 zVJ_&a8w64t$sTlCjLDb+jSZ9k5RLB!S2!ylycTtGM97X-cK3hxk({1fV-RTESm^NF z&5HUO=8Rcv-dJE@=>s=J@rwiA9K;{Mm;Z4UP!;Sr*`m+BoY3k<5 zgvqt#!GKztL?d-~RjHh;Ld{6@xwB{839=%Sj>9ik1!Z1!$raGeb;A*Bm7?kMHVDhvnh zR?DwGSoxR^(<-tT!5?b+u|WUsX4Z3X#;)A@=G(5EPaN9(mr|rbBJ64cVHjcfW-REG zt-91sG+ZdNACcnvWM|bA82OrqQ?)PAnlM)0*$^XTIk?mGM)Nk8>;eaYhy79x$jRv` zZ-QcqGT`$udIZ|N$H}p8f6UO&Ab_aycT2O?#JGuhl+USjpY8HhqTA@S^SB!(+T<5_ zXgQT&0K~}mqKn%2&_JW)USGZ`bZ7!W9sB8h$21*>&>?nXux`k+#diuu%@nPUESmeb`p2xDFo81MeJ8sZeU zY}TlvF-?~cvC>drlpc8alkOV}^;PC-v(@2B9}Xq)UW&=5z5^|i7`X(ORaAXQ(vPT+ z%1_&+U1^6H!c96K6Iz+yg~w4OOfoLp7{m+s^2{XW5<}D??(Rv;kw-sSa%qNzW-u6 znuL|AzhgkvqOJ?MQNYoi+*lWGvpQ6PKY3vH6#kC-EdOv^jGK8N-es%u(&*5t{Z9ns zr7`X$)?>R_KtD4@^R-!rUWZ*X0X8j zR5be&Wuyemj~~0yB!DvJ0GT<>sI(WLI4EP#rs5D5h&!uBb-{Ie&DxzRRQNnEz>XE5 zC{@s!ezJHFFprXx(bo(O4Rv=r0eA=bvs%k1O3!gG_-LbvMv)HRrdkrag>d|}v8WjF zp+#c!@w*LT1m(7^JU|FoUtI+qiYxI^LP8>&ic$Dt9K9NOvVS-UF>(7pe-S)oEH(4@ z*4j~fy>>hTHR5CxRh9MV9dB>%byca+*LXv?Nyo7u699*!G_W&Omza<$8XDvhG1%u~ zFtr3vl>fF=Y@0XsvkppFdG#ud3cdL(iQM5N_8n=+=8Cz_8{|j#6Zn^*f literal 0 HcmV?d00001 diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md new file mode 100644 index 00000000000..1efc518b778 --- /dev/null +++ b/docs/utilities/idempotency.md @@ -0,0 +1,323 @@ +--- +title: Idempotency +description: Utility +--- + +This utility provides a simple solution to convert your Lambda functions into idempotent operations which are safe to +retry. + +## Terminology + +The property of idempotency means that an operation does not cause additional side effects if it is called more than +once with the same input parameters. Idempotent operations will return the same result when they are called multiple +times with the same parameters. This makes idempotent operations safe to retry. + + +## Key features + +* Prevent Lambda handler code executing more than once on the same event payload during a time window +* Ensure Lambda handler returns the same result when called with the same payload +* Select a subset of the event as the idempotency key using JMESpath expressions +* Set a time window in which records with the same payload should be considered duplicates + +## Getting started + +### Required resources + +Before getting started, you need to create a DynamoDB table to store state used by the idempotency utility. Your lambda +functions will need read and write access to this table. + +> Example using AWS Serverless Application Model (SAM) + +=== "template.yml" +```yaml +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: python3.8 + ... + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref IdempotencyTable + IdempotencyTable: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: id + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: id + KeyType: HASH + TableName: "IdempotencyTable" + TimeToLiveSpecification: + AttributeName: expiration + Enabled: true +``` + +!!! note + When using this utility, each function invocation will generally make 2 requests to DynamoDB. If the result + returned by your Lambda is less than 1kb, you can expect 2 WCUs per invocation. For retried invocations, you will + see 1WCU and 1RCU. Review the [DynamoDB pricing documentation](https://aws.amazon.com/dynamodb/pricing/) to + estimate the cost. + + +### Lambda handler + +You can quickly start by initializing the `DynamoDBPersistenceLayer` class outside the Lambda handler, and using it +with the `idempotent` decorator on your lambda handler. There are 2 required parameters to initialize the persistence +layer: + +`table_name`: The name of the DynamoDB table to use. +`event_key_jmespath`: A JMESpath expression which will be used to extract the payload from the event your Lambda hander +is called with. This payload will be used as the key to decide if future invocations are duplicates. + +=== "app.py" + + ```python hl_lines="2 6-9 11" + import json + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + # Treat everything under the "body" key in + # the event json object as our payload + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable" + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + body = json.loads(event['body']) + payment = create_subscription_payment( + user=body['user'], + product=body['product_id'] + ) + ... + return {"message": "success", "statusCode": 200, "payment_id": payment.id} + ``` +=== "Example event" + + ```json + { + "version":"2.0", + "routeKey":"ANY /createpayment", + "rawPath":"/createpayment", + "rawQueryString":"", + "headers": { + "Header1": "value1", + "Header2": "value2" + }, + "requestContext":{ + "accountId":"123456789012", + "apiId":"api-id", + "domainName":"id.execute-api.us-east-1.amazonaws.com", + "domainPrefix":"id", + "http":{ + "method":"POST", + "path":"/createpayment", + "protocol":"HTTP/1.1", + "sourceIp":"ip", + "userAgent":"agent" + }, + "requestId":"id", + "routeKey":"ANY /createpayment", + "stage":"$default", + "time":"10/Feb/2021:13:40:43 +0000", + "timeEpoch":1612964443723 + }, + "body":"{\"username\":\"xyz\",\"product_id\":\"123456789\"}", + "isBase64Encoded":false + } + ``` + +In this example, we have a Lambda handler that creates a payment for a user subscribing to a product. We want to ensure +that we don't accidentally charge our customer by subscribing them more than once. Imagine the function executes +successfully, but the client never receives the response. When we're using the idempotent decorator, we can safely +retry. This sequence diagram shows an example flow of what happens in this case: + +![Idempotent sequence](../media/idempotent_sequence.png) + + +The client was successful in receiving the result after the retry. Since the Lambda handler was only executed once, our +customer hasn't been charged twice. + +!!! note + Bear in mind that the entire Lambda handler is treated as a single idempotent operation. If your Lambda handler can + cause multiple side effects, consider splitting it into separate functions. + +### Handling exceptions + +If your Lambda handler raises an unhandled exception, the record in the persistence layer will be deleted. This means +that if the client retries, your Lambda handler will be free to execute again. If you don't want the record to be +deleted, you need to catch Exceptions within the handler and return a successful response. + + +![Idempotent sequence exception](../media/idempotent_sequence_exception.png) + +!!! warning + If any of the calls to the persistence layer unexpectedly fail, `IdempotencyPersistenceLayerError` will be raised. + As this happens outside the scope of your Lambda handler, you are not able to catch it. + +### Setting a time window +In most cases, it is not desirable to store the idempotency records forever. Rather, you want to guarantee that the +same payload won't be executed within a period of time. By default, the period is set to 1 hour (3600 seconds). You can +change this window with the `expires_after_seconds` parameter: + +```python hl_lines="4" +DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + expires_after_seconds=5*60 # 5 minutes + ) + +``` +This will mark any records older than 5 minutes expired, and the lambda handler will be executed as normal if it is +invoked with a matching payload. If you have set the TTL field in DynamoDB like in the SAM example above, the record +will be automatically deleted from the table after a period of itme. + + +### Using local cache +To reduce the number of lookups to the persistence storage layer, you can enable in memory caching with the +`use_local_cache` parameter, which is disabled by default. This cache is local to each Lambda execution environment. +This means it will be effective in cases where your function's concurrency is low in comparison to the number of +"retry" invocations with the same payload. When enabled, the default is to cache a maxmum of 256 records in each Lambda +execution environment. You can change this with the `local_cache_max_items` parameter. + +```python hl_lines="4 5" +DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + use_local_cache=True, + local_cache_max_items=1000 + ) +``` + + +## Advanced + +### Payload validation +What happens if lambda is invoked with a payload that it has seen before, but some parameters which are not part of the +payload have changed? By default, lambda will return the same result as it returned before, which may be misleading. +Payload validation provides a solution to that. You can provide another JMESpath expression to the persistence store +with the `payload_validation_jmespath` to specify which part of the event body should be validated against previous +idempotent invocations. + +=== "app.py" + ```python hl_lines="6" + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="[userDetail, productId]", + table_name="IdempotencyTable",) + payload_validation_jmespath="amount" + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + # Creating a subscription payment is a side + # effect of calling this function! + payment = create_subscription_payment( + user=event['userDetail']['username'], + product=event['product_id'], + amount=event['amount'] + ) + ... + return {"message": "success", "statusCode": 200, + "payment_id": payment.id, "amount": payment.amount} + ``` +=== "Event" + ```json + { + "userDetail": { + "username": "User1", + "user_email": "user@example.com" + }, + "productId": 1500, + "charge_type": "subscription", + "amount": 500 + } + ``` + +In this example, the "userDetail" and "productId" keys are used as the payload to generate the idempotency key. If +we try to send the same request but with a different amount, Lambda will raise `IdempotencyValidationError`. Without +payload validation, we would have returned the same result as we did for the initial request. Since we're also +returning an amount in the response, this could be quite confusing for the client. By using payload validation on the +amount field, we prevent this potentially confusing behaviour and instead raise an Exception. + +### Changing dynamoDB attribute names +If you want to use an existing DynamoDB table, or wish to change the name of the attributes used to store items in the +table, you can do so when you construct the `DynamoDBPersistenceLayer` instance. + + +Parameter | Default value | Description +------------------- |--------------- | ------------ +key_attr | "id" | Primary key of the table. Hashed representation of the payload +expiry_attr | "expiration" | Unix timestamp of when record expires +status_attr | "status" | Stores status of the lambda execution during and after invocation +data_attr | "data" | Stores results of successfully executed Lambda handlers +validation_key_attr | "validation" | Hashed representation of the parts of the event used for validation + +This example demonstrates changing the attribute names to custom values: + +=== "app.py" + ```python hl_lines="5-10" + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="[userDetail, productId]", + table_name="IdempotencyTable",) + key_attr="idempotency_key", + expiry_attr="expires_at", + status_attr="current_status", + data_attr="result_data", + validation_key_attr="validation_key" + ) + ``` + +### Customizing boto configuration +You can provide custom boto configuration or event bring your own boto3 session if required by using the `boto_config` +or `boto3_session` parameters when constructing the persistence store. + +=== "Custom session" + ```python hl_lines="1 4 8" + import boto3 + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + boto3_session = boto3.session.Session() + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + boto3_session=boto3_session + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + ... + ``` +=== "Custom config" + ```python hl_lines="1 4 8" + from botocore.config import Config + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + boto_config = Config() + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + boto_config=boto_config + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + ... + ``` + +### Bring your own persistent store + +The utility provides an abstract base class which can be used to implement your choice of persistent storage layers. +You can inherit from the `BasePersistenceLayer` class and implement the abstract methods `_get_record`, `_put_record`, +`_update_record` and `_delete_record`. Pay attention to the documentation for each - you may need to perform additional +checks inside these methods to ensure the idempotency guarantees remain intact. For example, the `_put_record` method +needs to raise an exception if a non-expired record already exists in the data store with a matching key. + +## Extra resources +If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out +[this article](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/). diff --git a/mkdocs.yml b/mkdocs.yml index b3030c6b429..c8d1beda6a7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ nav: - utilities/validation.md - utilities/data_classes.md - utilities/parser.md + - utilities/idempotency.md theme: name: material From 88e983e4abfeb772f17ea2fb2d3b227a892951a6 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Sun, 14 Feb 2021 18:04:45 +0100 Subject: [PATCH 29/44] fix: Allow event_key_jmespath to be left empty to use entire event as payload --- .../utilities/idempotency/persistence/base.py | 9 ++++++--- docs/utilities/idempotency.md | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 84e74f81832..34470ddd3e8 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -114,7 +114,7 @@ class BasePersistenceLayer(ABC): def __init__( self, - event_key_jmespath: str, + event_key_jmespath: str = "", payload_validation_jmespath: str = "", expires_after_seconds: int = 60 * 60, # 1 hour default use_local_cache: bool = False, @@ -140,7 +140,8 @@ def __init__( Function to use for calculating hashes, by default md5. """ self.event_key_jmespath = event_key_jmespath - self.event_key_compiled_jmespath = jmespath.compile(event_key_jmespath) + if self.event_key_jmespath: + self.event_key_compiled_jmespath = jmespath.compile(event_key_jmespath) self.expires_after_seconds = expires_after_seconds self.use_local_cache = use_local_cache if self.use_local_cache: @@ -166,7 +167,9 @@ def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: Hashed representation of the data extracted by the jmespath expression """ - data = self.event_key_compiled_jmespath.search(lambda_event) + data = lambda_event + if self.event_key_jmespath: + data = self.event_key_compiled_jmespath.search(lambda_event) return self._generate_hash(data) def _get_hashed_payload(self, lambda_event: Dict[str, Any]) -> str: diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 1efc518b778..41e2e9595fb 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -66,12 +66,12 @@ Resources: ### Lambda handler You can quickly start by initializing the `DynamoDBPersistenceLayer` class outside the Lambda handler, and using it -with the `idempotent` decorator on your lambda handler. There are 2 required parameters to initialize the persistence -layer: +with the `idempotent` decorator on your lambda handler. The only required parameter is `table_name`, but you likely +want to specify `event_key_jmespath` as well. -`table_name`: The name of the DynamoDB table to use. `event_key_jmespath`: A JMESpath expression which will be used to extract the payload from the event your Lambda hander -is called with. This payload will be used as the key to decide if future invocations are duplicates. +is called with. This payload will be used as the key to decide if future invocations are duplicates. If you don't pass +this parameter, the entire event will be used as the key. === "app.py" From d17275d832fe0cad123b0ff7505b7be63c3f4389 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Sun, 14 Feb 2021 18:09:24 +0100 Subject: [PATCH 30/44] docs: add section for compatibility with other utils --- docs/utilities/idempotency.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 41e2e9595fb..9358bc55aaa 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -318,6 +318,34 @@ You can inherit from the `BasePersistenceLayer` class and implement the abstract checks inside these methods to ensure the idempotency guarantees remain intact. For example, the `_put_record` method needs to raise an exception if a non-expired record already exists in the data store with a matching key. +## Compatibility with other utilities + +### Validation utility + +The idempotency utility can be used with the `validator` decorator. Ensure that idempotency is the innermost decorator. + +!!! warning + If you use an envelope with the validator, the event received by the idempotency utility will be the unwrapped + event - not the "raw" event Lambda was invoked with. You will need to account for this if you set the + `event_key_jmespath`. + +=== "app.py" + ```python hl_lines="9 10" + from aws_lambda_powertools.utilities.validation import validator, envelopes + from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent + + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="[message, username]", + table_name="IdempotencyTable", + ) + + @validator(envelope=envelopes.API_GATEWAY_HTTP) + @idempotent(persistence_store=persistence_layer) + def lambda_handler(event, context): + cause_some_side_effects(event['username') + return {"message": event['message'], "statusCode": 200} + ``` + ## Extra resources If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out [this article](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/). From 83d78ceea5d12d61e781bb981f37ad4575196151 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Mon, 15 Feb 2021 16:15:05 +0100 Subject: [PATCH 31/44] chore: improvements to func tests --- .../utilities/idempotency/idempotency.py | 10 +- tests/events/apiGatewayProxyV2Event.json | 2 +- tests/functional/idempotency/conftest.py | 24 +++++ .../idempotency/test_idempotency.py | 91 ++++++++++++++++++- 4 files changed, 114 insertions(+), 13 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index e4bd0ae166d..0d7224147b8 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -23,10 +23,6 @@ logger = logging.getLogger(__name__) -def default_error_callback(): - raise - - @lambda_handler_decorator def idempotent( handler: Callable[[Any, LambdaContext], Any], @@ -107,7 +103,6 @@ def __init__( self.event = event self.lambda_handler = lambda_handler self.max_handler_retries = 2 - self.idempotency_key: Optional[str] = None def handle(self) -> Any: """ @@ -158,7 +153,6 @@ def _get_idempotency_record(self) -> DataRecord: except Exception as exc: raise IdempotencyPersistenceLayerError("Failed to get record from idempotency store") from exc - self.idempotency_key = event_record.idempotency_key return event_record def _handle_for_status(self, event_record: DataRecord) -> Optional[Dict[Any, Any]]: @@ -212,7 +206,7 @@ def _call_lambda(self) -> Any: self.persistence_store.delete_record(event=self.event, exception=handler_exception) except Exception as delete_exception: raise IdempotencyPersistenceLayerError( - f"Failed to delete record with idempotency key: {self.idempotency_key}" + "Failed to delete record from idempotency store" ) from delete_exception raise @@ -221,7 +215,7 @@ def _call_lambda(self) -> Any: self.persistence_store.save_success(event=self.event, result=handler_response) except Exception as save_exception: raise IdempotencyPersistenceLayerError( - f"Failed to update record state to success with " f"idempotency key: {self.idempotency_key}" + "Failed to update record state to success in idempotency store" ) from save_exception return handler_response diff --git a/tests/events/apiGatewayProxyV2Event.json b/tests/events/apiGatewayProxyV2Event.json index 9c310e6d52f..4d0cfdf5703 100644 --- a/tests/events/apiGatewayProxyV2Event.json +++ b/tests/events/apiGatewayProxyV2Event.json @@ -45,7 +45,7 @@ "time": "12/Mar/2020:19:03:58 +0000", "timeEpoch": 1583348638390 }, - "body": "Hello from Lambda", + "body": "{\"message\": \"hello world\", \"username\": \"tom\"}", "pathParameters": { "parameter1": "value1" }, diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 174bb49a2b9..91e2e2d9a65 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -2,6 +2,7 @@ import hashlib import json import os +from unittest import mock import jmespath import pytest @@ -9,6 +10,8 @@ from botocore.config import Config from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer +from aws_lambda_powertools.utilities.validation import envelopes +from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope TABLE_NAME = "TEST_TABLE" @@ -119,6 +122,14 @@ def hashed_idempotency_key(lambda_apigw_event, default_jmespath): return hashlib.md5(json.dumps(data).encode()).hexdigest() +@pytest.fixture +def hashed_idempotency_key_with_envelope(lambda_apigw_event): + event = unwrap_event_from_envelope( + data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, jmespath_options={} + ) + return hashlib.md5(json.dumps(event).encode()).hexdigest() + + @pytest.fixture def hashed_validation_key(lambda_apigw_event): return hashlib.md5(json.dumps(lambda_apigw_event["requestContext"]).encode()).hexdigest() @@ -135,6 +146,14 @@ def persistence_store(config, request, default_jmespath): return persistence_store +@pytest.fixture +def persistence_store_without_jmespath(config, request): + persistence_store = DynamoDBPersistenceLayer( + table_name=TABLE_NAME, boto_config=config, use_local_cache=request.param["use_local_cache"], + ) + return persistence_store + + @pytest.fixture def persistence_store_with_validation(config, request, default_jmespath): persistence_store = DynamoDBPersistenceLayer( @@ -145,3 +164,8 @@ def persistence_store_with_validation(config, request, default_jmespath): payload_validation_jmespath="requestContext", ) return persistence_store + + +@pytest.fixture +def mock_function(): + return mock.MagicMock() diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index b4f1a38acba..6381b11cd50 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -11,6 +11,7 @@ IdempotencyValidationError, ) from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent +from aws_lambda_powertools.utilities.validation import envelopes, validator TABLE_NAME = "TEST_TABLE" @@ -402,7 +403,7 @@ def lambda_handler(event, context): @pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) -def test_idempotent_persistence_exception_updating( +def test_idempotent_persistence_exception_deleting( persistence_store, lambda_apigw_event, timestamp_future, @@ -426,15 +427,16 @@ def test_idempotent_persistence_exception_updating( def lambda_handler(event, context): raise Exception("Something went wrong!") - with pytest.raises(IdempotencyPersistenceLayerError): + with pytest.raises(IdempotencyPersistenceLayerError) as exc: lambda_handler(lambda_apigw_event, {}) + assert exc.value.args[0] == "Failed to delete record from idempotency store" stubber.assert_no_pending_responses() stubber.deactivate() @pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) -def test_idempotent_persistence_exception_deleting( +def test_idempotent_persistence_exception_updating( persistence_store, lambda_apigw_event, timestamp_future, @@ -458,9 +460,41 @@ def test_idempotent_persistence_exception_deleting( def lambda_handler(event, context): return {"message": "success!"} - with pytest.raises(IdempotencyPersistenceLayerError): + with pytest.raises(IdempotencyPersistenceLayerError) as exc: lambda_handler(lambda_apigw_event, {}) + assert exc.value.args[0] == "Failed to update record state to success in idempotency store" + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_persistence_exception_getting( + persistence_store, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + expected_params_put_item, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception which is retryable. + """ + stubber = stub.Stubber(persistence_store.table.meta.client) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_client_error("get_item", "UnexpectedException") + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return {"message": "success!"} + + with pytest.raises(IdempotencyPersistenceLayerError) as exc: + lambda_handler(lambda_apigw_event, {}) + + assert exc.value.args[0] == "Failed to get record from idempotency store" stubber.assert_no_pending_responses() stubber.deactivate() @@ -495,3 +529,52 @@ def lambda_handler(lambda_apigw_event, context): stubber.assert_no_pending_responses() stubber.deactivate() + + +@pytest.mark.parametrize( + "persistence_store_without_jmespath", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True +) +def test_idempotent_lambda_with_validator_util( + persistence_store_without_jmespath, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key_with_envelope, + mock_function, +): + """ + Test idempotent decorator where event with matching event key has already been succesfully processed, using the + validator utility to unwrap the event + """ + + stubber = stub.Stubber(persistence_store_without_jmespath.table.meta.client) + ddb_response = { + "Item": { + "id": {"S": hashed_idempotency_key_with_envelope}, + "expiration": {"N": timestamp_future}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "COMPLETED"}, + } + } + + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": hashed_idempotency_key_with_envelope}, + "ConsistentRead": True, + } + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @validator(envelope=envelopes.API_GATEWAY_HTTP) + @idempotent(persistence_store=persistence_store_without_jmespath) + def lambda_handler(event, context): + mock_function() + return "shouldn't get here!" + + mock_function.assert_not_called() + lambda_resp = lambda_handler(lambda_apigw_event, {}) + assert lambda_resp == lambda_response + + stubber.assert_no_pending_responses() + stubber.deactivate() From 4bdfdf60b96dfd0461730ce271c7e5f5d027c488 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Mon, 15 Feb 2021 16:15:21 +0100 Subject: [PATCH 32/44] chore: add unit tests for lru cache --- tests/unit/test_lru_cache.py | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/unit/test_lru_cache.py diff --git a/tests/unit/test_lru_cache.py b/tests/unit/test_lru_cache.py new file mode 100644 index 00000000000..170972432ce --- /dev/null +++ b/tests/unit/test_lru_cache.py @@ -0,0 +1,58 @@ +import random + +import pytest + +from aws_lambda_powertools.shared.cache_dict import LRUDict + +MAX_CACHE_ITEMS = 50 +PREFILL_CACHE_ITEMS = 50 + + +@pytest.fixture +def populated_cache(): + cache_dict = LRUDict(max_items=MAX_CACHE_ITEMS, **{f"key_{i}": f"val_{i}" for i in range(0, PREFILL_CACHE_ITEMS)}) + return cache_dict + + +def test_cache_order_init(populated_cache): + first_item = list(populated_cache)[0] + last_item = list(populated_cache)[-1] + + assert first_item == "key_0" + assert last_item == f"key_{MAX_CACHE_ITEMS - 1}" + + +def test_cache_order_getitem(populated_cache): + random_value = random.randrange(0, MAX_CACHE_ITEMS) + _ = populated_cache[f"key_{random_value}"] + + last_item = list(populated_cache)[-1] + + assert last_item == f"key_{random_value}" + + +def test_cache_order_get(populated_cache): + random_value = random.randrange(0, MAX_CACHE_ITEMS) + _ = populated_cache.get(f"key_{random_value}") + + last_item = list(populated_cache)[-1] + + assert last_item == f"key_{random_value}" + + +def test_cache_evict_over_max_items(populated_cache): + assert "key_0" in populated_cache + assert len(populated_cache) == MAX_CACHE_ITEMS + populated_cache["new_item"] = "new_value" + assert len(populated_cache) == MAX_CACHE_ITEMS + assert "key_0" not in populated_cache + assert "key_1" in populated_cache + + +def test_setitem_moves_to_end(populated_cache): + random_value = random.randrange(0, MAX_CACHE_ITEMS) + populated_cache[f"key_{random_value}"] = f"new_val_{random_value}" + last_item = list(populated_cache)[-1] + + assert last_item == f"key_{random_value}" + assert populated_cache[f"key_{random_value}"] == f"new_val_{random_value}" From 9de6e29db44a4ad2797a21b2c4d0bfe90389507e Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 12:15:19 +0100 Subject: [PATCH 33/44] feat: add support for decimals in json serializer --- aws_lambda_powertools/shared/json_encoder.py | 16 +++++++++++++ .../utilities/idempotency/persistence/base.py | 5 ++-- tests/functional/idempotency/conftest.py | 24 +++++++++++++++---- .../idempotency/test_idempotency.py | 20 +++++++++++----- tests/unit/test_json_encoder.py | 14 +++++++++++ 5 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 aws_lambda_powertools/shared/json_encoder.py create mode 100644 tests/unit/test_json_encoder.py diff --git a/aws_lambda_powertools/shared/json_encoder.py b/aws_lambda_powertools/shared/json_encoder.py new file mode 100644 index 00000000000..32a094abd85 --- /dev/null +++ b/aws_lambda_powertools/shared/json_encoder.py @@ -0,0 +1,16 @@ +import decimal +import json +import math + + +class Encoder(json.JSONEncoder): + """ + Custom JSON encoder to allow for serialization of Decimals, similar to the serializer used by Lambda internally. + """ + + def default(self, obj): + if isinstance(obj, decimal.Decimal): + if obj.is_nan(): + return math.nan + return str(obj) + return super().default(obj) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 34470ddd3e8..038de9baee3 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -12,6 +12,7 @@ import jmespath from aws_lambda_powertools.shared.cache_dict import LRUDict +from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyInvalidStatusError, IdempotencyItemAlreadyExistsError, @@ -207,7 +208,7 @@ def _generate_hash(self, data: Any) -> str: Hashed representation of the provided data """ - hashed_data = self.hash_function(json.dumps(data).encode()) + hashed_data = self.hash_function(json.dumps(data, cls=Encoder).encode()) return hashed_data.hexdigest() def _validate_payload(self, lambda_event: Dict[str, Any], data_record: DataRecord) -> None: @@ -271,7 +272,7 @@ def save_success(self, event: Dict[str, Any], result: dict) -> None: result: dict The response from lambda handler """ - response_data = json.dumps(result) + response_data = json.dumps(result, cls=Encoder) data_record = DataRecord( idempotency_key=self._get_hashed_idempotency_key(event), diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 91e2e2d9a65..918eac9a507 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -2,6 +2,7 @@ import hashlib import json import os +from decimal import Decimal from unittest import mock import jmespath @@ -9,6 +10,7 @@ from botocore import stub from botocore.config import Config +from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer from aws_lambda_powertools.utilities.validation import envelopes from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope @@ -44,7 +46,17 @@ def timestamp_expired(): @pytest.fixture(scope="module") def lambda_response(): - return {"message": "test", "statusCode": 200} + return {"message": "test", "statusCode": 200, "decimal_val": Decimal("2.5"), "decimal_NaN": Decimal("NaN")} + + +@pytest.fixture(scope="module") +def serialized_lambda_response(lambda_response): + return json.dumps(lambda_response, cls=Encoder) + + +@pytest.fixture(scope="module") +def deserialized_lambda_response(lambda_response): + return json.loads(json.dumps(lambda_response, cls=Encoder)) @pytest.fixture @@ -53,12 +65,12 @@ def default_jmespath(): @pytest.fixture -def expected_params_update_item(lambda_response, hashed_idempotency_key): +def expected_params_update_item(serialized_lambda_response, hashed_idempotency_key): return { "ExpressionAttributeNames": {"#expiry": "expiration", "#response_data": "data", "#status": "status"}, "ExpressionAttributeValues": { ":expiry": stub.ANY, - ":response_data": json.dumps(lambda_response), + ":response_data": serialized_lambda_response, ":status": "COMPLETED", }, "Key": {"id": hashed_idempotency_key}, @@ -68,7 +80,9 @@ def expected_params_update_item(lambda_response, hashed_idempotency_key): @pytest.fixture -def expected_params_update_item_with_validation(lambda_response, hashed_idempotency_key, hashed_validation_key): +def expected_params_update_item_with_validation( + serialized_lambda_response, hashed_idempotency_key, hashed_validation_key +): return { "ExpressionAttributeNames": { "#expiry": "expiration", @@ -78,7 +92,7 @@ def expected_params_update_item_with_validation(lambda_response, hashed_idempote }, "ExpressionAttributeValues": { ":expiry": stub.ANY, - ":response_data": json.dumps(lambda_response), + ":response_data": serialized_lambda_response, ":status": "COMPLETED", ":validation_key": hashed_validation_key, }, diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 6381b11cd50..fd1e8b8a1ca 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -20,7 +20,12 @@ # enabled, and one without. @pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_already_completed( - persistence_store, lambda_apigw_event, timestamp_future, lambda_response, hashed_idempotency_key, + persistence_store, + lambda_apigw_event, + timestamp_future, + hashed_idempotency_key, + serialized_lambda_response, + deserialized_lambda_response, ): """ Test idempotent decorator where event with matching event key has already been succesfully processed @@ -31,7 +36,7 @@ def test_idempotent_lambda_already_completed( "Item": { "id": {"S": hashed_idempotency_key}, "expiration": {"N": timestamp_future}, - "data": {"S": '{"message": "test", "statusCode": 200}'}, + "data": {"S": serialized_lambda_response}, "status": {"S": "COMPLETED"}, } } @@ -50,7 +55,7 @@ def lambda_handler(event, context): raise Exception lambda_resp = lambda_handler(lambda_apigw_event, {}) - assert lambda_resp == lambda_response + assert lambda_resp == deserialized_lambda_response stubber.assert_no_pending_responses() stubber.deactivate() @@ -163,6 +168,8 @@ def test_idempotent_lambda_first_execution( expected_params_update_item, expected_params_put_item, lambda_response, + serialized_lambda_response, + deserialized_lambda_response, hashed_idempotency_key, ): """ @@ -538,7 +545,8 @@ def test_idempotent_lambda_with_validator_util( persistence_store_without_jmespath, lambda_apigw_event, timestamp_future, - lambda_response, + serialized_lambda_response, + deserialized_lambda_response, hashed_idempotency_key_with_envelope, mock_function, ): @@ -552,7 +560,7 @@ def test_idempotent_lambda_with_validator_util( "Item": { "id": {"S": hashed_idempotency_key_with_envelope}, "expiration": {"N": timestamp_future}, - "data": {"S": '{"message": "test", "statusCode": 200}'}, + "data": {"S": serialized_lambda_response}, "status": {"S": "COMPLETED"}, } } @@ -574,7 +582,7 @@ def lambda_handler(event, context): mock_function.assert_not_called() lambda_resp = lambda_handler(lambda_apigw_event, {}) - assert lambda_resp == lambda_response + assert lambda_resp == deserialized_lambda_response stubber.assert_no_pending_responses() stubber.deactivate() diff --git a/tests/unit/test_json_encoder.py b/tests/unit/test_json_encoder.py new file mode 100644 index 00000000000..8d6a9f3944c --- /dev/null +++ b/tests/unit/test_json_encoder.py @@ -0,0 +1,14 @@ +import decimal +import json + +from aws_lambda_powertools.shared.json_encoder import Encoder + + +def test_jsonencode_decimal(): + result = json.dumps({"val": decimal.Decimal("8.5")}, cls=Encoder) + assert result == '{"val": "8.5"}' + + +def test_jsonencode_decimal_nan(): + result = json.dumps({"val": decimal.Decimal("NaN")}, cls=Encoder) + assert result == '{"val": NaN}' From a4cc61a9f53087b53812bd1200c0090cbcddcd6f Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 12:21:21 +0100 Subject: [PATCH 34/44] chore: Add docstring for LRU cache dict --- aws_lambda_powertools/shared/cache_dict.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/aws_lambda_powertools/shared/cache_dict.py b/aws_lambda_powertools/shared/cache_dict.py index 79175d7b1a8..d079a9153ec 100644 --- a/aws_lambda_powertools/shared/cache_dict.py +++ b/aws_lambda_powertools/shared/cache_dict.py @@ -2,6 +2,11 @@ class LRUDict(OrderedDict): + """ + Cache implementation based on ordered dict with a maximum number of items. Last accessed item will be evicted + first. Currently used by idempotency utility. + """ + def __init__(self, max_items=1024, *args, **kwds): self.max_items = max_items super().__init__(*args, **kwds) From 978a6bb2b0acf3a235c59071abcc3a514b3ecefc Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 12:26:50 +0100 Subject: [PATCH 35/44] chore: Remove unused status constants --- .../utilities/idempotency/persistence/base.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 038de9baee3..3604ee08c6c 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -21,13 +21,7 @@ logger = logging.getLogger(__name__) -STATUS_CONSTANTS = { - "NOTEXISTING": "DOESNOTEXIST", - "INPROGRESS": "INPROGRESS", - "COMPLETED": "COMPLETED", - "EXPIRED": "EXPIRED", - "ERROR": "ERROR", -} +STATUS_CONSTANTS = {"INPROGRESS": "INPROGRESS", "COMPLETED": "COMPLETED", "EXPIRED": "EXPIRED"} class DataRecord: From 1fd8b5abec18c17124f66061bedcc6c71bbba22a Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 12:28:02 +0100 Subject: [PATCH 36/44] chore: Rename method for clarity --- aws_lambda_powertools/utilities/idempotency/idempotency.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 0d7224147b8..ec9be05b0b2 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -123,7 +123,7 @@ def handle(self) -> Any: record = self._get_idempotency_record() return self._handle_for_status(record) - return self._call_lambda() + return self._call_lambda_handler() def _get_idempotency_record(self) -> DataRecord: """ @@ -187,7 +187,7 @@ def _handle_for_status(self, event_record: DataRecord) -> Optional[Dict[Any, Any return event_record.response_json_as_dict() - def _call_lambda(self) -> Any: + def _call_lambda_handler(self) -> Any: """ Call the lambda handler function and update the persistence store appropriate depending on the output From 022739e28a678aa4e18c986b5d63d13e34514e55 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 12:28:58 +0100 Subject: [PATCH 37/44] chore: Correct example in docstring --- aws_lambda_powertools/utilities/idempotency/idempotency.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index ec9be05b0b2..bc556f49912 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -49,7 +49,7 @@ def idempotent( **Processes Lambda's event in an idempotent manner** >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer >>> - >>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store") + >>> persistence_store = DynamoDBPersistenceLayer(event_key_jmespath="body", table_name="idempotency_store") >>> >>> @idempotent(persistence_store=persistence_store) >>> def handler(event, context): From 8a2d4fe786374d20070e42ecb62dad68d6f3bde8 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 15:41:41 +0100 Subject: [PATCH 38/44] fix: make data attribute of data record optional in get_record so we don't throw the wrong error for INPROGRESS --- .../utilities/idempotency/persistence/dynamodb.py | 2 +- tests/functional/idempotency/test_idempotency.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index 8d5cef68f34..4d66448755d 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -95,7 +95,7 @@ def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord: idempotency_key=item[self.key_attr], status=item[self.status_attr], expiry_timestamp=item[self.expiry_attr], - response_data=item[self.data_attr], + response_data=item.get(self.data_attr), payload_hash=item.get(self.validation_key_attr), ) diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index fd1e8b8a1ca..a06c839b9fd 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -80,7 +80,6 @@ def test_idempotent_lambda_in_progress( "Item": { "id": {"S": hashed_idempotency_key}, "expiration": {"N": timestamp_future}, - "data": {"S": '{"message": "test", "statusCode": 200}'}, "status": {"S": "INPROGRESS"}, } } @@ -126,7 +125,6 @@ def test_idempotent_lambda_in_progress_with_cache( "Item": { "id": {"S": hashed_idempotency_key}, "expiration": {"N": timestamp_future}, - "data": {"S": '{"message": "test", "statusCode": 200}'}, "status": {"S": "INPROGRESS"}, } } From e6f2d9877252c82a1c808b1b645755f0ce2e879e Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 15:54:17 +0100 Subject: [PATCH 39/44] docs: clarify behaviour for concurrent executions and DDB behaviour for large items --- docs/utilities/idempotency.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 9358bc55aaa..1c6555088d9 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -24,8 +24,9 @@ times with the same parameters. This makes idempotent operations safe to retry. ### Required resources -Before getting started, you need to create a DynamoDB table to store state used by the idempotency utility. Your lambda -functions will need read and write access to this table. +Before getting started, you need to create a persistent storage layer where the idempotency utility can store its +state. Your lambda functions will need read and write access to it. DynamoDB is currently the only supported persistent +storage layer, so you'll need to create a table first. > Example using AWS Serverless Application Model (SAM) @@ -56,13 +57,16 @@ Resources: Enabled: true ``` -!!! note - When using this utility, each function invocation will generally make 2 requests to DynamoDB. If the result - returned by your Lambda is less than 1kb, you can expect 2 WCUs per invocation. For retried invocations, you will +!!! warning + When using this utility with DynamoDB, your lambda responses must always be smaller than 400kb. Larger items cannot + be written to DynamoDB and will cause exceptions. + +!!! info + Each function invocation will generally make 2 requests to DynamoDB. If the + result returned by your Lambda is less than 1kb, you can expect 2 WCUs per invocation. For retried invocations, you will see 1WCU and 1RCU. Review the [DynamoDB pricing documentation](https://aws.amazon.com/dynamodb/pricing/) to estimate the cost. - ### Lambda handler You can quickly start by initializing the `DynamoDBPersistenceLayer` class outside the Lambda handler, and using it @@ -172,11 +176,18 @@ DynamoDBPersistenceLayer( ) ``` -This will mark any records older than 5 minutes expired, and the lambda handler will be executed as normal if it is +This will mark any records older than 5 minutes as expired, and the lambda handler will be executed as normal if it is invoked with a matching payload. If you have set the TTL field in DynamoDB like in the SAM example above, the record will be automatically deleted from the table after a period of itme. +### Handling concurrent executions +If you invoke a Lambda function with a given payload, then try to invoke it again with the same payload before the +first invocation has finished, we'll raise an `IdempotencyAlreadyInProgressError` exception. This is the utility's +locking mechanism at work. Since we don't know the result from the first invocation yet, we can't safely allow another +concurrent execution. If you receive this error, you can safely retry the operation. + + ### Using local cache To reduce the number of lookups to the persistence storage layer, you can enable in memory caching with the `use_local_cache` parameter, which is disabled by default. This cache is local to each Lambda execution environment. From 4aa814597167ec1027e585938033e82a9481092f Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 17:09:43 +0100 Subject: [PATCH 40/44] Update aws_lambda_powertools/shared/cache_dict.py Co-authored-by: Michael Brewer --- aws_lambda_powertools/shared/cache_dict.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/shared/cache_dict.py b/aws_lambda_powertools/shared/cache_dict.py index d079a9153ec..09454c28924 100644 --- a/aws_lambda_powertools/shared/cache_dict.py +++ b/aws_lambda_powertools/shared/cache_dict.py @@ -9,7 +9,7 @@ class LRUDict(OrderedDict): def __init__(self, max_items=1024, *args, **kwds): self.max_items = max_items - super().__init__(*args, **kwds) + super().__init__(*args, **kwargs) def __getitem__(self, key): value = super().__getitem__(key) From c54952c0cffa77958e470837796f17360d753319 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 17:09:53 +0100 Subject: [PATCH 41/44] Update aws_lambda_powertools/shared/cache_dict.py Co-authored-by: Michael Brewer --- aws_lambda_powertools/shared/cache_dict.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/shared/cache_dict.py b/aws_lambda_powertools/shared/cache_dict.py index 09454c28924..d7184cc1e2b 100644 --- a/aws_lambda_powertools/shared/cache_dict.py +++ b/aws_lambda_powertools/shared/cache_dict.py @@ -7,7 +7,7 @@ class LRUDict(OrderedDict): first. Currently used by idempotency utility. """ - def __init__(self, max_items=1024, *args, **kwds): + def __init__(self, max_items=1024, *args, **kwargs): self.max_items = max_items super().__init__(*args, **kwargs) From 7832e568488062ae6a72bc546dfd66c010b7f45d Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 17:12:06 +0100 Subject: [PATCH 42/44] Update aws_lambda_powertools/utilities/idempotency/persistence/base.py Co-authored-by: Michael Brewer --- .../utilities/idempotency/persistence/base.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 3604ee08c6c..7eb5277b418 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -246,11 +246,10 @@ def _save_to_cache(self, data_record: DataRecord): def _retrieve_from_cache(self, idempotency_key: str): cached_record = self._cache.get(idempotency_key) if cached_record: - if cached_record.is_expired: - logger.debug(f"Removing expired local cache record for idempotency key: {idempotency_key}") - self._delete_from_cache(idempotency_key) - else: + if not cached_record.is_expired: return cached_record + logger.debug(f"Removing expired local cache record for idempotency key: {idempotency_key}") + self._delete_from_cache(idempotency_key) def _delete_from_cache(self, idempotency_key: str): del self._cache[idempotency_key] From fed58fed5c20462c7cba81bc81ad28de413ef8c5 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 17:14:48 +0100 Subject: [PATCH 43/44] chore: add test for invalid status on data record --- tests/functional/idempotency/test_idempotency.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index a06c839b9fd..e6e64e3b38b 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -7,10 +7,12 @@ from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, + IdempotencyInvalidStatusError, IdempotencyPersistenceLayerError, IdempotencyValidationError, ) from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent +from aws_lambda_powertools.utilities.idempotency.persistence.base import DataRecord from aws_lambda_powertools.utilities.validation import envelopes, validator TABLE_NAME = "TEST_TABLE" @@ -584,3 +586,11 @@ def lambda_handler(event, context): stubber.assert_no_pending_responses() stubber.deactivate() + + +def test_data_record_invalid_status_value(): + data_record = DataRecord("key", status="UNSUPPORTED_STATUS") + with pytest.raises(IdempotencyInvalidStatusError) as e: + _ = data_record.status + + assert e.value.args[0] == "UNSUPPORTED_STATUS" From b079387018a3d68e0fc70f954eff457c20631dce Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 17:17:12 +0100 Subject: [PATCH 44/44] Update aws_lambda_powertools/utilities/idempotency/persistence/base.py Co-authored-by: Michael Brewer --- .../utilities/idempotency/persistence/base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 7eb5277b418..c9751b0ca12 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -68,10 +68,7 @@ def is_expired(self) -> bool: bool Whether the record is currently expired or not """ - if self.expiry_timestamp: - if int(datetime.datetime.now().timestamp()) > self.expiry_timestamp: - return True - return False + return bool(self.expiry_timestamp and int(datetime.datetime.now().timestamp()) > self.expiry_timestamp) @property def status(self) -> str: