Skip to content

Commit b9fcef6

Browse files
author
Alexander Schueren
authored
fix(idempotency): skip persistence for optional idempotency key (#1507)
* add skip idempotency step in the handler in specific cases
1 parent 043b4ad commit b9fcef6

17 files changed

+311
-109
lines changed

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"scripts": {
1818
"init-environment": "husky install",
1919
"test": "npm t -ws",
20+
"test:e2e": "npm run test:e2e -ws",
2021
"commit": "commit",
2122
"package": "npm run package -ws",
2223
"setup-local": "npm ci && npm run build && npm run init-environment",

Diff for: packages/commons/tests/utils/e2eUtils.ts

+3-9
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* E2E utils is used by e2e tests. They are helper function that calls either CDK or SDK
33
* to interact with services.
44
*/
5-
import { App, CfnOutput, Stack, Duration } from 'aws-cdk-lib';
5+
import { App, CfnOutput, Duration, Stack } from 'aws-cdk-lib';
66
import {
77
NodejsFunction,
88
NodejsFunctionProps,
@@ -91,15 +91,11 @@ export const invokeFunction = async (
9191
): Promise<InvocationLogs[]> => {
9292
const invocationLogs: InvocationLogs[] = [];
9393

94-
const promiseFactory = (
95-
index?: number,
96-
includeIndex = true
97-
): Promise<void> => {
94+
const promiseFactory = (index?: number): Promise<void> => {
9895
// in some cases we need to send a payload without the index, i.e. idempotency tests
9996
const payloadToSend = includeIndex
10097
? { invocation: index, ...payload }
10198
: { ...payload };
102-
10399
const invokePromise = lambdaClient
104100
.send(
105101
new InvokeCommand({
@@ -126,9 +122,7 @@ export const invokeFunction = async (
126122

127123
const invocation =
128124
invocationMode == 'PARALLEL'
129-
? Promise.all(
130-
promiseFactories.map((factory, index) => factory(index, includeIndex))
131-
)
125+
? Promise.all(promiseFactories.map((factory, index) => factory(index)))
132126
: chainPromises(promiseFactories);
133127
await invocation;
134128

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

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class IdempotencyConfig {
3838
/**
3939
* Throw an error if the idempotency key is not found in the event.
4040
* In some cases, you may want to allow the request to continue without idempotency.
41+
* If set to false and idempotency key is not found, the request will continue without idempotency.
42+
* @default false
4143
*/
4244
public throwOnNoIdempotencyKey: boolean;
4345
/**

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

+31
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { BasePersistenceLayer, IdempotencyRecord } from './persistence';
1010
import { IdempotencyConfig } from './IdempotencyConfig';
1111
import { MAX_RETRIES } from './constants';
12+
import { search } from 'jmespath';
1213

1314
/**
1415
* @internal
@@ -127,6 +128,17 @@ export class IdempotencyHandler<U> {
127128
}
128129

129130
public async processIdempotency(): Promise<U> {
131+
// early return if we should skip idempotency completely
132+
if (
133+
IdempotencyHandler.shouldSkipIdempotency(
134+
this.idempotencyConfig.eventKeyJmesPath,
135+
this.idempotencyConfig.throwOnNoIdempotencyKey,
136+
this.fullFunctionPayload
137+
)
138+
) {
139+
return await this.functionToMakeIdempotent(this.fullFunctionPayload);
140+
}
141+
130142
try {
131143
await this.persistenceStore.saveInProgress(
132144
this.functionPayloadToBeHashed
@@ -146,4 +158,23 @@ export class IdempotencyHandler<U> {
146158

147159
return this.getFunctionResult();
148160
}
161+
162+
/**
163+
* avoid idempotency if the eventKeyJmesPath is not present in the payload and throwOnNoIdempotencyKey is false
164+
* static so {@link makeHandlerIdempotent} middleware can use it
165+
* TOOD: refactor so middy uses IdempotencyHandler internally wihtout reimplementing the logic
166+
* @param eventKeyJmesPath
167+
* @param throwOnNoIdempotencyKey
168+
* @param fullFunctionPayload
169+
* @private
170+
*/
171+
public static shouldSkipIdempotency(
172+
eventKeyJmesPath: string,
173+
throwOnNoIdempotencyKey: boolean,
174+
fullFunctionPayload: Record<string, unknown>
175+
): boolean {
176+
return (eventKeyJmesPath &&
177+
!throwOnNoIdempotencyKey &&
178+
!search(fullFunctionPayload, eventKeyJmesPath)) as boolean;
179+
}
149180
}

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

+22-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { IdempotencyHandler } from '../IdempotencyHandler';
22
import { IdempotencyConfig } from '../IdempotencyConfig';
33
import { cleanupMiddlewares } from '@aws-lambda-powertools/commons/lib/middleware';
44
import {
5+
IdempotencyInconsistentStateError,
56
IdempotencyItemAlreadyExistsError,
67
IdempotencyPersistenceLayerError,
7-
IdempotencyInconsistentStateError,
88
} from '../Exceptions';
99
import { IdempotencyRecord } from '../persistence';
1010
import { MAX_RETRIES } from '../constants';
@@ -50,6 +50,9 @@ const makeHandlerIdempotent = (
5050
config: idempotencyConfig,
5151
});
5252

53+
// keep the flag for after and onError checks
54+
let shouldSkipIdempotency = false;
55+
5356
/**
5457
* Function called before the handler is executed.
5558
*
@@ -72,6 +75,18 @@ const makeHandlerIdempotent = (
7275
request: MiddyLikeRequest,
7376
retryNo = 0
7477
): Promise<unknown | void> => {
78+
if (
79+
IdempotencyHandler.shouldSkipIdempotency(
80+
idempotencyConfig.eventKeyJmesPath,
81+
idempotencyConfig.throwOnNoIdempotencyKey,
82+
request.event as Record<string, unknown>
83+
)
84+
) {
85+
// set the flag to skip checks in after and onError
86+
shouldSkipIdempotency = true;
87+
88+
return;
89+
}
7590
try {
7691
await persistenceStore.saveInProgress(
7792
request.event as Record<string, unknown>,
@@ -114,7 +129,6 @@ const makeHandlerIdempotent = (
114129
}
115130
}
116131
};
117-
118132
/**
119133
* Function called after the handler has executed successfully.
120134
*
@@ -125,6 +139,9 @@ const makeHandlerIdempotent = (
125139
* @param request - The Middy request object
126140
*/
127141
const after = async (request: MiddyLikeRequest): Promise<void> => {
142+
if (shouldSkipIdempotency) {
143+
return;
144+
}
128145
try {
129146
await persistenceStore.saveSuccess(
130147
request.event as Record<string, unknown>,
@@ -146,6 +163,9 @@ const makeHandlerIdempotent = (
146163
* @param request - The Middy request object
147164
*/
148165
const onError = async (request: MiddyLikeRequest): Promise<void> => {
166+
if (shouldSkipIdempotency) {
167+
return;
168+
}
149169
try {
150170
await persistenceStore.deleteRecord(
151171
request.event as Record<string, unknown>

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

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createHash, Hash } from 'node:crypto';
22
import { search } from 'jmespath';
3-
import { IdempotencyRecordStatus } from '../types';
43
import type { BasePersistenceLayerOptions } from '../types';
4+
import { IdempotencyRecordStatus } from '../types';
55
import { EnvironmentVariablesService } from '../config';
66
import { IdempotencyRecord } from './IdempotencyRecord';
77
import { BasePersistenceLayerInterface } from './BasePersistenceLayerInterface';
@@ -176,10 +176,13 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
176176
}
177177

178178
protected abstract _deleteRecord(record: IdempotencyRecord): Promise<void>;
179+
179180
protected abstract _getRecord(
180181
idempotencyKey: string
181182
): Promise<IdempotencyRecord>;
183+
182184
protected abstract _putRecord(record: IdempotencyRecord): Promise<void>;
185+
183186
protected abstract _updateRecord(record: IdempotencyRecord): Promise<void>;
184187

185188
private deleteFromCache(idempotencyKey: string): void {
@@ -294,7 +297,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
294297
* Save record to local cache except for when status is `INPROGRESS`.
295298
*
296299
* We can't cache `INPROGRESS` records because we have no way to reflect updates
297-
* that might happen to the record outside of the execution context of the function.
300+
* that might happen to the record outside the execution context of the function.
298301
*
299302
* @param record - record to save
300303
*/

Diff for: packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LambdaInterface } from '@aws-lambda-powertools/commons';
33
import { idempotentFunction, idempotentLambdaHandler } from '../../src';
44
import { Logger } from '../../../logger';
55
import { DynamoDBPersistenceLayer } from '../../src/persistence/DynamoDBPersistenceLayer';
6+
import { IdempotencyConfig } from '../../src/';
67

78
const IDEMPOTENCY_TABLE_NAME =
89
process.env.IDEMPOTENCY_TABLE_NAME || 'table_name';
@@ -62,10 +63,25 @@ class DefaultLambda implements LambdaInterface {
6263
_context: Context
6364
): Promise<string> {
6465
logger.info(`Got test event: ${JSON.stringify(_event)}`);
65-
// sleep for 5 seconds
6666

6767
throw new Error('Failed');
6868
}
69+
70+
@idempotentLambdaHandler({
71+
persistenceStore: dynamoDBPersistenceLayer,
72+
config: new IdempotencyConfig({
73+
eventKeyJmesPath: 'idempotencyKey',
74+
throwOnNoIdempotencyKey: false,
75+
}),
76+
})
77+
public async handlerWithOptionalIdempoitencyKey(
78+
_event: TestEvent,
79+
_context: Context
80+
): Promise<string> {
81+
logger.info(`Got test event: ${JSON.stringify(_event)}`);
82+
83+
return 'This should not be stored in DynamoDB';
84+
}
6985
}
7086

7187
const defaultLambda = new DefaultLambda();
@@ -74,6 +90,9 @@ export const handlerCustomized =
7490
defaultLambda.handlerCustomized.bind(defaultLambda);
7591
export const handlerFails = defaultLambda.handlerFails.bind(defaultLambda);
7692

93+
export const handlerWithOptionalIdempoitencyKey =
94+
defaultLambda.handlerWithOptionalIdempoitencyKey.bind(defaultLambda);
95+
7796
const logger = new Logger();
7897

7998
class LambdaWithKeywordArgument implements LambdaInterface {

Diff for: packages/idempotency/tests/e2e/idempotencyDecorator.test.ts

+39-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
destroyStack,
2323
} from '../../../commons/tests/utils/cdk-cli';
2424
import { LEVEL } from '../../../commons/tests/utils/InvocationLogs';
25-
import { GetCommand } from '@aws-sdk/lib-dynamodb';
25+
import { GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
2626
import { createHash } from 'node:crypto';
2727
import { createIdempotencyResources } from '../helpers/idempotencyUtils';
2828

@@ -110,6 +110,23 @@ createIdempotencyResources(
110110
functionNameFails,
111111
'handlerFails'
112112
);
113+
114+
const functionNameOptionalIdempotencyKey = generateUniqueName(
115+
RESOURCE_NAME_PREFIX,
116+
uuid,
117+
runtime,
118+
'optionalIdempotencyKey'
119+
);
120+
const ddbTableNameOptionalIdempotencyKey =
121+
stackName + '-optional-idempotencyKey-table';
122+
createIdempotencyResources(
123+
stack,
124+
runtime,
125+
ddbTableNameOptionalIdempotencyKey,
126+
decoratorFunctionFile,
127+
functionNameOptionalIdempotencyKey,
128+
'handlerWithOptionalIdempoitencyKey'
129+
);
113130
describe('Idempotency e2e test decorator, default settings', () => {
114131
beforeAll(async () => {
115132
await deployStack(app, stack);
@@ -285,6 +302,27 @@ describe('Idempotency e2e test decorator, default settings', () => {
285302
TEST_CASE_TIMEOUT
286303
);
287304

305+
test(
306+
'when called with a function with optional idempotency key and thorwOnNoIdempotencyKey is false, it does not create ddb entry',
307+
async () => {
308+
const payload = { foo: 'baz' }; // we set eventKeyJmesPath: 'idempotencyKey' in the idempotency configuration
309+
await invokeFunction(
310+
functionNameOptionalIdempotencyKey,
311+
2,
312+
'PARALLEL',
313+
payload,
314+
false
315+
);
316+
const result = await ddb.send(
317+
new ScanCommand({
318+
TableName: ddbTableNameOptionalIdempotencyKey,
319+
})
320+
);
321+
expect(result?.Items).toEqual([]);
322+
},
323+
TEST_CASE_TIMEOUT
324+
);
325+
288326
afterAll(async () => {
289327
if (!process.env.DISABLE_TEARDOWN) {
290328
await destroyStack(app, stack);

Diff for: packages/idempotency/tests/unit/IdempotencyHandler.test.ts

+38
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,44 @@ describe('Class IdempotencyHandler', () => {
209209
expect(mockGetRecord).toHaveBeenCalledTimes(1);
210210
expect(mockDetermineResultFromIdempotencyRecord).toHaveBeenCalledTimes(1);
211211
});
212+
213+
test('when throwOnNoIdempotencyKey is false and the key is missing, we skip idempotency', async () => {
214+
const idempotentHandlerSkips = new IdempotencyHandler({
215+
functionToMakeIdempotent: mockFunctionToMakeIdempotent,
216+
functionPayloadToBeHashed: mockFunctionPayloadToBeHashed,
217+
persistenceStore: mockIdempotencyOptions.persistenceStore,
218+
fullFunctionPayload: mockFullFunctionPayload,
219+
idempotencyConfig: new IdempotencyConfig({
220+
throwOnNoIdempotencyKey: false,
221+
eventKeyJmesPath: 'idempotencyKey',
222+
}),
223+
});
224+
225+
const mockSaveInProgress = jest.spyOn(
226+
mockIdempotencyOptions.persistenceStore,
227+
'saveInProgress'
228+
);
229+
230+
const mockSaveSuccessfulResult = jest.spyOn(
231+
mockIdempotencyOptions.persistenceStore,
232+
'saveSuccess'
233+
);
234+
const mockGetRecord = jest.spyOn(
235+
mockIdempotencyOptions.persistenceStore,
236+
'getRecord'
237+
);
238+
239+
mockFunctionToMakeIdempotent.mockImplementation(() => {
240+
return 'result';
241+
});
242+
243+
await expect(idempotentHandlerSkips.processIdempotency()).resolves.toBe(
244+
'result'
245+
);
246+
expect(mockSaveInProgress).toHaveBeenCalledTimes(0);
247+
expect(mockGetRecord).toHaveBeenCalledTimes(0);
248+
expect(mockSaveSuccessfulResult).toHaveBeenCalledTimes(0);
249+
});
212250
});
213251

214252
describe('Method: getFunctionResult', () => {

0 commit comments

Comments
 (0)