Skip to content

Commit 0358d64

Browse files
feat(idempotency): Add response hook feature (#1814)
* Add responseHook definition to IdempotencyConfig and call from IdempotencyHandler. Update idempotency example with an example use-case modifying API GW headers. * Make debug log message more concise in IdempotencyHandler.java. * Add unit test for idempotency response hook. * Document idempotency response hook feature. * Update examples/powertools-examples-idempotency/src/main/java/helloworld/App.java Co-authored-by: Leandro Damascena <[email protected]> --------- Co-authored-by: Leandro Damascena <[email protected]>
1 parent 8479a7f commit 0358d64

File tree

5 files changed

+312
-96
lines changed

5 files changed

+312
-96
lines changed

docs/utilities/idempotency.md

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ times with the same parameters**. This makes idempotent operations safe to retry
5555
<aspectLibraries>
5656
<aspectLibrary>
5757
<groupId>software.amazon.lambda</groupId>
58-
<artifactId>powertools-idempotency-dynamodb</artifactId>
58+
<artifactId>powertools-idempotency-core</artifactId>
5959
</aspectLibrary>
6060
</aspectLibraries>
6161
</configuration>
@@ -584,20 +584,22 @@ IdempotencyConfig.builder()
584584
.withUseLocalCache(true)
585585
.withLocalCacheMaxItems(432)
586586
.withHashFunction("SHA-256")
587+
.withResponseHook((responseData, dataRecord) -> responseData)
587588
.build()
588589
```
589590

590591
These are the available options for further configuration:
591592

592593
| Parameter | Default | Description |
593594
|---------------------------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------|
594-
| **EventKeyJMESPath** | `""` | JMESPath expression to extract the idempotency key from the event record. See available [built-in functions](serialization) |
595+
| **EventKeyJMESPath** | `""` | JMESPath expression to extract the idempotency key from the event record. See available [built-in functions](serialization) |
595596
| **PayloadValidationJMESPath** | `""` | JMESPath expression to validate whether certain parameters have changed in the event |
596-
| **ThrowOnNoIdempotencyKey** | `False` | Throw exception if no idempotency key was found in the request |
597+
| **ThrowOnNoIdempotencyKey** | `false` | Throw exception if no idempotency key was found in the request |
597598
| **ExpirationInSeconds** | 3600 | The number of seconds to wait before a record is expired |
598599
| **UseLocalCache** | `false` | Whether to locally cache idempotency results (LRU cache) |
599600
| **LocalCacheMaxItems** | 256 | Max number of items to store in local cache |
600601
| **HashFunction** | `MD5` | Algorithm to use for calculating hashes, as supported by `java.security.MessageDigest` (eg. SHA-1, SHA-256, ...) |
602+
| **ResponseHook** | `null` | Response hook to apply modifications to idempotent responses |
601603

602604
These features are detailed below.
603605

@@ -855,6 +857,58 @@ You can extend the `BasePersistenceStore` class and implement the abstract metho
855857

856858
For example, the `putRecord` method needs to throw an exception if a non-expired record already exists in the data store with a matching key.
857859

860+
### Manipulating the Idempotent Response
861+
862+
You can set up a response hook in the Idempotency configuration to manipulate the returned data when an operation is idempotent. The hook function will be called with the current de-serialized response `Object` and the Idempotency `DataRecord`.
863+
864+
The example below shows how to append an HTTP header to an `APIGatewayProxyResponseEvent`.
865+
866+
=== "Using an Idempotent Response Hook"
867+
868+
```java hl_lines="3-20"
869+
Idempotency.config().withConfig(
870+
IdempotencyConfig.builder()
871+
.withResponseHook((responseData, dataRecord) -> {
872+
if (responseData instanceof APIGatewayProxyResponseEvent) {
873+
APIGatewayProxyResponseEvent proxyResponse =
874+
(APIGatewayProxyResponseEvent) responseData;
875+
final Map<String, String> headers = new HashMap<>();
876+
headers.putAll(proxyResponse.getHeaders());
877+
// Append idempotency headers
878+
headers.put("x-idempotency-response", "true");
879+
headers.put("x-idempotency-expiration",
880+
String.valueOf(dataRecord.getExpiryTimestamp()));
881+
882+
proxyResponse.setHeaders(headers);
883+
884+
return proxyResponse;
885+
}
886+
887+
return responseData;
888+
})
889+
.build())
890+
.withPersistenceStore(
891+
DynamoDBPersistenceStore.builder()
892+
.withTableName(System.getenv("IDEMPOTENCY_TABLE"))
893+
.build())
894+
.configure();
895+
```
896+
897+
???+ info "Info: Using custom de-serialization?"
898+
899+
The response hook is called after de-serialization so the payload you process will be the de-serialized Java object.
900+
901+
#### Being a good citizen
902+
903+
When using response hooks to manipulate returned data from idempotent operations, it's important to follow best practices to avoid introducing complexity or issues. Keep these guidelines in mind:
904+
905+
1. **Response hook works exclusively when operations are idempotent.** The hook will not be called when an operation is not idempotent, or when the idempotent logic fails.
906+
907+
2. **Catch and Handle Exceptions.** Your response hook code should catch and handle any exceptions that may arise from your logic. Unhandled exceptions will cause the Lambda function to fail unexpectedly.
908+
909+
3. **Keep Hook Logic Simple** Response hooks should consist of minimal and straightforward logic for manipulating response data. Avoid complex conditional branching and aim for hooks that are easy to reason about.
910+
911+
858912
## Compatibility with other utilities
859913

860914
### Validation utility

examples/powertools-examples-idempotency/src/main/java/helloworld/App.java

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,36 @@ public App() {
4545

4646
public App(DynamoDbClient client) {
4747
Idempotency.config().withConfig(
48-
IdempotencyConfig.builder()
49-
.withEventKeyJMESPath(
50-
"powertools_json(body).address") // will retrieve the address field in the body which is a string transformed to json with `powertools_json`
51-
.build())
48+
IdempotencyConfig.builder()
49+
.withEventKeyJMESPath("powertools_json(body).address")
50+
.withResponseHook((responseData, dataRecord) -> {
51+
if (responseData instanceof APIGatewayProxyResponseEvent) {
52+
APIGatewayProxyResponseEvent proxyResponse = (APIGatewayProxyResponseEvent) responseData;
53+
final Map<String, String> headers = new HashMap<>();
54+
headers.putAll(proxyResponse.getHeaders());
55+
headers.put("x-idempotency-response", "true");
56+
headers.put("x-idempotency-expiration",
57+
String.valueOf(dataRecord.getExpiryTimestamp()));
58+
59+
proxyResponse.setHeaders(headers);
60+
61+
return proxyResponse;
62+
}
63+
64+
return responseData;
65+
})
66+
.build())
5267
.withPersistenceStore(
5368
DynamoDBPersistenceStore.builder()
5469
.withDynamoDbClient(client)
5570
.withTableName(System.getenv("IDEMPOTENCY_TABLE"))
56-
.build()
57-
).configure();
71+
.build())
72+
.configure();
5873
}
5974

6075
/**
61-
* This is our Lambda event handler. It accepts HTTP POST requests from API gateway and returns the contents of the given URL. Requests are made idempotent
76+
* This is your Lambda event handler. It accepts HTTP POST requests from API gateway and returns the contents of the
77+
* given URL. Requests are made idempotent
6278
* by the idempotency library, and results are cached for the default 1h expiry time.
6379
* <p>
6480
* You can test the endpoint like this:
@@ -67,8 +83,10 @@ public App(DynamoDbClient client) {
6783
* curl -X POST https://[REST-API-ID].execute-api.[REGION].amazonaws.com/Prod/helloidem/ -H "Content-Type: application/json" -d '{"address": "https://checkip.amazonaws.com"}'
6884
* </pre>
6985
* <ul>
70-
* <li>First call will execute the handleRequest normally, and store the response in the idempotency table (Look into DynamoDB)</li>
71-
* <li>Second call (and next ones) will retrieve from the cache (if cache is enabled, which is by default) or from the store, the handler won't be called. Until the expiration happens (by default 1 hour).</li>
86+
* <li>First call will execute the handleRequest normally, and store the response in the idempotency table (Look
87+
* into DynamoDB)</li>
88+
* <li>Second call (and next ones) will retrieve from the cache (if cache is enabled, which is by default) or from
89+
* the store, the handler won't be called. Until the expiration happens (by default 1 hour).</li>
7290
* </ul>
7391
*/
7492
@Idempotent // The magic is here!
@@ -101,14 +119,14 @@ public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEv
101119
}
102120
}
103121

104-
105122
/**
106123
* Helper to retrieve the contents of the given URL and return them as a string.
107124
* <p>
108125
* We could also put the @Idempotent annotation here if we only wanted this sub-operation to be idempotent. Putting
109126
* it on the handler, however, reduces total execution time and saves us time!
110127
*
111-
* @param address The URL to fetch
128+
* @param address
129+
* The URL to fetch
112130
* @return The contents of the given URL
113131
* @throws IOException
114132
*/
@@ -118,4 +136,4 @@ private String getPageContents(String address) throws IOException {
118136
return br.lines().collect(Collectors.joining(System.lineSeparator()));
119137
}
120138
}
121-
}
139+
}

powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414

1515
package software.amazon.lambda.powertools.idempotency;
1616

17+
import java.time.Duration;
18+
import java.util.function.BiFunction;
19+
1720
import com.amazonaws.services.lambda.runtime.Context;
18-
import software.amazon.lambda.powertools.idempotency.internal.cache.LRUCache;
1921

20-
import java.time.Duration;
22+
import software.amazon.lambda.powertools.idempotency.internal.cache.LRUCache;
23+
import software.amazon.lambda.powertools.idempotency.persistence.DataRecord;
2124

2225
/**
2326
* Configuration of the idempotency feature. Use the {@link Builder} to create an instance.
@@ -30,18 +33,20 @@ public class IdempotencyConfig {
3033
private final String payloadValidationJMESPath;
3134
private final boolean throwOnNoIdempotencyKey;
3235
private final String hashFunction;
36+
private final BiFunction<Object, DataRecord, Object> responseHook;
3337
private Context lambdaContext;
3438

3539
private IdempotencyConfig(String eventKeyJMESPath, String payloadValidationJMESPath,
36-
boolean throwOnNoIdempotencyKey, boolean useLocalCache, int localCacheMaxItems,
37-
long expirationInSeconds, String hashFunction) {
40+
boolean throwOnNoIdempotencyKey, boolean useLocalCache, int localCacheMaxItems,
41+
long expirationInSeconds, String hashFunction, BiFunction<Object, DataRecord, Object> responseHook) {
3842
this.localCacheMaxItems = localCacheMaxItems;
3943
this.useLocalCache = useLocalCache;
4044
this.expirationInSeconds = expirationInSeconds;
4145
this.eventKeyJMESPath = eventKeyJMESPath;
4246
this.payloadValidationJMESPath = payloadValidationJMESPath;
4347
this.throwOnNoIdempotencyKey = throwOnNoIdempotencyKey;
4448
this.hashFunction = hashFunction;
49+
this.responseHook = responseHook;
4550
}
4651

4752
/**
@@ -89,6 +94,10 @@ public void setLambdaContext(Context lambdaContext) {
8994
this.lambdaContext = lambdaContext;
9095
}
9196

97+
public BiFunction<Object, DataRecord, Object> getResponseHook() {
98+
return responseHook;
99+
}
100+
92101
public static class Builder {
93102

94103
private int localCacheMaxItems = 256;
@@ -98,14 +107,18 @@ public static class Builder {
98107
private String payloadValidationJMESPath;
99108
private boolean throwOnNoIdempotencyKey = false;
100109
private String hashFunction = "MD5";
110+
private BiFunction<Object, DataRecord, Object> responseHook;
101111

102112
/**
103113
* Initialize and return an instance of {@link IdempotencyConfig}.<br>
104114
* Example:<br>
115+
*
105116
* <pre>
106117
* IdempotencyConfig.builder().withUseLocalCache().build();
107118
* </pre>
119+
*
108120
* This instance must then be passed to the {@link Idempotency.Config}:
121+
*
109122
* <pre>
110123
* Idempotency.config().withConfig(config).configure();
111124
* </pre>
@@ -120,13 +133,15 @@ public IdempotencyConfig build() {
120133
useLocalCache,
121134
localCacheMaxItems,
122135
expirationInSeconds,
123-
hashFunction);
136+
hashFunction,
137+
responseHook);
124138
}
125139

126140
/**
127141
* A JMESPath expression to extract the idempotency key from the event record. <br>
128142
* See <a href="https://jmespath.org/">https://jmespath.org/</a> for more details.<br>
129-
* Common paths are: <ul>
143+
* Common paths are:
144+
* <ul>
130145
* <li><code>powertools_json(body)</code> for APIGatewayProxyRequestEvent and APIGatewayV2HTTPEvent</li>
131146
* <li><code>Records[*].powertools_json(body)</code> for SQSEvent</li>
132147
* <li><code>Records[0].Sns.Message | powertools_json(@)</code> for SNSEvent</li>
@@ -136,7 +151,8 @@ public IdempotencyConfig build() {
136151
* <li>...</li>
137152
* </ul>
138153
*
139-
* @param eventKeyJMESPath path of the key in the Lambda event
154+
* @param eventKeyJMESPath
155+
* path of the key in the Lambda event
140156
* @return the instance of the builder (to chain operations)
141157
*/
142158
public Builder withEventKeyJMESPath(String eventKeyJMESPath) {
@@ -147,7 +163,8 @@ public Builder withEventKeyJMESPath(String eventKeyJMESPath) {
147163
/**
148164
* Set the maximum number of items to store in local cache, by default 256
149165
*
150-
* @param localCacheMaxItems maximum number of items to store in local cache
166+
* @param localCacheMaxItems
167+
* maximum number of items to store in local cache
151168
* @return the instance of the builder (to chain operations)
152169
*/
153170
public Builder withLocalCacheMaxItems(int localCacheMaxItems) {
@@ -158,8 +175,9 @@ public Builder withLocalCacheMaxItems(int localCacheMaxItems) {
158175
/**
159176
* Whether to locally cache idempotency results, by default false
160177
*
161-
* @param useLocalCache boolean that indicate if a local cache must be used in addition to the persistence store.
162-
* If set to true, will use the {@link LRUCache}
178+
* @param useLocalCache
179+
* boolean that indicate if a local cache must be used in addition to the persistence store.
180+
* If set to true, will use the {@link LRUCache}
163181
* @return the instance of the builder (to chain operations)
164182
*/
165183
public Builder withUseLocalCache(boolean useLocalCache) {
@@ -170,7 +188,8 @@ public Builder withUseLocalCache(boolean useLocalCache) {
170188
/**
171189
* The number of seconds to wait before a record is expired
172190
*
173-
* @param expiration expiration of the record in the store
191+
* @param expiration
192+
* expiration of the record in the store
174193
* @return the instance of the builder (to chain operations)
175194
*/
176195
public Builder withExpiration(Duration expiration) {
@@ -182,7 +201,8 @@ public Builder withExpiration(Duration expiration) {
182201
* A JMESPath expression to extract the payload to be validated from the event record. <br/>
183202
* See <a href="https://jmespath.org/">https://jmespath.org/</a> for more details.
184203
*
185-
* @param payloadValidationJMESPath JMES Path of a part of the payload to be used for validation
204+
* @param payloadValidationJMESPath
205+
* JMES Path of a part of the payload to be used for validation
186206
* @return the instance of the builder (to chain operations)
187207
*/
188208
public Builder withPayloadValidationJMESPath(String payloadValidationJMESPath) {
@@ -193,8 +213,9 @@ public Builder withPayloadValidationJMESPath(String payloadValidationJMESPath) {
193213
/**
194214
* Whether to throw an exception if no idempotency key was found in the request, by default false
195215
*
196-
* @param throwOnNoIdempotencyKey boolean to indicate if we must throw an Exception when
197-
* idempotency key could not be found in the payload.
216+
* @param throwOnNoIdempotencyKey
217+
* boolean to indicate if we must throw an Exception when idempotency key could not be found in the
218+
* payload.
198219
* @return the instance of the builder (to chain operations)
199220
*/
200221
public Builder withThrowOnNoIdempotencyKey(boolean throwOnNoIdempotencyKey) {
@@ -215,15 +236,43 @@ public Builder withThrowOnNoIdempotencyKey() {
215236
/**
216237
* Function to use for calculating hashes, by default MD5.
217238
*
218-
* @param hashFunction Can be any algorithm supported by {@link java.security.MessageDigest}, most commons are<ul>
219-
* <li>MD5</li>
220-
* <li>SHA-1</li>
221-
* <li>SHA-256</li></ul>
239+
* @param hashFunction
240+
* Can be any algorithm supported by {@link java.security.MessageDigest}, most commons are
241+
* <ul>
242+
* <li>MD5</li>
243+
* <li>SHA-1</li>
244+
* <li>SHA-256</li>
245+
* </ul>
222246
* @return the instance of the builder (to chain operations)
223247
*/
224248
public Builder withHashFunction(String hashFunction) {
225249
this.hashFunction = hashFunction;
226250
return this;
227251
}
252+
253+
/**
254+
* Response hook that will be called for each idempotent response. This hook will receive the de-serialized
255+
* response data from the persistence store as first argument and the original DataRecord from the persistence
256+
* store as second argument.
257+
*
258+
* Usage:
259+
*
260+
* <pre>
261+
* IdempotencyConfig.builder().withResponseHook((responseData, dataRecord) -> {
262+
* // do something with the response data, for example:
263+
* if(responseData instanceof APIGatewayProxyRequestEvent) {
264+
* ((APIGatewayProxyRequestEvent) responseData).setHeaders(Map.of("x-idempotency-response", "true")
265+
* }
266+
* return responseData;
267+
* })
268+
* </pre>
269+
*
270+
* @param responseHook
271+
* @return
272+
*/
273+
public Builder withResponseHook(BiFunction<Object, DataRecord, Object> responseHook) {
274+
this.responseHook = responseHook;
275+
return this;
276+
}
228277
}
229278
}

0 commit comments

Comments
 (0)