Skip to content

Commit f2ebf08

Browse files
author
Alexander Schueren
authored
feat(idempotency): implement IdempotencyHandler (#1416)
1 parent 5d680a0 commit f2ebf08

File tree

5 files changed

+265
-45
lines changed

5 files changed

+265
-45
lines changed

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

+61-18
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,84 @@
1+
import type { AnyFunctionWithRecord, IdempotencyOptions } from './types';
12
import { IdempotencyRecordStatus } from './types';
2-
import type {
3-
AnyFunctionWithRecord,
4-
IdempotencyOptions,
5-
} from './types';
63
import {
4+
IdempotencyAlreadyInProgressError,
75
IdempotencyInconsistentStateError,
86
IdempotencyItemAlreadyExistsError,
9-
IdempotencyAlreadyInProgressError,
10-
IdempotencyPersistenceLayerError
7+
IdempotencyPersistenceLayerError,
118
} from './Exceptions';
12-
import { IdempotencyRecord } from './persistence/IdempotencyRecord';
9+
import { IdempotencyRecord } from './persistence';
1310

1411
export class IdempotencyHandler<U> {
1512
public constructor(
1613
private functionToMakeIdempotent: AnyFunctionWithRecord<U>,
17-
private functionPayloadToBeHashed: Record<string, unknown>,
14+
private functionPayloadToBeHashed: Record<string, unknown>,
1815
private idempotencyOptions: IdempotencyOptions,
19-
private fullFunctionPayload: Record<string, unknown>
20-
) {}
16+
private fullFunctionPayload: Record<string, unknown>,
17+
) {
18+
}
2119

22-
public determineResultFromIdempotencyRecord(idempotencyRecord: IdempotencyRecord): Promise<U> | U {
20+
public determineResultFromIdempotencyRecord(
21+
idempotencyRecord: IdempotencyRecord
22+
): Promise<U> | U {
2323
if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.EXPIRED) {
24-
throw new IdempotencyInconsistentStateError('Item has expired during processing and may not longer be valid.');
25-
} else if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.INPROGRESS){
26-
throw new IdempotencyAlreadyInProgressError(`There is already an execution in progress with idempotency key: ${idempotencyRecord.idempotencyKey}`);
24+
throw new IdempotencyInconsistentStateError(
25+
'Item has expired during processing and may not longer be valid.'
26+
);
27+
} else if (
28+
idempotencyRecord.getStatus() === IdempotencyRecordStatus.INPROGRESS
29+
) {
30+
if (
31+
idempotencyRecord.inProgressExpiryTimestamp &&
32+
idempotencyRecord.inProgressExpiryTimestamp <
33+
new Date().getUTCMilliseconds()
34+
) {
35+
throw new IdempotencyInconsistentStateError(
36+
'Item is in progress but the in progress expiry timestamp has expired.'
37+
);
38+
} else {
39+
throw new IdempotencyAlreadyInProgressError(
40+
`There is already an execution in progress with idempotency key: ${idempotencyRecord.idempotencyKey}`
41+
);
42+
}
2743
} else {
2844
// Currently recalling the method as this fulfills FR1. FR3 will address using the previously stored value https://github.com/awslabs/aws-lambda-powertools-typescript/issues/447
29-
return this.functionToMakeIdempotent(this.fullFunctionPayload);
45+
return this.functionToMakeIdempotent(this.fullFunctionPayload);
46+
}
47+
}
48+
49+
/**
50+
* Main entry point for the handler
51+
* IdempotencyInconsistentStateError can happen under rare but expected cases
52+
* when persistent state changes in the small time between put & get requests.
53+
* In most cases we can retry successfully on this exception.
54+
*/
55+
public async handle(): Promise<U> {
56+
57+
const MAX_RETRIES = 2;
58+
for (let i = 1; i <= MAX_RETRIES; i++) {
59+
try {
60+
return await this.processIdempotency();
61+
} catch (e) {
62+
if (!(e instanceof IdempotencyAlreadyInProgressError) || i === MAX_RETRIES) {
63+
throw e;
64+
}
65+
}
3066
}
67+
/* istanbul ignore next */
68+
throw new Error('This should never happen');
3169
}
3270

