Skip to content

Commit 863edc8

Browse files
authored
test(idempotency): improve integration tests for utility (#1591)
* docs: new name * chore: rename e2e files * tests(idempotency): expand integration tests * chore(idempotency): remove unreachable code
1 parent 2eb7ab3 commit 863edc8

13 files changed

+1145
-330
lines changed

Diff for: .github/workflows/run-e2e-tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
contents: read
2020
strategy:
2121
matrix:
22-
package: [logger, metrics, tracer, parameters]
22+
package: [logger, metrics, tracer, parameters, idempotency]
2323
version: [14, 16, 18]
2424
fail-fast: false
2525
steps:

Diff for: packages/idempotency/README.md

+88-7
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,37 @@ Next, review the IAM permissions attached to your AWS Lambda function and make s
5353

5454
### Function wrapper
5555

56-
You can make any function idempotent, and safe to retry, by wrapping it using the `makeFunctionIdempotent` higher-order function.
56+
You can make any function idempotent, and safe to retry, by wrapping it using the `makeIdempotent` higher-order function.
5757

5858
The function wrapper takes a reference to the function to be made idempotent as first argument, and an object with options as second argument.
5959

60+
When you wrap your Lambda handler function, the utility uses the content of the `event` parameter to handle the idempotency logic.
61+
6062
```ts
61-
import { makeFunctionIdempotent } from '@aws-lambda-powertools/idempotency';
63+
import { makeIdempotent } from '@aws-lambda-powertools/idempotency';
64+
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
65+
import type { Context, APIGatewayProxyEvent } from 'aws-lambda';
66+
67+
const persistenceStore = new DynamoDBPersistenceLayer({
68+
tableName: 'idempotencyTableName',
69+
});
70+
71+
const myHandler = async (
72+
event: APIGatewayProxyEvent,
73+
_context: Context
74+
): Promise<void> => {
75+
// your code goes here here
76+
};
77+
78+
export const handler = makeIdempotent(myHandler, {
79+
persistenceStore,
80+
});
81+
```
82+
83+
You can also use the `makeIdempotent` function to wrap any other arbitrary function, not just Lambda handlers.
84+
85+
```ts
86+
import { makeIdempotent } from '@aws-lambda-powertools/idempotency';
6287
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
6388
import type { Context, SQSEvent, SQSRecord } from 'aws-lambda';
6489

@@ -70,20 +95,76 @@ const processingFunction = async (payload: SQSRecord): Promise<void> => {
7095
// your code goes here here
7196
};
7297

98+
const processIdempotently = makeIdempotent(processingFunction, {
99+
persistenceStore,
100+
});
101+
73102
export const handler = async (
74103
event: SQSEvent,
75104
_context: Context
76105
): Promise<void> => {
77106
for (const record of event.Records) {
78-
await makeFunctionIdempotent(processingFunction, {
79-
dataKeywordArgument: 'transactionId',
80-
persistenceStore,
81-
});
107+
await processIdempotently(record);
82108
}
83109
};
84110
```
85111

86-
Note that we are specifying a `dataKeywordArgument` option, this tells the Idempotency utility which field(s) will be used as idempotency key.
112+
If your function has multiple arguments, you can use the `dataIndexArgument` option to specify which argument should be used as the idempotency key.
113+
114+
```ts
115+
import { makeIdempotent } from '@aws-lambda-powertools/idempotency';
116+
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
117+
import type { Context, SQSEvent, SQSRecord } from 'aws-lambda';
118+
119+
const persistenceStore = new DynamoDBPersistenceLayer({
120+
tableName: 'idempotencyTableName',
121+
});
122+
123+
const processingFunction = async (payload: SQSRecord, customerId: string): Promise<void> => {
124+
// your code goes here here
125+
};
126+
127+
const processIdempotently = makeIdempotent(processingFunction, {
128+
persistenceStore,
129+
// this tells the utility to use the second argument (`customerId`) as the idempotency key
130+
dataIndexArgument: 1,
131+
});
132+
133+
export const handler = async (
134+
event: SQSEvent,
135+
_context: Context
136+
): Promise<void> => {
137+
for (const record of event.Records) {
138+
await processIdempotently(record, 'customer-123');
139+
}
140+
};
141+
```
142+
143+
Note that you can also specify a JMESPath expression in the Idempotency config object to select a subset of the event payload as the idempotency key. This is useful when dealing with payloads that contain timestamps or request ids.
144+
145+
```ts
146+
import { makeIdempotent, IdempotencyConfig } from '@aws-lambda-powertools/idempotency';
147+
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
148+
import type { Context, APIGatewayProxyEvent } from 'aws-lambda';
149+
150+
const persistenceStore = new DynamoDBPersistenceLayer({
151+
tableName: 'idempotencyTableName',
152+
});
153+
154+
const myHandler = async (
155+
event: APIGatewayProxyEvent,
156+
_context: Context
157+
): Promise<void> => {
158+
// your code goes here here
159+
};
160+
161+
export const handler = makeIdempotent(myHandler, {
162+
persistenceStore,
163+
config: new IdempotencyConfig({
164+
eventKeyJmespath: 'requestContext.identity.user',
165+
}),
166+
});
167+
```
87168

88169
Check the [docs](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/idempotency/) for more examples.
89170

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const isOptionsWithDataIndexArgument = (
5353
* };
5454
*
5555
* // we use wrapper to make processing function idempotent with DynamoDBPersistenceLayer
56-
* const processIdempotently = makeFunctionIdempotent(processRecord, {
56+
* const processIdempotently = makeIdempotent(processRecord, {
5757
* persistenceStore: new DynamoDBPersistenceLayer()
5858
* dataKeywordArgument: 'transactionId', // keyword argument to hash the payload and the result
5959
* });

Diff for: packages/idempotency/src/middleware/makeHandlerIdempotent.ts

+97-40
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { IdempotencyHandler } from '../IdempotencyHandler';
22
import { IdempotencyConfig } from '../IdempotencyConfig';
3-
import { cleanupMiddlewares } from '@aws-lambda-powertools/commons/lib/middleware';
3+
import {
4+
cleanupMiddlewares,
5+
IDEMPOTENCY_KEY,
6+
} from '@aws-lambda-powertools/commons/lib/middleware';
47
import {
58
IdempotencyInconsistentStateError,
69
IdempotencyItemAlreadyExistsError,
@@ -9,51 +12,94 @@ import {
912
import { IdempotencyRecord } from '../persistence';
1013
import { MAX_RETRIES } from '../constants';
1114
import type { IdempotencyLambdaHandlerOptions } from '../types';
15+
import type { BasePersistenceLayerInterface } from '../persistence';
1216
import {
1317
MiddlewareLikeObj,
1418
MiddyLikeRequest,
1519
JSONValue,
1620
} from '@aws-lambda-powertools/commons';
1721

22+
/**
23+
* @internal
24+
* Utility function to get the persistence store from the request internal storage
25+
*
26+
* @param request The Middy request object
27+
* @returns The persistence store from the request internal
28+
*/
29+
const getPersistenceStoreFromRequestInternal = (
30+
request: MiddyLikeRequest
31+
): BasePersistenceLayerInterface => {
32+
const persistenceStore = request.internal[
33+
`${IDEMPOTENCY_KEY}.idempotencyPersistenceStore`
34+
] as BasePersistenceLayerInterface;
35+
36+
return persistenceStore;
37+
};
38+
39+
/**
40+
* @internal
41+
* Utility function to set the persistence store in the request internal storage
42+
*
43+
* @param request The Middy request object
44+
* @param persistenceStore The persistence store to set in the request internal
45+
*/
46+
const setPersistenceStoreInRequestInternal = (
47+
request: MiddyLikeRequest,
48+
persistenceStore: BasePersistenceLayerInterface
49+
): void => {
50+
request.internal[`${IDEMPOTENCY_KEY}.idempotencyPersistenceStore`] =
51+
persistenceStore;
52+
};
53+
54+
/**
55+
* @internal
56+
* Utility function to set a flag in the request internal storage to skip the idempotency middleware
57+
* This is used to skip the idempotency middleware when the idempotency key is not present in the payload
58+
* or when idempotency is disabled
59+
*
60+
* @param request The Middy request object
61+
*/
62+
const setIdempotencySkipFlag = (request: MiddyLikeRequest): void => {
63+
request.internal[`${IDEMPOTENCY_KEY}.skip`] = true;
64+
};
65+
66+
/**
67+
* @internal
68+
* Utility function to get the idempotency key from the request internal storage
69+
* and determine if the request should skip the idempotency middleware
70+
*
71+
* @param request The Middy request object
72+
* @returns Whether the idempotency middleware should be skipped
73+
*/
74+
const shouldSkipIdempotency = (request: MiddyLikeRequest): boolean => {
75+
return request.internal[`${IDEMPOTENCY_KEY}.skip`] === true;
76+
};
77+
1878
/**
1979
* A middy middleware to make your Lambda Handler idempotent.
2080
*
2181
* @example
2282
* ```typescript
23-
* import {
24-
* makeHandlerIdempotent,
25-
* DynamoDBPersistenceLayer,
26-
* } from '@aws-lambda-powertools/idempotency';
83+
* import { makeHandlerIdempotent } from '@aws-lambda-powertools/idempotency/middleware';
84+
* import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
2785
* import middy from '@middy/core';
2886
*
29-
* const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({
30-
* tableName: 'idempotencyTable',
87+
* const persistenceStore = new DynamoDBPersistenceLayer({
88+
* tableName: 'idempotencyTable',
3189
* });
3290
*
33-
* const lambdaHandler = async (_event: unknown, _context: unknown) => {
34-
* //...
35-
* };
36-
*
37-
* export const handler = middy(lambdaHandler)
38-
* .use(makeHandlerIdempotent({ persistenceStore: dynamoDBPersistenceLayer }));
91+
* export const handler = middy(
92+
* async (_event: unknown, _context: unknown): Promise<void> => {
93+
* // your code goes here
94+
* }
95+
* ).use(makeHandlerIdempotent({ persistenceStore: dynamoDBPersistenceLayer }));
3996
* ```
4097
*
4198
* @param options - Options for the idempotency middleware
4299
*/
43100
const makeHandlerIdempotent = (
44101
options: IdempotencyLambdaHandlerOptions
45102
): MiddlewareLikeObj => {
46-
const idempotencyConfig = options.config
47-
? options.config
48-
: new IdempotencyConfig({});
49-
const persistenceStore = options.persistenceStore;
50-
persistenceStore.configure({
51-
config: idempotencyConfig,
52-
});
53-
54-
// keep the flag for after and onError checks
55-
let shouldSkipIdempotency = false;
56-
57103
/**
58104
* Function called before the handler is executed.
59105
*
@@ -76,18 +122,34 @@ const makeHandlerIdempotent = (
76122
request: MiddyLikeRequest,
77123
retryNo = 0
78124
): Promise<unknown | void> => {
125+
const idempotencyConfig = options.config
126+
? options.config
127+
: new IdempotencyConfig({});
128+
const persistenceStore = options.persistenceStore;
129+
persistenceStore.configure({
130+
config: idempotencyConfig,
131+
});
132+
79133
if (
134+
!idempotencyConfig.isEnabled() ||
80135
IdempotencyHandler.shouldSkipIdempotency(
81136
idempotencyConfig.eventKeyJmesPath,
82137
idempotencyConfig.throwOnNoIdempotencyKey,
83138
request.event as JSONValue
84139
)
85140
) {
86141
// set the flag to skip checks in after and onError
87-
shouldSkipIdempotency = true;
142+
setIdempotencySkipFlag(request);
88143

89144
return;
90145
}
146+
147+
/**
148+
* Store the persistence store in the request internal so that it can be
149+
* used in after and onError
150+
*/
151+
setPersistenceStoreInRequestInternal(request, persistenceStore);
152+
91153
try {
92154
await persistenceStore.saveInProgress(
93155
request.event as JSONValue,
@@ -129,6 +191,7 @@ const makeHandlerIdempotent = (
129191
}
130192
}
131193
};
194+
132195
/**
133196
* Function called after the handler has executed successfully.
134197
*
@@ -139,9 +202,10 @@ const makeHandlerIdempotent = (
139202
* @param request - The Middy request object
140203
*/
141204
const after = async (request: MiddyLikeRequest): Promise<void> => {
142-
if (shouldSkipIdempotency) {
205+
if (shouldSkipIdempotency(request)) {
143206
return;
144207
}
208+
const persistenceStore = getPersistenceStoreFromRequestInternal(request);
145209
try {
146210
await persistenceStore.saveSuccess(
147211
request.event as JSONValue,
@@ -164,9 +228,10 @@ const makeHandlerIdempotent = (
164228
* @param request - The Middy request object
165229
*/
166230
const onError = async (request: MiddyLikeRequest): Promise<void> => {
167-
if (shouldSkipIdempotency) {
231+
if (shouldSkipIdempotency(request)) {
168232
return;
169233
}
234+
const persistenceStore = getPersistenceStoreFromRequestInternal(request);
170235
try {
171236
await persistenceStore.deleteRecord(request.event as JSONValue);
172237
} catch (error) {
@@ -177,19 +242,11 @@ const makeHandlerIdempotent = (
177242
}
178243
};
179244

180-
if (idempotencyConfig.isEnabled()) {
181-
return {
182-
before,
183-
after,
184-
onError,
185-
};
186-
} else {
187-
return {
188-
before: () => {
189-
return undefined;
190-
},
191-
};
192-
}
245+
return {
246+
before,
247+
after,
248+
onError,
249+
};
193250
};
194251

195252
export { makeHandlerIdempotent };

Diff for: packages/idempotency/src/persistence/BasePersistenceLayerInterface.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { IdempotencyRecord } from './IdempotencyRecord';
22
import type { BasePersistenceLayerOptions } from '../types/BasePersistenceLayer';
33

4+
// TODO: move this to types folder
45
interface BasePersistenceLayerInterface {
56
configure(options?: BasePersistenceLayerOptions): void;
67
isPayloadValidationEnabled(): boolean;
7-
saveInProgress(data: unknown): Promise<void>;
8+
saveInProgress(data: unknown, remainingTimeInMillis?: number): Promise<void>;
89
saveSuccess(data: unknown, result: unknown): Promise<void>;
910
deleteRecord(data: unknown): Promise<void>;
1011
getRecord(data: unknown): Promise<IdempotencyRecord>;

0 commit comments

Comments
 (0)