Skip to content

Commit f7c1769

Browse files
arnabrahmanam29ddreamorosi
authored
feat(idempotency): manipulate idempotent response via response hook (#3071)
Co-authored-by: Alexander Schueren <[email protected]> Co-authored-by: Andrea Amorosi <[email protected]>
1 parent 1a65746 commit f7c1769

9 files changed

+263
-10
lines changed

Diff for: docs/utilities/idempotency.md

+71
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,40 @@ sequenceDiagram
373373
<i>Idempotent successful request cached</i>
374374
</center>
375375

376+
#### Successful request with responseHook configured
377+
378+
<center>
379+
```mermaid
380+
sequenceDiagram
381+
participant Client
382+
participant Lambda
383+
participant Response hook
384+
participant Persistence Layer
385+
alt initial request
386+
Client->>Lambda: Invoke (event)
387+
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
388+
activate Persistence Layer
389+
Note over Lambda,Persistence Layer: Set record status to INPROGRESS. <br> Prevents concurrent invocations <br> with the same payload
390+
Lambda-->>Lambda: Call your function
391+
Lambda->>Persistence Layer: Update record with result
392+
deactivate Persistence Layer
393+
Persistence Layer-->>Persistence Layer: Update record
394+
Note over Lambda,Persistence Layer: Set record status to COMPLETE. <br> New invocations with the same payload <br> now return the same result
395+
Lambda-->>Client: Response sent to client
396+
else retried request
397+
Client->>Lambda: Invoke (event)
398+
Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
399+
activate Persistence Layer
400+
Persistence Layer-->>Response hook: Already exists in persistence layer.
401+
deactivate Persistence Layer
402+
Note over Response hook,Persistence Layer: Record status is COMPLETE and not expired
403+
Response hook->>Lambda: Response hook invoked
404+
Lambda-->>Client: Manipulated idempotent response sent to client
405+
end
406+
```
407+
<i>Successful idempotent request with a response hook</i>
408+
</center>
409+
376410
#### Expired idempotency records
377411

378412
<center>
@@ -544,6 +578,7 @@ Idempotent decorator can be further configured with **`IdempotencyConfig`** as s
544578
| **useLocalCache** | `false` | Whether to locally cache idempotency results |
545579
| **localCacheMaxItems** | 256 | Max number of items to store in local cache |
546580
| **hashFunction** | `md5` | Function to use for calculating hashes, as provided by the [crypto](https://nodejs.org/api/crypto.html#cryptocreatehashalgorithm-options){target="_blank"} module in the standard library. |
581+
| **responseHook** | `undefined` | Function to use for processing the stored Idempotent response. This function hook is called when an existing idempotent response is found. See [Manipulating The Idempotent Response](idempotency.md#manipulating-the-idempotent-response) |
547582

548583
### Handling concurrent executions with the same payload
549584

@@ -744,6 +779,42 @@ Below an example implementation of a custom persistence layer backed by a generi
744779

745780
For example, the `_putRecord()` method needs to throw an error if a non-expired record already exists in the data store with a matching key.
746781

782+
### Manipulating the Idempotent Response
783+
784+
You can set up a `responseHook` in the `IdempotentConfig` class to manipulate the returned data when an operation is idempotent. The hook function will be called with the current deserialized response object and the Idempotency record.
785+
786+
=== "Using an Idempotent Response Hook"
787+
788+
```typescript hl_lines="16 19 27 56"
789+
--8<-- "examples/snippets/idempotency/workingWithResponseHook.ts"
790+
```
791+
792+
=== "Sample event"
793+
794+
```json
795+
--8<-- "examples/snippets/idempotency/samples/workingWithResponseHookSampleEvent.json"
796+
```
797+
798+
=== "Sample Idempotent response"
799+
800+
```json hl_lines="6"
801+
--8<-- "examples/snippets/idempotency/samples/workingWithResponseHookIdempotentResponse.json"
802+
```
803+
804+
???+ info "Info: Using custom de-serialization?"
805+
806+
The responseHook is called after the custom de-serialization so the payload you process will be the de-serialized version.
807+
808+
#### Being a good citizen
809+
810+
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:
811+
812+
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.
813+
814+
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.
815+
816+
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.
817+
747818
## Testing your code
748819

749820
The idempotency utility provides several routes to test your code.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"message": "success",
3+
"paymentId": "31a964eb-7477-4fe1-99fe-7f8a6a351a7e",
4+
"statusCode": 200,
5+
"headers": {
6+
"x-idempotency-key": "function-name#mHfGv2vJ8h+ZvLIr/qGBbQ=="
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"user": "John Doe",
3+
"productId": "123456"
4+
}
+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { randomUUID } from 'node:crypto';
2+
import type { JSONValue } from '@aws-lambda-powertools/commons/types';
3+
import {
4+
IdempotencyConfig,
5+
makeIdempotent,
6+
} from '@aws-lambda-powertools/idempotency';
7+
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
8+
import type { IdempotencyRecord } from '@aws-lambda-powertools/idempotency/persistence';
9+
import type { Context } from 'aws-lambda';
10+
import type { Request, Response, SubscriptionResult } from './types.js';
11+
12+
const persistenceStore = new DynamoDBPersistenceLayer({
13+
tableName: 'idempotencyTableName',
14+
});
15+
16+
const responseHook = (response: JSONValue, record: IdempotencyRecord) => {
17+
// Return inserted Header data into the Idempotent Response
18+
(response as Response).headers = {
19+
'x-idempotency-key': record.idempotencyKey,
20+
};
21+
22+
// Must return the response here
23+
return response as JSONValue;
24+
};
25+
26+
const config = new IdempotencyConfig({
27+
responseHook,
28+
});
29+
30+
const createSubscriptionPayment = async (
31+
event: Request
32+
): Promise<SubscriptionResult> => {
33+
// ... create payment
34+
return {
35+
id: randomUUID(),
36+
productId: event.productId,
37+
};
38+
};
39+
40+
export const handler = makeIdempotent(
41+
async (event: Request, _context: Context): Promise<Response> => {
42+
try {
43+
const payment = await createSubscriptionPayment(event);
44+
45+
return {
46+
paymentId: payment.id,
47+
message: 'success',
48+
statusCode: 200,
49+
};
50+
} catch (error) {
51+
throw new Error('Error creating payment');
52+
}
53+
},
54+
{
55+
persistenceStore,
56+
config,
57+
}
58+
);

Diff for: packages/idempotency/src/IdempotencyConfig.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { PowertoolsFunctions } from '@aws-lambda-powertools/jmespath/functions';
22
import type { JMESPathParsingOptions } from '@aws-lambda-powertools/jmespath/types';
33
import type { Context } from 'aws-lambda';
44
import { EnvironmentVariablesService } from './config/EnvironmentVariablesService.js';
5-
import type { IdempotencyConfigOptions } from './types/IdempotencyOptions.js';
5+
import type {
6+
IdempotencyConfigOptions,
7+
ResponseHook,
8+
} from './types/IdempotencyOptions.js';
69

710
/**
811
* Configuration for the idempotency feature.
@@ -52,6 +55,10 @@ class IdempotencyConfig {
5255
* @default false
5356
*/
5457
public throwOnNoIdempotencyKey: boolean;
58+
/**
59+
* A hook that runs when an idempotent request is made.
60+
*/
61+
public responseHook?: ResponseHook;
5562

5663
/**
5764
* Use the local cache to store idempotency keys.
@@ -70,6 +77,7 @@ class IdempotencyConfig {
7077
this.maxLocalCacheSize = config.maxLocalCacheSize ?? 1000;
7178
this.hashFunction = config.hashFunction ?? 'md5';
7279
this.lambdaContext = config.lambdaContext;
80+
this.responseHook = config.responseHook;
7381
this.#envVarsService = new EnvironmentVariablesService();
7482
this.#enabled = this.#envVarsService.getIdempotencyEnabled();
7583
}

Diff for: packages/idempotency/src/IdempotencyHandler.ts

+12-5
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,14 @@ export class IdempotencyHandler<Func extends AnyFunction> {
8787
/**
8888
* Takes an idempotency key and returns the idempotency record from the persistence layer.
8989
*
90+
* If a response hook is provided in the idempotency configuration, it will be called before returning the response.
91+
*
9092
* If the idempotency record is not COMPLETE, then it will throw an error based on the status of the record.
9193
*
9294
* @param idempotencyRecord The idempotency record stored in the persistence layer
9395
* @returns The result of the function if the idempotency record is in a terminal state
9496
*/
95-
public static determineResultFromIdempotencyRecord(
97+
public determineResultFromIdempotencyRecord(
9698
idempotencyRecord: IdempotencyRecord
9799
): JSONValue {
98100
if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.EXPIRED) {
@@ -115,7 +117,14 @@ export class IdempotencyHandler<Func extends AnyFunction> {
115117
);
116118
}
117119

118-
return idempotencyRecord.getResponse();
120+
const response = idempotencyRecord.getResponse();
121+
122+
// If a response hook is provided, call it to allow the user to modify the response
123+
if (this.#idempotencyConfig.responseHook) {
124+
return this.#idempotencyConfig.responseHook(response, idempotencyRecord);
125+
}
126+
127+
return response;
119128
}
120129

121130
/**
@@ -381,9 +390,7 @@ export class IdempotencyHandler<Func extends AnyFunction> {
381390

382391
returnValue.isIdempotent = true;
383392
returnValue.result =
384-
IdempotencyHandler.determineResultFromIdempotencyRecord(
385-
idempotencyRecord
386-
);
393+
this.determineResultFromIdempotencyRecord(idempotencyRecord);
387394

388395
return returnValue;
389396
}

Diff for: packages/idempotency/src/types/IdempotencyOptions.ts

+14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { JSONValue } from '@aws-lambda-powertools/commons/types';
22
import type { Context, Handler } from 'aws-lambda';
33
import type { IdempotencyConfig } from '../IdempotencyConfig.js';
44
import type { BasePersistenceLayer } from '../persistence/BasePersistenceLayer.js';
5+
import type { IdempotencyRecord } from '../persistence/IdempotencyRecord.js';
56

67
/**
78
* Configuration options for the idempotency utility.
@@ -147,6 +148,14 @@ type IdempotencyHandlerOptions = {
147148
thisArg?: Handler;
148149
};
149150

151+
/**
152+
* A hook that runs when an idempotent request is made.
153+
*/
154+
type ResponseHook = (
155+
response: JSONValue,
156+
record: IdempotencyRecord
157+
) => JSONValue;
158+
150159
/**
151160
* Idempotency configuration options
152161
*/
@@ -183,6 +192,10 @@ type IdempotencyConfigOptions = {
183192
* AWS Lambda Context object containing information about the current invocation, function, and execution environment
184193
*/
185194
lambdaContext?: Context;
195+
/**
196+
* A hook that runs when an idempotent request is made
197+
*/
198+
responseHook?: ResponseHook;
186199
};
187200

188201
export type {
@@ -191,4 +204,5 @@ export type {
191204
ItempotentFunctionOptions,
192205
IdempotencyLambdaHandlerOptions,
193206
IdempotencyHandlerOptions,
207+
ResponseHook,
194208
};

Diff for: packages/idempotency/src/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type {
1212
IdempotencyHandlerOptions,
1313
ItempotentFunctionOptions,
1414
AnyFunction,
15+
ResponseHook,
1516
} from './IdempotencyOptions.js';
1617
export type {
1718
DynamoDBPersistenceOptions,

0 commit comments

Comments
 (0)