diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md
index 1c6395db29..e14fe864fb 100644
--- a/docs/utilities/idempotency.md
+++ b/docs/utilities/idempotency.md
@@ -373,6 +373,40 @@ sequenceDiagram
Idempotent successful request cached
+#### Successful request with responseHook configured
+
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Lambda
+ participant Response hook
+ participant Persistence Layer
+ alt initial request
+ Client->>Lambda: Invoke (event)
+ Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
+ activate Persistence Layer
+ Note over Lambda,Persistence Layer: Set record status to INPROGRESS.
Prevents concurrent invocations
with the same payload
+ Lambda-->>Lambda: Call your function
+ Lambda->>Persistence Layer: Update record with result
+ deactivate Persistence Layer
+ Persistence Layer-->>Persistence Layer: Update record
+ Note over Lambda,Persistence Layer: Set record status to COMPLETE.
New invocations with the same payload
now return the same result
+ Lambda-->>Client: Response sent to client
+ else retried request
+ Client->>Lambda: Invoke (event)
+ Lambda->>Persistence Layer: Get or set idempotency_key=hash(payload)
+ activate Persistence Layer
+ Persistence Layer-->>Response hook: Already exists in persistence layer.
+ deactivate Persistence Layer
+ Note over Response hook,Persistence Layer: Record status is COMPLETE and not expired
+ Response hook->>Lambda: Response hook invoked
+ Lambda-->>Client: Manipulated idempotent response sent to client
+ end
+```
+Successful idempotent request with a response hook
+
+
#### Expired idempotency records
@@ -544,6 +578,7 @@ Idempotent decorator can be further configured with **`IdempotencyConfig`** as s
| **useLocalCache** | `false` | Whether to locally cache idempotency results |
| **localCacheMaxItems** | 256 | Max number of items to store in local cache |
| **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. |
+| **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) |
### Handling concurrent executions with the same payload
@@ -744,6 +779,42 @@ Below an example implementation of a custom persistence layer backed by a generi
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.
+### Manipulating the Idempotent Response
+
+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.
+
+=== "Using an Idempotent Response Hook"
+
+ ```typescript hl_lines="16 19 27 56"
+ --8<-- "examples/snippets/idempotency/workingWithResponseHook.ts"
+ ```
+
+=== "Sample event"
+
+ ```json
+ --8<-- "examples/snippets/idempotency/samples/workingWithResponseHookSampleEvent.json"
+ ```
+
+=== "Sample Idempotent response"
+
+ ```json hl_lines="6"
+ --8<-- "examples/snippets/idempotency/samples/workingWithResponseHookIdempotentResponse.json"
+ ```
+
+???+ info "Info: Using custom de-serialization?"
+
+ The responseHook is called after the custom de-serialization so the payload you process will be the de-serialized version.
+
+#### Being a good citizen
+
+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:
+
+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.
+
+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.
+
+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.
+
## Testing your code
The idempotency utility provides several routes to test your code.
diff --git a/examples/snippets/idempotency/samples/workingWithResponseHookIdempotentResponse.json b/examples/snippets/idempotency/samples/workingWithResponseHookIdempotentResponse.json
new file mode 100644
index 0000000000..0c7e1abdae
--- /dev/null
+++ b/examples/snippets/idempotency/samples/workingWithResponseHookIdempotentResponse.json
@@ -0,0 +1,8 @@
+{
+ "message": "success",
+ "paymentId": "31a964eb-7477-4fe1-99fe-7f8a6a351a7e",
+ "statusCode": 200,
+ "headers": {
+ "x-idempotency-key": "function-name#mHfGv2vJ8h+ZvLIr/qGBbQ=="
+ }
+ }
\ No newline at end of file
diff --git a/examples/snippets/idempotency/samples/workingWithResponseHookSampleEvent.json b/examples/snippets/idempotency/samples/workingWithResponseHookSampleEvent.json
new file mode 100644
index 0000000000..40a46dcbf4
--- /dev/null
+++ b/examples/snippets/idempotency/samples/workingWithResponseHookSampleEvent.json
@@ -0,0 +1,4 @@
+{
+ "user": "John Doe",
+ "productId": "123456"
+}
\ No newline at end of file
diff --git a/examples/snippets/idempotency/workingWithResponseHook.ts b/examples/snippets/idempotency/workingWithResponseHook.ts
new file mode 100644
index 0000000000..e24509ba06
--- /dev/null
+++ b/examples/snippets/idempotency/workingWithResponseHook.ts
@@ -0,0 +1,58 @@
+import { randomUUID } from 'node:crypto';
+import type { JSONValue } from '@aws-lambda-powertools/commons/types';
+import {
+ IdempotencyConfig,
+ makeIdempotent,
+} from '@aws-lambda-powertools/idempotency';
+import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
+import type { IdempotencyRecord } from '@aws-lambda-powertools/idempotency/persistence';
+import type { Context } from 'aws-lambda';
+import type { Request, Response, SubscriptionResult } from './types.js';
+
+const persistenceStore = new DynamoDBPersistenceLayer({
+ tableName: 'idempotencyTableName',
+});
+
+const responseHook = (response: JSONValue, record: IdempotencyRecord) => {
+ // Return inserted Header data into the Idempotent Response
+ (response as Response).headers = {
+ 'x-idempotency-key': record.idempotencyKey,
+ };
+
+ // Must return the response here
+ return response as JSONValue;
+};
+
+const config = new IdempotencyConfig({
+ responseHook,
+});
+
+const createSubscriptionPayment = async (
+ event: Request
+): Promise => {
+ // ... create payment
+ return {
+ id: randomUUID(),
+ productId: event.productId,
+ };
+};
+
+export const handler = makeIdempotent(
+ async (event: Request, _context: Context): Promise => {
+ try {
+ const payment = await createSubscriptionPayment(event);
+
+ return {
+ paymentId: payment.id,
+ message: 'success',
+ statusCode: 200,
+ };
+ } catch (error) {
+ throw new Error('Error creating payment');
+ }
+ },
+ {
+ persistenceStore,
+ config,
+ }
+);
diff --git a/packages/idempotency/src/IdempotencyConfig.ts b/packages/idempotency/src/IdempotencyConfig.ts
index 043fa828f8..84e943d8ce 100644
--- a/packages/idempotency/src/IdempotencyConfig.ts
+++ b/packages/idempotency/src/IdempotencyConfig.ts
@@ -2,7 +2,10 @@ import { PowertoolsFunctions } from '@aws-lambda-powertools/jmespath/functions';
import type { JMESPathParsingOptions } from '@aws-lambda-powertools/jmespath/types';
import type { Context } from 'aws-lambda';
import { EnvironmentVariablesService } from './config/EnvironmentVariablesService.js';
-import type { IdempotencyConfigOptions } from './types/IdempotencyOptions.js';
+import type {
+ IdempotencyConfigOptions,
+ ResponseHook,
+} from './types/IdempotencyOptions.js';
/**
* Configuration for the idempotency feature.
@@ -52,6 +55,10 @@ class IdempotencyConfig {
* @default false
*/
public throwOnNoIdempotencyKey: boolean;
+ /**
+ * A hook that runs when an idempotent request is made.
+ */
+ public responseHook?: ResponseHook;
/**
* Use the local cache to store idempotency keys.
@@ -70,6 +77,7 @@ class IdempotencyConfig {
this.maxLocalCacheSize = config.maxLocalCacheSize ?? 1000;
this.hashFunction = config.hashFunction ?? 'md5';
this.lambdaContext = config.lambdaContext;
+ this.responseHook = config.responseHook;
this.#envVarsService = new EnvironmentVariablesService();
this.#enabled = this.#envVarsService.getIdempotencyEnabled();
}
diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts
index 4dcfa03def..3c64a92a6b 100644
--- a/packages/idempotency/src/IdempotencyHandler.ts
+++ b/packages/idempotency/src/IdempotencyHandler.ts
@@ -87,12 +87,14 @@ export class IdempotencyHandler {
/**
* Takes an idempotency key and returns the idempotency record from the persistence layer.
*
+ * If a response hook is provided in the idempotency configuration, it will be called before returning the response.
+ *
* If the idempotency record is not COMPLETE, then it will throw an error based on the status of the record.
*
* @param idempotencyRecord The idempotency record stored in the persistence layer
* @returns The result of the function if the idempotency record is in a terminal state
*/
- public static determineResultFromIdempotencyRecord(
+ public determineResultFromIdempotencyRecord(
idempotencyRecord: IdempotencyRecord
): JSONValue {
if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.EXPIRED) {
@@ -115,7 +117,14 @@ export class IdempotencyHandler {
);
}
- return idempotencyRecord.getResponse();
+ const response = idempotencyRecord.getResponse();
+
+ // If a response hook is provided, call it to allow the user to modify the response
+ if (this.#idempotencyConfig.responseHook) {
+ return this.#idempotencyConfig.responseHook(response, idempotencyRecord);
+ }
+
+ return response;
}
/**
@@ -381,9 +390,7 @@ export class IdempotencyHandler {
returnValue.isIdempotent = true;
returnValue.result =
- IdempotencyHandler.determineResultFromIdempotencyRecord(
- idempotencyRecord
- );
+ this.determineResultFromIdempotencyRecord(idempotencyRecord);
return returnValue;
}
diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts
index 99736c6e22..b9ca069fc9 100644
--- a/packages/idempotency/src/types/IdempotencyOptions.ts
+++ b/packages/idempotency/src/types/IdempotencyOptions.ts
@@ -2,6 +2,7 @@ import type { JSONValue } from '@aws-lambda-powertools/commons/types';
import type { Context, Handler } from 'aws-lambda';
import type { IdempotencyConfig } from '../IdempotencyConfig.js';
import type { BasePersistenceLayer } from '../persistence/BasePersistenceLayer.js';
+import type { IdempotencyRecord } from '../persistence/IdempotencyRecord.js';
/**
* Configuration options for the idempotency utility.
@@ -147,6 +148,14 @@ type IdempotencyHandlerOptions = {
thisArg?: Handler;
};
+/**
+ * A hook that runs when an idempotent request is made.
+ */
+type ResponseHook = (
+ response: JSONValue,
+ record: IdempotencyRecord
+) => JSONValue;
+
/**
* Idempotency configuration options
*/
@@ -183,6 +192,10 @@ type IdempotencyConfigOptions = {
* AWS Lambda Context object containing information about the current invocation, function, and execution environment
*/
lambdaContext?: Context;
+ /**
+ * A hook that runs when an idempotent request is made
+ */
+ responseHook?: ResponseHook;
};
export type {
@@ -191,4 +204,5 @@ export type {
ItempotentFunctionOptions,
IdempotencyLambdaHandlerOptions,
IdempotencyHandlerOptions,
+ ResponseHook,
};
diff --git a/packages/idempotency/src/types/index.ts b/packages/idempotency/src/types/index.ts
index c390a7d185..0158d7855b 100644
--- a/packages/idempotency/src/types/index.ts
+++ b/packages/idempotency/src/types/index.ts
@@ -12,6 +12,7 @@ export type {
IdempotencyHandlerOptions,
ItempotentFunctionOptions,
AnyFunction,
+ ResponseHook,
} from './IdempotencyOptions.js';
export type {
DynamoDBPersistenceOptions,
diff --git a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts
index 89e0f91d10..7c5f218a1a 100644
--- a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts
+++ b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts
@@ -1,3 +1,4 @@
+import type { JSONValue } from '@aws-lambda-powertools/commons/types';
import { IdempotencyHandler } from '../../src/IdempotencyHandler.js';
import { IdempotencyRecordStatus, MAX_RETRIES } from '../../src/constants.js';
import {
@@ -16,12 +17,17 @@ import { IdempotencyRecord } from '../../src/persistence/index.js';
import { PersistenceLayerTestClass } from '../helpers/idempotencyUtils.js';
const mockFunctionToMakeIdempotent = jest.fn();
+const mockResponseHook = jest
+ .fn()
+ .mockImplementation((response, record) => response);
const mockFunctionPayloadToBeHashed = {};
const persistenceStore = new PersistenceLayerTestClass();
const mockIdempotencyOptions = {
persistenceStore,
dataKeywordArgument: 'testKeywordArgument',
- config: new IdempotencyConfig({}),
+ config: new IdempotencyConfig({
+ responseHook: mockResponseHook,
+ }),
};
const idempotentHandler = new IdempotencyHandler({
@@ -64,8 +70,9 @@ describe('Class IdempotencyHandler', () => {
expect(stubRecord.isExpired()).toBe(false);
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS);
expect(() =>
- IdempotencyHandler.determineResultFromIdempotencyRecord(stubRecord)
+ idempotentHandler.determineResultFromIdempotencyRecord(stubRecord)
).toThrow(IdempotencyAlreadyInProgressError);
+ expect(mockResponseHook).not.toHaveBeenCalled();
});
test('when record is in progress and outside expiry window, it rejects with IdempotencyInconsistentStateError', async () => {
@@ -83,8 +90,9 @@ describe('Class IdempotencyHandler', () => {
expect(stubRecord.isExpired()).toBe(false);
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS);
expect(() =>
- IdempotencyHandler.determineResultFromIdempotencyRecord(stubRecord)
+ idempotentHandler.determineResultFromIdempotencyRecord(stubRecord)
).toThrow(IdempotencyInconsistentStateError);
+ expect(mockResponseHook).not.toHaveBeenCalled();
});
test('when record is expired, it rejects with IdempotencyInconsistentStateError', async () => {
@@ -102,8 +110,82 @@ describe('Class IdempotencyHandler', () => {
expect(stubRecord.isExpired()).toBe(true);
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.EXPIRED);
expect(() =>
- IdempotencyHandler.determineResultFromIdempotencyRecord(stubRecord)
+ idempotentHandler.determineResultFromIdempotencyRecord(stubRecord)
).toThrow(IdempotencyInconsistentStateError);
+ expect(mockResponseHook).not.toHaveBeenCalled();
+ });
+
+ test('when response hook is provided, it should should call responseHook during an idempotent request', () => {
+ // Prepare
+ const stubRecord = new IdempotencyRecord({
+ idempotencyKey: 'idempotencyKey',
+ responseData: { responseData: 'responseData' },
+ payloadHash: 'payloadHash',
+ status: IdempotencyRecordStatus.COMPLETED,
+ });
+
+ // Act
+ idempotentHandler.determineResultFromIdempotencyRecord(stubRecord);
+
+ // Assess
+ expect(mockResponseHook).toHaveBeenCalled();
+ });
+
+ test('when response hook is provided, it can manipulate response during an idempotent request', () => {
+ // Prepare
+ interface HandlerResponse {
+ message: string;
+ statusCode: number;
+ headers?: Record;
+ }
+
+ const responseHook = jest
+ .fn()
+ .mockImplementation(
+ (response: JSONValue, record: IdempotencyRecord) => {
+ const handlerResponse = response as unknown as HandlerResponse;
+ handlerResponse.headers = {
+ 'x-idempotency-key': record.idempotencyKey,
+ };
+ return handlerResponse as unknown as JSONValue;
+ }
+ );
+
+ const idempotentHandler = new IdempotencyHandler({
+ functionToMakeIdempotent: mockFunctionToMakeIdempotent,
+ functionPayloadToBeHashed: mockFunctionPayloadToBeHashed,
+ persistenceStore: mockIdempotencyOptions.persistenceStore,
+ functionArguments: [],
+ idempotencyConfig: new IdempotencyConfig({
+ responseHook,
+ }),
+ });
+
+ const responseData = {
+ message: 'Original message',
+ statusCode: 200,
+ };
+
+ const stubRecord = new IdempotencyRecord({
+ idempotencyKey: 'test-key',
+ responseData,
+ payloadHash: 'payloadHash',
+ status: IdempotencyRecordStatus.COMPLETED,
+ });
+
+ // Act
+ const result =
+ idempotentHandler.determineResultFromIdempotencyRecord(stubRecord);
+
+ // Assess
+ expect(responseHook).toHaveBeenCalledWith(responseData, stubRecord);
+ expect(result).toEqual({
+ message: 'Original message',
+ statusCode: 200,
+ headers: {
+ 'x-idempotency-key': 'test-key',
+ },
+ });
});
});