Skip to content

Commit 921e058

Browse files
Merge branch 'develop' into drop-python38
2 parents 6b09961 + 792892e commit 921e058

File tree

7 files changed

+180
-19
lines changed

7 files changed

+180
-19
lines changed

aws_lambda_powertools/utilities/idempotency/base.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def __init__(
7474
config: IdempotencyConfig,
7575
persistence_store: BasePersistenceLayer,
7676
output_serializer: BaseIdempotencySerializer | None = None,
77+
key_prefix: str | None = None,
7778
function_args: tuple | None = None,
7879
function_kwargs: dict | None = None,
7980
):
@@ -91,6 +92,8 @@ def __init__(
9192
output_serializer: BaseIdempotencySerializer | None
9293
Serializer to transform the data to and from a dictionary.
9394
If not supplied, no serialization is done via the NoOpSerializer
95+
key_prefix: str | Optional
96+
Custom prefix for idempotency key: key_prefix#hash
9497
function_args: tuple | None
9598
Function arguments
9699
function_kwargs: dict | None
@@ -102,8 +105,14 @@ def __init__(
102105
self.fn_args = function_args
103106
self.fn_kwargs = function_kwargs
104107
self.config = config
108+
self.key_prefix = key_prefix
109+
110+
persistence_store.configure(
111+
config=config,
112+
function_name=f"{self.function.__module__}.{self.function.__qualname__}",
113+
key_prefix=self.key_prefix,
114+
)
105115

106-
persistence_store.configure(config, f"{self.function.__module__}.{self.function.__qualname__}")
107116
self.persistence_store = persistence_store
108117

109118
def handle(self) -> Any:

aws_lambda_powertools/utilities/idempotency/idempotency.py

+9
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def idempotent(
4040
context: LambdaContext,
4141
persistence_store: BasePersistenceLayer,
4242
config: IdempotencyConfig | None = None,
43+
key_prefix: str | None = None,
4344
**kwargs,
4445
) -> Any:
4546
"""
@@ -57,6 +58,8 @@ def idempotent(
5758
Instance of BasePersistenceLayer to store data
5859
config: IdempotencyConfig
5960
Configuration
61+
key_prefix: str | Optional
62+
Custom prefix for idempotency key: key_prefix#hash
6063
6164
Examples
6265
--------
@@ -94,6 +97,7 @@ def idempotent(
9497
function_payload=event,
9598
config=config,
9699
persistence_store=persistence_store,
100+
key_prefix=key_prefix,
97101
function_args=args,
98102
function_kwargs=kwargs,
99103
)
@@ -108,6 +112,7 @@ def idempotent_function(
108112
persistence_store: BasePersistenceLayer,
109113
config: IdempotencyConfig | None = None,
110114
output_serializer: BaseIdempotencySerializer | type[BaseIdempotencyModelSerializer] | None = None,
115+
key_prefix: str | None = None,
111116
**kwargs: Any,
112117
) -> Any:
113118
"""
@@ -128,6 +133,8 @@ def idempotent_function(
128133
If not supplied, no serialization is done via the NoOpSerializer.
129134
In case a serializer of type inheriting BaseIdempotencyModelSerializer is given,
130135
the serializer is derived from the function return type.
136+
key_prefix: str | Optional
137+
Custom prefix for idempotency key: key_prefix#hash
131138
132139
Examples
133140
--------
@@ -154,6 +161,7 @@ def process_order(customer_id: str, order: dict, **kwargs):
154161
persistence_store=persistence_store,
155162
config=config,
156163
output_serializer=output_serializer,
164+
key_prefix=key_prefix,
157165
**kwargs,
158166
),
159167
)
@@ -191,6 +199,7 @@ def decorate(*args, **kwargs):
191199
config=config,
192200
persistence_store=persistence_store,
193201
output_serializer=output_serializer,
202+
key_prefix=key_prefix,
194203
function_args=args,
195204
function_kwargs=kwargs,
196205
)

aws_lambda_powertools/utilities/idempotency/persistence/base.py

+12-5
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ def __init__(self):
5454
self.use_local_cache = False
5555
self.hash_function = hashlib.md5
5656

