Skip to content

Commit 19b1526

Browse files
Tom McCarthyheitorlessa
Tom McCarthy
andauthored
feat(idempotency): makes customers unit testing easier (#719)
Co-authored-by: Heitor Lessa <[email protected]>
1 parent 2fc7c42 commit 19b1526

File tree

5 files changed

+174
-5
lines changed

5 files changed

+174
-5
lines changed

aws_lambda_powertools/shared/constants.py

+2
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@
2121

2222
XRAY_SDK_MODULE: str = "aws_xray_sdk"
2323
XRAY_SDK_CORE_MODULE: str = "aws_xray_sdk.core"
24+
25+
IDEMPOTENCY_DISABLED_ENV: str = "POWERTOOLS_IDEMPOTENCY_DISABLED"

aws_lambda_powertools/utilities/idempotency/idempotency.py

+8
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
"""
44
import functools
55
import logging
6+
import os
67
from typing import Any, Callable, Dict, Optional, cast
78

89
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
10+
from aws_lambda_powertools.shared.constants import IDEMPOTENCY_DISABLED_ENV
911
from aws_lambda_powertools.shared.types import AnyCallableT
1012
from aws_lambda_powertools.utilities.idempotency.base import IdempotencyHandler
1113
from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig
@@ -56,6 +58,9 @@ def idempotent(
5658
>>> return {"StatusCode": 200}
5759
"""
5860

61+
if os.getenv(IDEMPOTENCY_DISABLED_ENV):
62+
return handler(event, context)
63+
5964
config = config or IdempotencyConfig()
6065
args = event, context
6166
idempotency_handler = IdempotencyHandler(
@@ -122,6 +127,9 @@ def process_order(customer_id: str, order: dict, **kwargs):
122127

123128
@functools.wraps(function)
124129
def decorate(*args, **kwargs):
130+
if os.getenv(IDEMPOTENCY_DISABLED_ENV):
131+
return function(*args, **kwargs)
132+
125133
payload = kwargs.get(data_keyword_argument)
126134

127135
if payload is None:

aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py

+24-5
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,37 @@ def __init__(
6262
>>> return {"StatusCode": 200}
6363
"""
6464

65-
boto_config = boto_config or Config()
66-
session = boto3_session or boto3.session.Session()
67-
self._ddb_resource = session.resource("dynamodb", config=boto_config)
65+
self._boto_config = boto_config or Config()
66+
self._boto3_session = boto3_session or boto3.session.Session()
67+
68+
self._table = None
6869
self.table_name = table_name
69-
self.table = self._ddb_resource.Table(self.table_name)
7070
self.key_attr = key_attr
7171
self.expiry_attr = expiry_attr
7272
self.status_attr = status_attr
7373
self.data_attr = data_attr
7474
self.validation_key_attr = validation_key_attr
7575
super(DynamoDBPersistenceLayer, self).__init__()
7676

77+
@property
78+
def table(self):
79+
"""
80+
Caching property to store boto3 dynamodb Table resource
81+
82+
"""
83+
if self._table:
84+
return self._table
85+
ddb_resource = self._boto3_session.resource("dynamodb", config=self._boto_config)
86+
self._table = ddb_resource.Table(self.table_name)
87+
return self._table
88+
89+
@table.setter
90+
def table(self, table):
91+
"""
92+
Allow table instance variable to be set directly, primarily for use in tests
93+
"""
94+
self._table = table
95+
7796
def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord:
7897
"""
7998
Translate raw item records from DynamoDB to DataRecord
@@ -125,7 +144,7 @@ def _put_record(self, data_record: DataRecord) -> None:
125144
ExpressionAttributeNames={"#id": self.key_attr, "#now": self.expiry_attr},
126145
ExpressionAttributeValues={":now": int(now.timestamp())},
127146
)
128-
except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException:
147+
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
129148
logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}")
130149
raise IdempotencyItemAlreadyExistsError
131150

docs/utilities/idempotency.md

+117
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,123 @@ The idempotency utility can be used with the `validator` decorator. Ensure that
765765
!!! tip "JMESPath Powertools functions are also available"
766766
Built-in functions known in the validation utility like `powertools_json`, `powertools_base64`, `powertools_base64_gzip` are also available to use in this utility.
767767

768+
769+
## Testing your code
770+
771+
The idempotency utility provides several routes to test your code.
772+
773+
### Disabling the idempotency utility
774+
When testing your code, you may wish to disable the idempotency logic altogether and focus on testing your business logic. To do this, you can set the environment variable `POWERTOOLS_IDEMPOTENCY_DISABLED`
775+
with a truthy value. If you prefer setting this for specific tests, and are using Pytest, you can use [monkeypatch](https://docs.pytest.org/en/latest/monkeypatch.html) fixture:
776+
777+
=== "tests.py"
778+
779+
```python hl_lines="2 3"
780+
def test_idempotent_lambda_handler(monkeypatch):
781+
# Set POWERTOOLS_IDEMPOTENCY_DISABLED before calling decorated functions
782+
monkeypatch.setenv("POWERTOOLS_IDEMPOTENCY_DISABLED", 1)
783+
784+
result = handler()
785+
...
786+
```
787+
=== "app.py"
788+
789+
```python
790+
from aws_lambda_powertools.utilities.idempotency import (
791+
DynamoDBPersistenceLayer, idempotent
792+
)
793+
794+
persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency")
795+
796+
@idempotent(persistence_store=persistence_layer)
797+
def handler(event, context):
798+
print('expensive operation')
799+
return {
800+
"payment_id": 12345,
801+
"message": "success",
802+
"statusCode": 200,
803+
}
804+
```
805+
806+
### Testing with DynamoDB Local
807+
808+
To test with [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html), you can replace the `Table` resource used by the persistence layer with one you create inside your tests. This allows you to set the endpoint_url.
809+
810+
=== "tests.py"
811+
812+
```python hl_lines="6 7 8"
813+
import boto3
814+
815+
import app
816+
817+
def test_idempotent_lambda():
818+
# Create our own Table resource using the endpoint for our DynamoDB Local instance
819+
resource = boto3.resource("dynamodb", endpoint_url='http://localhost:8000')
820+
table = resource.Table(app.persistence_layer.table_name)
821+
app.persistence_layer.table = table
822+
823+
result = app.handler({'testkey': 'testvalue'}, {})
824+
assert result['payment_id'] == 12345
825+
```
826+
827+
=== "app.py"
828+
829+
```python
830+
from aws_lambda_powertools.utilities.idempotency import (
831+
DynamoDBPersistenceLayer, idempotent
832+
)
833+
834+
persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency")
835+
836+
@idempotent(persistence_store=persistence_layer)
837+
def handler(event, context):
838+
print('expensive operation')
839+
return {
840+
"payment_id": 12345,
841+
"message": "success",
842+
"statusCode": 200,
843+
}
844+
```
845+
846+
### How do I mock all DynamoDB I/O operations
847+
848+
The idempotency utility lazily creates the dynamodb [Table](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table) which it uses to access DynamoDB.
849+
This means it is possible to pass a mocked Table resource, or stub various methods.
850+
851+
=== "tests.py"
852+
853+
```python hl_lines="6 7 8 9"
854+
from unittest.mock import MagicMock
855+
856+
import app
857+
858+
def test_idempotent_lambda():
859+
table = MagicMock()
860+
app.persistence_layer.table = table
861+
result = app.handler({'testkey': 'testvalue'}, {})
862+
table.put_item.assert_called()
863+
...
864+
```
865+
866+
=== "app.py"
867+
868+
```python
869+
from aws_lambda_powertools.utilities.idempotency import (
870+
DynamoDBPersistenceLayer, idempotent
871+
)
872+
873+
persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency")
874+
875+
@idempotent(persistence_store=persistence_layer)
876+
def handler(event, context):
877+
print('expensive operation')
878+
return {
879+
"payment_id": 12345,
880+
"message": "success",
881+
"statusCode": 200,
882+
}
883+
```
884+
768885
## Extra resources
769886

770887
If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out

tests/functional/idempotency/test_idempotency.py

+23
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import sys
55
from hashlib import md5
6+
from unittest.mock import MagicMock
67

78
import jmespath
89
import pytest
@@ -994,3 +995,25 @@ def dummy(payload):
994995

995996
# WHEN
996997
dummy(payload=data_two)
998+
999+
1000+
def test_idempotency_disabled_envvar(monkeypatch, lambda_context, persistence_store: DynamoDBPersistenceLayer):
1001+
# Scenario to validate no requests sent to dynamodb table when 'POWERTOOLS_IDEMPOTENCY_DISABLED' is set
1002+
mock_event = {"data": "value"}
1003+
1004+
persistence_store.table = MagicMock()
1005+
1006+
monkeypatch.setenv("POWERTOOLS_IDEMPOTENCY_DISABLED", "1")
1007+
1008+
@idempotent_function(data_keyword_argument="data", persistence_store=persistence_store)
1009+
def dummy(data):
1010+
return {"message": "hello"}
1011+
1012+
@idempotent(persistence_store=persistence_store)
1013+
def dummy_handler(event, context):
1014+
return {"message": "hi"}
1015+
1016+
dummy(data=mock_event)
1017+
dummy_handler(mock_event, lambda_context)
1018+
1019+
assert len(persistence_store.table.method_calls) == 0

0 commit comments

Comments
 (0)