Skip to content

Commit 6e293b1

Browse files
Michael Brewerheitorlessa
Michael Brewer
andauthored
feat(idempotency): Add raise_on_no_idempotency_key flag (#297)
* feat(idempotencty): Raise error when event data is None GIVEN a persistence_store with event_key_jmespath = `body` WHEN getting the hashed idempotency key with an event without a `body` key THEN raise IdempotencyValidationError * feat(idempotency): Add raise_on_no_idempotency_key Add `raise_on_no_idempotency_key` to enforce a idempotency key value is passed into the lambda. * fix: Include empty data structures Take in account for empty data structures, including non-lists iterables like tuples, dictionaries Co-authored-by: Heitor Lessa <[email protected]> * tests: Include tests for empty data structures Co-authored-by: Heitor Lessa <[email protected]> * fix: tests * docs: Make some doc changes Co-authored-by: Heitor Lessa <[email protected]>
1 parent 11ebcf9 commit 6e293b1

File tree

5 files changed

+91
-4
lines changed

5 files changed

+91
-4
lines changed

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

+6
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,9 @@ class IdempotencyPersistenceLayerError(Exception):
4343
"""
4444
Unrecoverable error from the data store
4545
"""
46+
47+
48+
class IdempotencyKeyError(Exception):
49+
"""
50+
Payload does not contain a idempotent key
51+
"""

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

+16
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import hashlib
77
import json
88
import logging
9+
import warnings
910
from abc import ABC, abstractmethod
1011
from types import MappingProxyType
1112
from typing import Any, Dict
@@ -17,6 +18,7 @@
1718
from aws_lambda_powertools.utilities.idempotency.exceptions import (
1819
IdempotencyInvalidStatusError,
1920
IdempotencyItemAlreadyExistsError,
21+
IdempotencyKeyError,
2022
IdempotencyValidationError,
2123
)
2224

@@ -112,6 +114,7 @@ def __init__(
112114
use_local_cache: bool = False,
113115
local_cache_max_items: int = 256,
114116
hash_function: str = "md5",
117+
raise_on_no_idempotency_key: bool = False,
115118
) -> None:
116119
"""
117120
Initialize the base persistence layer
@@ -130,6 +133,8 @@ def __init__(
130133
Max number of items to store in local cache, by default 1024
131134
hash_function: str, optional
132135
Function to use for calculating hashes, by default md5.
136+
raise_on_no_idempotency_key: bool, optional
137+
Raise exception if no idempotency key was found in the request, by default False
133138
"""
134139
self.event_key_jmespath = event_key_jmespath
135140
if self.event_key_jmespath:
@@ -143,6 +148,7 @@ def __init__(
143148
self.validation_key_jmespath = jmespath.compile(payload_validation_jmespath)
144149
self.payload_validation_enabled = True
145150
self.hash_function = getattr(hashlib, hash_function)
151+
self.raise_on_no_idempotency_key = raise_on_no_idempotency_key
146152

147153
def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str:
148154
"""
@@ -162,8 +168,18 @@ def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str:
162168
data = lambda_event
163169
if self.event_key_jmespath:
164170
data = self.event_key_compiled_jmespath.search(lambda_event)
171+
172+
if self.is_missing_idempotency_key(data):
173+
warnings.warn(f"No value found for idempotency_key. jmespath: {self.event_key_jmespath}")
174+
if self.raise_on_no_idempotency_key:
175+
raise IdempotencyKeyError("No data found to create a hashed idempotency_key")
176+
165177
return self._generate_hash(data)
166178

179+
@staticmethod
180+
def is_missing_idempotency_key(data) -> bool:
181+
return data is None or not data or all(x is None for x in data)
182+
167183
def _get_hashed_payload(self, lambda_event: Dict[str, Any]) -> str:
168184
"""
169185
Extract data from lambda event using validation key jmespath, and return a hashed representation

Diff for: docs/utilities/idempotency.md

+15-2
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,24 @@ idempotent invocations.
254254
```
255255

256256
In this example, the "userDetail" and "productId" keys are used as the payload to generate the idempotency key. If
257-
we try to send the same request but with a different amount, Lambda will raise `IdempotencyValidationError`. Without
257+
we try to send the same request but with a different amount, we will raise `IdempotencyValidationError`. Without
258258
payload validation, we would have returned the same result as we did for the initial request. Since we're also
259259
returning an amount in the response, this could be quite confusing for the client. By using payload validation on the
260260
amount field, we prevent this potentially confusing behaviour and instead raise an Exception.
261261

262+
### Making idempotency key required
263+
264+
If you want to enforce that an idempotency key is required, you can set `raise_on_no_idempotency_key` to `True`,
265+
and we will raise `IdempotencyKeyError` if none was found.
266+
267+
```python hl_lines="4"
268+
DynamoDBPersistenceLayer(
269+
event_key_jmespath="body",
270+
table_name="IdempotencyTable",
271+
raise_on_no_idempotency_key=True
272+
)
273+
```
274+
262275
### Changing dynamoDB attribute names
263276
If you want to use an existing DynamoDB table, or wish to change the name of the attributes used to store items in the
264277
table, you can do so when you construct the `DynamoDBPersistenceLayer` instance.
@@ -278,7 +291,7 @@ This example demonstrates changing the attribute names to custom values:
278291
```python hl_lines="5-10"
279292
persistence_layer = DynamoDBPersistenceLayer(
280293
event_key_jmespath="[userDetail, productId]",
281-
table_name="IdempotencyTable",)
294+
table_name="IdempotencyTable",
282295
key_attr="idempotency_key",
283296
expiry_attr="expires_at",
284297
status_attr="current_status",

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def hashed_validation_key(lambda_apigw_event):
152152
@pytest.fixture
153153
def persistence_store(config, request, default_jmespath):
154154
persistence_store = DynamoDBPersistenceLayer(
155-
event_key_jmespath=default_jmespath,
155+
event_key_jmespath=request.param.get("event_key_jmespath") or default_jmespath,
156156
table_name=TABLE_NAME,
157157
boto_config=config,
158158
use_local_cache=request.param["use_local_cache"],

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

+53-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import copy
2+
import json
23
import sys
4+
from hashlib import md5
35

46
import pytest
57
from botocore import stub
@@ -8,11 +10,12 @@
810
IdempotencyAlreadyInProgressError,
911
IdempotencyInconsistentStateError,
1012
IdempotencyInvalidStatusError,
13+
IdempotencyKeyError,
1114
IdempotencyPersistenceLayerError,
1215
IdempotencyValidationError,
1316
)
1417
from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent
15-
from aws_lambda_powertools.utilities.idempotency.persistence.base import DataRecord
18+
from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer, DataRecord
1619
from aws_lambda_powertools.utilities.validation import envelopes, validator
1720

1821
TABLE_NAME = "TEST_TABLE"
@@ -638,3 +641,52 @@ def test_delete_from_cache_when_empty(persistence_store):
638641
except KeyError:
639642
# THEN we should not get a KeyError
640643
pytest.fail("KeyError should not happen")
644+
645+
646+
def test_is_missing_idempotency_key():
647+
# GIVEN None THEN is_missing_idempotency_key is True
648+
assert BasePersistenceLayer.is_missing_idempotency_key(None)
649+
# GIVEN a list of Nones THEN is_missing_idempotency_key is True
650+
assert BasePersistenceLayer.is_missing_idempotency_key([None, None])
651+
# GIVEN a list of all not None THEN is_missing_idempotency_key is false
652+
assert BasePersistenceLayer.is_missing_idempotency_key([None, "Value"]) is False
653+
# GIVEN a str THEN is_missing_idempotency_key is false
654+
assert BasePersistenceLayer.is_missing_idempotency_key("Value") is False
655+
# GIVEN an empty tuple THEN is_missing_idempotency_key is false
656+
assert BasePersistenceLayer.is_missing_idempotency_key(())
657+
# GIVEN an empty list THEN is_missing_idempotency_key is false
658+
assert BasePersistenceLayer.is_missing_idempotency_key([])
659+
# GIVEN an empty dictionary THEN is_missing_idempotency_key is false
660+
assert BasePersistenceLayer.is_missing_idempotency_key({})
661+
# GIVEN an empty str THEN is_missing_idempotency_key is false
662+
assert BasePersistenceLayer.is_missing_idempotency_key("")
663+
664+
665+
@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False, "event_key_jmespath": "body"}], indirect=True)
666+
def test_default_no_raise_on_missing_idempotency_key(persistence_store):
667+
# GIVEN a persistence_store with use_local_cache = False and event_key_jmespath = "body"
668+
assert persistence_store.use_local_cache is False
669+
assert "body" in persistence_store.event_key_jmespath
670+
671+
# WHEN getting the hashed idempotency key for an event with no `body` key
672+
hashed_key = persistence_store._get_hashed_idempotency_key({})
673+
674+
# THEN return the hash of None
675+
assert md5(json.dumps(None).encode()).hexdigest() == hashed_key
676+
677+
678+
@pytest.mark.parametrize(
679+
"persistence_store", [{"use_local_cache": False, "event_key_jmespath": "[body, x]"}], indirect=True
680+
)
681+
def test_raise_on_no_idempotency_key(persistence_store):
682+
# GIVEN a persistence_store with raise_on_no_idempotency_key and no idempotency key in the request
683+
persistence_store.raise_on_no_idempotency_key = True
684+
assert persistence_store.use_local_cache is False
685+
assert "body" in persistence_store.event_key_jmespath
686+
687+
# WHEN getting the hashed idempotency key for an event with no `body` key
688+
with pytest.raises(IdempotencyKeyError) as excinfo:
689+
persistence_store._get_hashed_idempotency_key({})
690+
691+
# THEN raise IdempotencyKeyError error
692+
assert "No data found to create a hashed idempotency_key" in str(excinfo.value)

0 commit comments

Comments
 (0)