57-
def configure(self, config: IdempotencyConfig, function_name: str | None = None) -> None:
57+
def configure(
58+
self,
59+
config: IdempotencyConfig,
60+
function_name: str | None = None,
61+
key_prefix: str | None = None,
62+
) -> None:
5863
"""
5964
Initialize the base persistence layer from the configuration settings
6065
@@ -64,8 +69,12 @@ def configure(self, config: IdempotencyConfig, function_name: str | None = None)
6469
Idempotency configuration settings
6570
function_name: str, Optional
6671
The name of the function being decorated
72+
key_prefix: str | Optional
73+
Custom prefix for idempotency key: key_prefix#hash
6774
"""
68-
self.function_name = f"{os.getenv(constants.LAMBDA_FUNCTION_NAME_ENV, 'test-func')}.{function_name or ''}"
75+
self.function_name = (
76+
key_prefix or f"{os.getenv(constants.LAMBDA_FUNCTION_NAME_ENV, 'test-func')}.{function_name or ''}"
77+
)
6978

7079
if self.configured:
7180
# Prevent being reconfigured multiple times
@@ -75,9 +84,7 @@ def configure(self, config: IdempotencyConfig, function_name: str | None = None)
7584
self.event_key_jmespath = config.event_key_jmespath
7685
if config.event_key_jmespath:
7786
self.event_key_compiled_jmespath = jmespath.compile(config.event_key_jmespath)
78-
self.jmespath_options = config.jmespath_options
79-
if not self.jmespath_options:
80-
self.jmespath_options = {"custom_functions": PowertoolsFunctions()}
87+
self.jmespath_options = config.jmespath_options or {"custom_functions": PowertoolsFunctions()}
8188
if config.payload_validation_jmespath:
8289
self.validation_key_jmespath = jmespath.compile(config.payload_validation_jmespath)
8390
self.payload_validation_enabled = True

docs/utilities/idempotency.md

+25-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ The idempotency utility allows you to retry operations within a time window with
1818

1919
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.
2020