3371
public async processIdempotency(): Promise<U> {
3472
try {
35-
await this.idempotencyOptions.persistenceStore.saveInProgress(this.functionPayloadToBeHashed);
73+
await this.idempotencyOptions.persistenceStore.saveInProgress(
74+
this.functionPayloadToBeHashed,
75+
);
3676
} catch (e) {
3777
if (e instanceof IdempotencyItemAlreadyExistsError) {
38-
const idempotencyRecord: IdempotencyRecord = await this.idempotencyOptions.persistenceStore.getRecord(this.functionPayloadToBeHashed);
78+
const idempotencyRecord: IdempotencyRecord =
79+
await this.idempotencyOptions.persistenceStore.getRecord(
80+
this.functionPayloadToBeHashed
81+
);
3982

4083
return this.determineResultFromIdempotencyRecord(idempotencyRecord);
4184
} else {
@@ -45,4 +88,4 @@ export class IdempotencyHandler<U> {
4588

4689
return this.functionToMakeIdempotent(this.fullFunctionPayload);
4790
}
48-
}
91+
}

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

+4-5
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@ const idempotent = function (options: IdempotencyOptions) {
1111
descriptor.value = function(record: GenericTempRecord){
1212
const idempotencyHandler = new IdempotencyHandler<GenericTempRecord>(childFunction, record[options.dataKeywordArgument], options, record);
1313

14-
return idempotencyHandler.processIdempotency();
14+
return idempotencyHandler.handle();
1515
};
16-
16+
1717
return descriptor;
18-
};
18+
};
1919
};
20-
20+
2121
export { idempotent };
22-

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const makeFunctionIdempotent = function <U>(
1313
const wrappedFn: AnyIdempotentFunction<U> = function (record: GenericTempRecord): Promise<U> {
1414
const idempotencyHandler: IdempotencyHandler<U> = new IdempotencyHandler<U>(fn, record[options.dataKeywordArgument], options, record);
1515

16-
return idempotencyHandler.processIdempotency();
16+
return idempotencyHandler.handle();
1717
};
1818

1919
return wrappedFn;
+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* Test Idempotency Handler
3+
*
4+
* @group unit/idempotency/IdempotencyHandler
5+
*/
6+
7+
import {
8+
IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError,
9+
IdempotencyItemAlreadyExistsError,
10+
IdempotencyPersistenceLayerError
11+
} from '../../src/Exceptions';
12+
import { IdempotencyOptions, IdempotencyRecordStatus } from '../../src/types';
13+
import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence';
14+
import { IdempotencyHandler } from '../../src/IdempotencyHandler';
15+
16+
class PersistenceLayerTestClass extends BasePersistenceLayer {
17+
protected _deleteRecord = jest.fn();
18+
protected _getRecord = jest.fn();
19+
protected _putRecord = jest.fn();
20+
protected _updateRecord = jest.fn();
21+
}
22+
23+
const mockFunctionToMakeIdempotent = jest.fn();
24+
const mockFunctionPayloadToBeHashed = {};
25+
const mockIdempotencyOptions: IdempotencyOptions = {
26+
persistenceStore: new PersistenceLayerTestClass(),
27+
dataKeywordArgument: 'testingKey'
28+
};
29+
const mockFullFunctionPayload = {};
30+
31+
const idempotentHandler = new IdempotencyHandler(
32+
mockFunctionToMakeIdempotent,
33+
mockFunctionPayloadToBeHashed,
34+
mockIdempotencyOptions,
35+
mockFullFunctionPayload,
36+
);
37+
38+
describe('Class IdempotencyHandler', () => {
39+
beforeEach(() => jest.resetAllMocks());
40+
41+
describe('Method: determineResultFromIdempotencyRecord', () => {
42+
test('when record is in progress and within expiry window, it rejects with IdempotencyAlreadyInProgressError', async () => {
43+
44+
const stubRecord = new IdempotencyRecord({
45+
idempotencyKey: 'idempotencyKey',
46+
expiryTimestamp: Date.now() + 1000, // should be in the future
47+
inProgressExpiryTimestamp: 0, // less than current time in milliseconds
48+
responseData: { responseData: 'responseData' },
49+
payloadHash: 'payloadHash',
50+
status: IdempotencyRecordStatus.INPROGRESS
51+
});
52+
53+
expect(stubRecord.isExpired()).toBe(false);
54+
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS);
55+
56+
try {
57+
await idempotentHandler.determineResultFromIdempotencyRecord(stubRecord);
58+
} catch (e) {
59+
expect(e).toBeInstanceOf(IdempotencyAlreadyInProgressError);
60+
}
61+
});
62+
63+
test('when record is in progress and outside expiry window, it rejects with IdempotencyInconsistentStateError', async () => {
64+
65+
const stubRecord = new IdempotencyRecord({
66+
idempotencyKey: 'idempotencyKey',
67+
expiryTimestamp: Date.now() + 1000, // should be in the future
68+
inProgressExpiryTimestamp: new Date().getUTCMilliseconds() - 1000, // should be in the past
69+
responseData: { responseData: 'responseData' },
70+
payloadHash: 'payloadHash',
71+
status: IdempotencyRecordStatus.INPROGRESS
72+
});
73+
74+
expect(stubRecord.isExpired()).toBe(false);
75+
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS);
76+
77+
try {
78+
await idempotentHandler.determineResultFromIdempotencyRecord(stubRecord);
79+
} catch (e) {
80+
expect(e).toBeInstanceOf(IdempotencyInconsistentStateError);
81+
}
82+
});
83+
84+
test('when record is expired, it rejects with IdempotencyInconsistentStateError', async () => {
85+
86+
const stubRecord = new IdempotencyRecord({
87+
idempotencyKey: 'idempotencyKey',
88+
expiryTimestamp: new Date().getUTCMilliseconds() - 1000, // should be in the past
89+
inProgressExpiryTimestamp: 0, // less than current time in milliseconds
90+
responseData: { responseData: 'responseData' },
91+
payloadHash: 'payloadHash',
92+
status: IdempotencyRecordStatus.EXPIRED
93+
});
94+
95+
expect(stubRecord.isExpired()).toBe(true);
96+
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.EXPIRED);
97+
98+
try {
99+
await idempotentHandler.determineResultFromIdempotencyRecord(stubRecord);
100+
} catch (e) {
101+
expect(e).toBeInstanceOf(IdempotencyInconsistentStateError);
102+
}
103+
});
104+
});
105+
106+
describe('Method: handle', () => {
107+
108+
afterAll(() => jest.restoreAllMocks()); // restore processIdempotency for other tests
109+
110+
test('when IdempotencyAlreadyInProgressError is thrown, it retries two times', async () => {
111+
const mockProcessIdempotency = jest.spyOn(IdempotencyHandler.prototype, 'processIdempotency').mockRejectedValue(new IdempotencyAlreadyInProgressError('There is already an execution in progress'));
112+
await expect(
113+
idempotentHandler.handle()
114+
).rejects.toThrow(IdempotencyAlreadyInProgressError);
115+
expect(mockProcessIdempotency).toHaveBeenCalledTimes(2);
116+
});
117+
118+
test('when non IdempotencyAlreadyInProgressError is thrown, it rejects', async () => {
119+
120+
const mockProcessIdempotency = jest.spyOn(IdempotencyHandler.prototype, 'processIdempotency').mockRejectedValue(new Error('Some other error'));
121+
122+
await expect(
123+
idempotentHandler.handle()
124+
).rejects.toThrow(Error);
125+
expect(mockProcessIdempotency).toHaveBeenCalledTimes(1);
126+
});
127+
128+
});
129+
130+
describe('Method: processIdempotency', () => {
131+
132+
test('when persistenceStore saves successfuly, it resolves', async () => {
133+
const mockSaveInProgress = jest.spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress').mockResolvedValue();
134+
135+
mockFunctionToMakeIdempotent.mockImplementation(() => Promise.resolve('result'));
136+
137+
await expect(
138+
idempotentHandler.processIdempotency()
139+
).resolves.toBe('result');
140+
expect(mockSaveInProgress).toHaveBeenCalledTimes(1);
141+
});
142+
143+
test('when persistences store throws any error, it wraps the error to IdempotencyPersistencesLayerError', async () => {
144+
const mockSaveInProgress = jest.spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress').mockRejectedValue(new Error('Some error'));
145+
const mockDetermineResultFromIdempotencyRecord = jest.spyOn(IdempotencyHandler.prototype, 'determineResultFromIdempotencyRecord').mockResolvedValue('result');
146+
147+
await expect(
148+
idempotentHandler.processIdempotency()
149+
).rejects.toThrow(IdempotencyPersistenceLayerError);
150+
expect(mockSaveInProgress).toHaveBeenCalledTimes(1);
151+
expect(mockDetermineResultFromIdempotencyRecord).toHaveBeenCalledTimes(0);
152+
});
153+
154+
test('when idempotency item already exists, it returns the existing record', async () => {
155+
const mockSaveInProgress = jest.spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress').mockRejectedValue(new IdempotencyItemAlreadyExistsError('There is already an execution in progress'));
156+
157+
const stubRecord = new IdempotencyRecord({
158+
idempotencyKey: 'idempotencyKey',
159+
expiryTimestamp: 0,
160+
inProgressExpiryTimestamp: 0,
161+
responseData: { responseData: 'responseData' },
162+
payloadHash: 'payloadHash',
163+
status: IdempotencyRecordStatus.INPROGRESS
164+
});
165+
const mockGetRecord = jest.spyOn(mockIdempotencyOptions.persistenceStore, 'getRecord').mockImplementation(() => Promise.resolve(stubRecord));
166+
const mockDetermineResultFromIdempotencyRecord = jest.spyOn(IdempotencyHandler.prototype, 'determineResultFromIdempotencyRecord').mockResolvedValue('result');
167+
168+
await expect(
169+
idempotentHandler.processIdempotency()
170+
).resolves.toBe('result');
171+
expect(mockSaveInProgress).toHaveBeenCalledTimes(1);
172+
expect(mockGetRecord).toHaveBeenCalledTimes(1);
173+
expect(mockDetermineResultFromIdempotencyRecord).toHaveBeenCalledTimes(1);
174+
});
175+
});
176+
177+
});
178+

0 commit comments

Comments
 (0)