Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(idempotent): Change UX to use a config class for non-persistence related features #306

Merged
merged 14 commits into from
Mar 5, 2021
Merged
4 changes: 2 additions & 2 deletions aws_lambda_powertools/utilities/idempotency/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
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 .idempotency import IdempotencyConfig, idempotent

__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent")
__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent", "IdempotencyConfig")
38 changes: 38 additions & 0 deletions aws_lambda_powertools/utilities/idempotency/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
class IdempotencyConfig:
def __init__(
Comment on lines +4 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great - Hopefully when 3.6 is EOL we'll be able to move to dataclasses to make this easier too, including having a generic Config that auto-discovers env vars based on config option name

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@heitorlessa should we add a mini project for ideas?

self,
event_key_jmespath: str = "",
payload_validation_jmespath: str = "",
raise_on_no_idempotency_key: bool = False,
expires_after_seconds: int = 60 * 60, # 1 hour default
use_local_cache: bool = False,
local_cache_max_items: int = 256,
hash_function: str = "md5",
):
"""
Initialize the base persistence layer

Parameters
----------
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
raise_on_no_idempotency_key: bool, optional
Raise exception if no idempotency key was found in the request, by default False
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_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_jmespath = event_key_jmespath
self.payload_validation_jmespath = payload_validation_jmespath
self.raise_on_no_idempotency_key = raise_on_no_idempotency_key
self.expires_after_seconds = expires_after_seconds
self.use_local_cache = use_local_cache
self.local_cache_max_items = local_cache_max_items
self.hash_function = hash_function
16 changes: 12 additions & 4 deletions aws_lambda_powertools/utilities/idempotency/idempotency.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any, Callable, Dict, Optional

from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig
from aws_lambda_powertools.utilities.idempotency.exceptions import (
IdempotencyAlreadyInProgressError,
IdempotencyInconsistentStateError,
Expand All @@ -29,6 +30,7 @@ def idempotent(
event: Dict[str, Any],
context: LambdaContext,
persistence_store: BasePersistenceLayer,
config: IdempotencyConfig = None,
) -> Any:
"""
Middleware to handle idempotency
Expand All @@ -43,20 +45,24 @@ def idempotent(
Lambda's Context
persistence_store: BasePersistenceLayer
Instance of BasePersistenceLayer to store data
config: IdempotencyConfig
Configutation

Examples
--------
**Processes Lambda's event in an idempotent manner**
>>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer
>>> from aws_lambda_powertools.utilities.idempotency import (
>>> idempotent, DynamoDBPersistenceLayer
>>> )
>>>
>>> persistence_store = DynamoDBPersistenceLayer(event_key_jmespath="body", table_name="idempotency_store")
>>> persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency_store")
>>>
>>> @idempotent(persistence_store=persistence_store)
>>> @idempotent(persistence_store=persistence_layer, config=IdempotencyConfig(event_key_jmespath="body"))
>>> def handler(event, context):
>>> return {"StatusCode": 200}
"""

idempotency_handler = IdempotencyHandler(handler, event, context, persistence_store)
idempotency_handler = IdempotencyHandler(handler, event, context, config, 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.
Expand All @@ -82,6 +88,7 @@ def __init__(
lambda_handler: Callable[[Any, LambdaContext], Any],
event: Dict[str, Any],
context: LambdaContext,
config: IdempotencyConfig,
persistence_store: BasePersistenceLayer,
):
"""
Expand All @@ -98,6 +105,7 @@ def __init__(
persistence_store : BasePersistenceLayer
Instance of persistence layer to store idempotency records
"""
persistence_store.configure(config)
michaelbrewer marked this conversation as resolved.
Show resolved Hide resolved
self.persistence_store = persistence_store
self.context = context
self.event = event
Expand Down
72 changes: 34 additions & 38 deletions aws_lambda_powertools/utilities/idempotency/persistence/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
import warnings
from abc import ABC, abstractmethod
from types import MappingProxyType
from typing import Any, Dict
from typing import Any, Dict, Optional

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.config import IdempotencyConfig
from aws_lambda_powertools.utilities.idempotency.exceptions import (
IdempotencyInvalidStatusError,
IdempotencyItemAlreadyExistsError,
Expand Down Expand Up @@ -106,49 +107,44 @@ class BasePersistenceLayer(ABC):
Abstract Base Class for Idempotency persistence layer.
"""

def __init__(
self,
event_key_jmespath: str = "",
payload_validation_jmespath: str = "",
expires_after_seconds: int = 60 * 60, # 1 hour default
use_local_cache: bool = False,
local_cache_max_items: int = 256,
hash_function: str = "md5",
raise_on_no_idempotency_key: bool = False,
) -> None:
def __init__(self):
self.configured = False

self.event_key_jmespath: Optional[str] = None
self.event_key_compiled_jmespath = None
self.payload_validation_enabled = False
self.validation_key_jmespath = None
self.raise_on_no_idempotency_key = False
self.expires_after_seconds = None
self.use_local_cache = False
self._cache: Optional[LRUDict] = None
self.hash_function = None

def configure(self, config: IdempotencyConfig,) -> None:
"""
Initialize the base persistence layer

Parameters
----------
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_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_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.
raise_on_no_idempotency_key: bool, optional
Raise exception if no idempotency key was found in the request, by default False
"""
self.event_key_jmespath = 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:
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)
config: IdempotencyConfig
Configuration settings
"""
if self.configured:
# Temp hack to prevent being reconfigured.
return
self.configured = True
self.event_key_jmespath = config.event_key_jmespath
if config.event_key_jmespath:
self.event_key_compiled_jmespath = jmespath.compile(config.event_key_jmespath)
if config.payload_validation_jmespath:
self.validation_key_jmespath = jmespath.compile(config.payload_validation_jmespath)
self.payload_validation_enabled = True
self.hash_function = getattr(hashlib, hash_function)
self.raise_on_no_idempotency_key = raise_on_no_idempotency_key
self.raise_on_no_idempotency_key = config.raise_on_no_idempotency_key
self.expires_after_seconds = config.expires_after_seconds
self.use_local_cache = config.use_local_cache
if self.use_local_cache:
self._cache = LRUDict(max_items=config.local_cache_max_items)
self.hash_function = getattr(hashlib, config.hash_function)

def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str:
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ def __init__(
validation_key_attr: str = "validation",
boto_config: Optional[Config] = None,
boto3_session: Optional[boto3.session.Session] = None,
*args,
**kwargs,
):
"""
Initialize the DynamoDB client
Expand Down Expand Up @@ -57,9 +55,9 @@ def __init__(
**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")
>>> persistence_store = DynamoDBPersistenceLayer(table_name="idempotency_store")
>>>
>>> @idempotent(persistence_store=persistence_store)
>>> @idempotent(persistence_store=persistence_store, event_key="body")
>>> def handler(event, context):
>>> return {"StatusCode": 200}
"""
Expand All @@ -74,7 +72,7 @@ def __init__(
self.status_attr = status_attr
self.data_attr = data_attr
self.validation_key_attr = validation_key_attr
super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs)
super(DynamoDBPersistenceLayer, self).__init__()

def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord:
"""
Expand Down
27 changes: 12 additions & 15 deletions tests/functional/idempotency/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from aws_lambda_powertools.shared.json_encoder import Encoder
from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer
from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig
from aws_lambda_powertools.utilities.validation import envelopes
from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope

Expand Down Expand Up @@ -150,34 +151,30 @@ def hashed_validation_key(lambda_apigw_event):


@pytest.fixture
def persistence_store(config, request, default_jmespath):
persistence_store = DynamoDBPersistenceLayer(
def persistence_store(config):
return DynamoDBPersistenceLayer(table_name=TABLE_NAME, boto_config=config)


@pytest.fixture
def idempotency_config(config, request, default_jmespath):
return IdempotencyConfig(
event_key_jmespath=request.param.get("event_key_jmespath") or default_jmespath,
table_name=TABLE_NAME,
boto_config=config,
use_local_cache=request.param["use_local_cache"],
)
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
def config_without_jmespath(config, request):
return IdempotencyConfig(use_local_cache=request.param["use_local_cache"],)


@pytest.fixture
def persistence_store_with_validation(config, request, default_jmespath):
persistence_store = DynamoDBPersistenceLayer(
def config_with_validation(config, request, default_jmespath):
return IdempotencyConfig(
event_key_jmespath=default_jmespath,
table_name=TABLE_NAME,
boto_config=config,
use_local_cache=request.param,
payload_validation_jmespath="requestContext",
)
return persistence_store


@pytest.fixture
Expand Down