21-
**Idempotency key** is a combination of **(a)** Lambda function name, **(b)** fully qualified name of your function, and **(c)** a hash of the entire payload or part(s) of the payload you specify.
21+
<!-- markdownlint-disable MD013 -->
22+
**Idempotency key** By default, this is a combination of **(a)** Lambda function name, **(b)** fully qualified name of your function, and **(c)** a hash of the entire payload or part(s) of the payload you specify. However, you can customize the key generation by using **(a)** a [custom prefix name](#customizing-the-idempotency-key-generation), while still incorporating **(c)** a hash of the entire payload or part(s) of the payload you specify.
23+
<!-- markdownlint-enable MD013 -->
2224

2325
**Idempotent request** is an operation with the same input previously processed that is not expired in your persistent storage or in-memory cache.
2426

@@ -356,6 +358,28 @@ You can change this expiration window with the **`expires_after_seconds`** param
356358

357359
A record might still be valid (`COMPLETE`) when we retrieved, but in some rare cases it might expire a second later. A record could also be [cached in memory](#using-in-memory-cache). You might also want to have idempotent transactions that should expire in seconds.
358360

361+
### Customizing the Idempotency key generation
362+
363+
!!! warning "Warning: Changing the idempotency key generation will invalidate existing idempotency records"
364+
365+
Use **`key_prefix`** parameter in the `@idempotent` or `@idempotent_function` decorators to define a custom prefix for your Idempotency Key. This allows you to decouple idempotency key name from function names. It can be useful during application refactoring, for example.
366+
367+
=== "Using a custom prefix in Lambda Handler"
368+
369+
```python hl_lines="25"
370+
--8<-- "examples/idempotency/src/working_with_custom_idempotency_key_prefix.py"
371+
```
372+
373+
1. The Idempotency record will be something like `my_custom_prefix#c4ca4238a0b923820dcc509a6f75849b`
374+
375+
=== "Using a custom prefix in standalone functions"
376+
377+
```python hl_lines="32"
378+
--8<-- "examples/idempotency/src/working_with_custom_idempotency_key_prefix_standalone.py"
379+
```
380+
381+
1. The Idempotency record will be something like `my_custom_prefix#c4ca4238a0b923820dcc509a6f75849b`
382+
359383
### Lambda timeouts
360384

361385
!!! note "You can skip this section if you are using the [`@idempotent` decorator](#idempotent-decorator)"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import os
2+
from dataclasses import dataclass, field
3+
from uuid import uuid4
4+
5+
from aws_lambda_powertools.utilities.idempotency import (
6+
DynamoDBPersistenceLayer,
7+
idempotent,
8+
)
9+
from aws_lambda_powertools.utilities.typing import LambdaContext
10+
11+
table = os.getenv("IDEMPOTENCY_TABLE", "")
12+
persistence_layer = DynamoDBPersistenceLayer(table_name=table)
13+
14+
15+
@dataclass
16+
class Payment:
17+
user_id: str
18+
product_id: str
19+
payment_id: str = field(default_factory=lambda: f"{uuid4()}")
20+
21+
22+
class PaymentError(Exception): ...
23+
24+
25+
@idempotent(persistence_store=persistence_layer, key_prefix="my_custom_prefix") # (1)!
26+
def lambda_handler(event: dict, context: LambdaContext):
27+
try:
28+
payment: Payment = create_subscription_payment(event)
29+
return {
30+
"payment_id": payment.payment_id,
31+
"message": "success",
32+
"statusCode": 200,
33+
}
34+
except Exception as exc:
35+
raise PaymentError(f"Error creating payment {str(exc)}")
36+
37+
38+
def create_subscription_payment(event: dict) -> Payment:
39+
return Payment(**event)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import os
2+
from dataclasses import dataclass
3+
4+
from aws_lambda_powertools.utilities.idempotency import (
5+
DynamoDBPersistenceLayer,
6+
IdempotencyConfig,
7+
idempotent_function,
8+
)
9+
from aws_lambda_powertools.utilities.typing import LambdaContext
10+
11+
table = os.getenv("IDEMPOTENCY_TABLE", "")
12+
dynamodb = DynamoDBPersistenceLayer(table_name=table)
13+
config = IdempotencyConfig(event_key_jmespath="order_id") # see Choosing a payload subset section
14+
15+
16+
@dataclass
17+
class OrderItem:
18+
sku: str
19+
description: str
20+
21+
22+
@dataclass
23+
class Order:
24+
item: OrderItem
25+
order_id: int
26+
27+
28+
@idempotent_function(
29+
data_keyword_argument="order",
30+
config=config,
31+
persistence_store=dynamodb,
32+
key_prefix="my_custom_prefix", # (1)!
33+
)
34+
def process_order(order: Order):
35+
return f"processed order {order.order_id}"
36+
37+
38+
def lambda_handler(event: dict, context: LambdaContext):
39+
# see Lambda timeouts section
40+
config.register_lambda_context(context)
41+
42+
order_item = OrderItem(sku="fake", description="sample")
43+
order = Order(item=order_item, order_id=1)
44+
45+
# `order` parameter must be called as a keyword argument to work
46+
process_order(order=order)

tests/functional/idempotency/_boto3/test_idempotency.py

+39-12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import copy
2+
import dataclasses
23
import datetime
34
import warnings
45
from typing import Any, Optional
@@ -59,13 +60,6 @@
5960
TESTS_MODULE_PREFIX = "test-func.tests.functional.idempotency._boto3.test_idempotency"
6061

6162

62-
def get_dataclasses_lib():
63-
"""Python 3.6 doesn't support dataclasses natively"""
64-
import dataclasses
65-
66-
return dataclasses
67-
68-
6963
# Using parametrize to run test twice, with two separate instances of persistence store. One instance with caching
7064
# enabled, and one without.
7165
@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True)
@@ -1313,7 +1307,6 @@ def record_handler(record):
13131307
@pytest.mark.parametrize("output_serializer_type", ["explicit", "deduced"])
13141308
def test_idempotent_function_serialization_dataclass(output_serializer_type: str):
13151309
# GIVEN
1316-
dataclasses = get_dataclasses_lib()
13171310
config = IdempotencyConfig(use_local_cache=True)
13181311
mock_event = {"customer_id": "fake", "transaction_id": "fake-id"}
13191312
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_serialization_dataclass.<locals>.collect_payment#{hash_idempotency_key(mock_event)}" # noqa E501
@@ -1359,7 +1352,6 @@ def collect_payment(payment: PaymentInput) -> PaymentOutput:
13591352

13601353
def test_idempotent_function_serialization_dataclass_failure_no_return_type():
13611354
# GIVEN
1362-
dataclasses = get_dataclasses_lib()
13631355
config = IdempotencyConfig(use_local_cache=True)
13641356
mock_event = {"customer_id": "fake", "transaction_id": "fake-id"}
13651357
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_serialization_pydantic_failure_no_return_type.<locals>.collect_payment#{hash_idempotency_key(mock_event)}" # noqa E501
@@ -1655,7 +1647,6 @@ def test_invalid_dynamodb_persistence_layer():
16551647

16561648
def test_idempotent_function_dataclasses():
16571649
# Scenario _prepare_data should convert a python dataclasses to a dict
1658-
dataclasses = get_dataclasses_lib()
16591650

16601651
@dataclasses.dataclass
16611652
class Foo:
@@ -1670,7 +1661,6 @@ class Foo:
16701661

16711662
def test_idempotent_function_dataclass_with_jmespath():
16721663
# GIVEN
1673-
dataclasses = get_dataclasses_lib()
16741664
config = IdempotencyConfig(event_key_jmespath="transaction_id", use_local_cache=True)
16751665
mock_event = {"customer_id": "fake", "transaction_id": "fake-id"}
16761666
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_dataclass_with_jmespath.<locals>.collect_payment#{hash_idempotency_key(mock_event['transaction_id'])}" # noqa E501
@@ -2019,7 +2009,6 @@ def lambda_handler(event, context):
20192009
@pytest.mark.parametrize("output_serializer_type", ["explicit", "deduced"])
20202010
def test_idempotent_function_serialization_dataclass_with_optional_return(output_serializer_type: str):
20212011
# GIVEN
2022-
dataclasses = get_dataclasses_lib()
20232012
config = IdempotencyConfig(use_local_cache=True)
20242013
mock_event = {"customer_id": "fake", "transaction_id": "fake-id"}
20252014
idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_serialization_dataclass_with_optional_return.<locals>.collect_payment#{hash_idempotency_key(mock_event)}" # noqa E501
@@ -2061,3 +2050,41 @@ def collect_payment(payment: PaymentInput) -> Optional[PaymentOutput]:
20612050
assert isinstance(second_call, PaymentOutput)
20622051
assert second_call.customer_id == payment.customer_id
20632052
assert second_call.transaction_id == payment.transaction_id
2053+
2054+
2055+
def test_idempotent_function_with_custom_prefix_standalone_function():
2056+
# Scenario to validate we can use idempotent_function with any function
2057+
mock_event = {"data": "value"}
2058+
idempotency_key = f"my-custom-prefix#{hash_idempotency_key(mock_event)}"
2059+
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
2060+
expected_result = {"message": "Foo"}
2061+
2062+
@idempotent_function(
2063+
persistence_store=persistence_layer,
2064+
data_keyword_argument="record",
2065+
key_prefix="my-custom-prefix",
2066+
)
2067+
def record_handler(record):
2068+
return expected_result
2069+
2070+
# WHEN calling the function
2071+
result = record_handler(record=mock_event)
2072+
# THEN we expect the function to execute successfully
2073+
assert result == expected_result
2074+
2075+
2076+
def test_idempotent_function_with_custom_prefix_lambda_handler(lambda_context):
2077+
# Scenario to validate we can use idempotent_function with any function
2078+
mock_event = {"data": "value"}
2079+
idempotency_key = f"my-custom-prefix#{hash_idempotency_key(mock_event)}"
2080+
persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
2081+
expected_result = {"message": "Foo"}
2082+
2083+
@idempotent(persistence_store=persistence_layer, key_prefix="my-custom-prefix")
2084+
def lambda_handler(record, context):
2085+
return expected_result
2086+
2087+
# WHEN calling the function
2088+
result = lambda_handler(mock_event, lambda_context)
2089+
# THEN we expect the function to execute successfully
2090+
assert result == expected_result

0 commit comments

Comments
 (0)