Skip to content

Commit 55c3878

Browse files
authored
fix(idempotency): check error identity via names (#2747)
1 parent 74198ef commit 55c3878

File tree

5 files changed

+159
-27
lines changed

5 files changed

+159
-27
lines changed

packages/idempotency/src/IdempotencyHandler.ts

+27-10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
IdempotencyInconsistentStateError,
1313
IdempotencyItemAlreadyExistsError,
1414
IdempotencyPersistenceLayerError,
15+
IdempotencyUnknownError,
1516
} from './errors.js';
1617
import { BasePersistenceLayer } from './persistence/BasePersistenceLayer.js';
1718
import { IdempotencyRecord } from './persistence/IdempotencyRecord.js';
@@ -176,8 +177,13 @@ export class IdempotencyHandler<Func extends AnyFunction> {
176177

177178
return await this.getFunctionResult();
178179
} catch (error) {
180+
if (!(error instanceof Error))
181+
throw new IdempotencyUnknownError(
182+
'An unknown error occurred while processing the request.',
183+
{ cause: error }
184+
);
179185
if (
180-
error instanceof IdempotencyInconsistentStateError &&
186+
error.name === 'IdempotencyInconsistentStateError' &&
181187
retryNo < MAX_RETRIES
182188
) {
183189
// Retry
@@ -241,7 +247,12 @@ export class IdempotencyHandler<Func extends AnyFunction> {
241247
break;
242248
} catch (error) {
243249
if (
244-
error instanceof IdempotencyInconsistentStateError &&
250+
/**
251+
* It's safe to cast the error here because this catch block is only
252+
* reached when an error is thrown in code paths that we control,
253+
* and we only throw instances of `Error`.
254+
*/
255+
(error as Error).name === 'IdempotencyInconsistentStateError' &&
245256
retryNo < MAX_RETRIES
246257
) {
247258
// Retry
@@ -313,10 +324,10 @@ export class IdempotencyHandler<Func extends AnyFunction> {
313324
await this.#persistenceStore.deleteRecord(
314325
this.#functionPayloadToBeHashed
315326
);
316-
} catch (e) {
327+
} catch (error) {
317328
throw new IdempotencyPersistenceLayerError(
318329
'Failed to delete record from idempotency store',
319-
e as Error
330+
{ cause: error }
320331
);
321332
}
322333
};
@@ -345,9 +356,15 @@ export class IdempotencyHandler<Func extends AnyFunction> {
345356
);
346357

347358
return returnValue;
348-
} catch (e) {
349-
if (e instanceof IdempotencyItemAlreadyExistsError) {
350-
let idempotencyRecord = e.existingRecord;
359+
} catch (error) {
360+
if (!(error instanceof Error))
361+
throw new IdempotencyUnknownError(
362+
'An unknown error occurred while processing the request.',
363+
{ cause: error }
364+
);
365+
if (error.name === 'IdempotencyItemAlreadyExistsError') {
366+
let idempotencyRecord = (error as IdempotencyItemAlreadyExistsError)
367+
.existingRecord;
351368
if (idempotencyRecord !== undefined) {
352369
// If the error includes the existing record, we can use it to validate
353370
// the record being processed and cache it in memory.
@@ -374,7 +391,7 @@ export class IdempotencyHandler<Func extends AnyFunction> {
374391
} else {
375392
throw new IdempotencyPersistenceLayerError(
376393
'Failed to save in progress record to idempotency store',
377-
e as Error
394+
{ cause: error }
378395
);
379396
}
380397
}
@@ -393,10 +410,10 @@ export class IdempotencyHandler<Func extends AnyFunction> {
393410
this.#functionPayloadToBeHashed,
394411
result
395412
);
396-
} catch (e) {
413+
} catch (error) {
397414
throw new IdempotencyPersistenceLayerError(
398415
'Failed to update success record to idempotency store',
399-
e as Error
416+
{ cause: error }
400417
);
401418
}
402419
};

packages/idempotency/src/errors.ts

+70-16
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,122 @@
11
import type { IdempotencyRecord } from './persistence/IdempotencyRecord.js';
22

3+
/**
4+
* Base error for idempotency errors.
5+
*
6+
* Generally this error should not be thrown directly unless you are throwing a generic and unknown error.
7+
*/
8+
class IdempotencyUnknownError extends Error {
9+
public constructor(message?: string, options?: ErrorOptions) {
10+
super(message, options);
11+
this.name = 'IdempotencyUnknownError';
12+
}
13+
}
14+
315
/**
416
* Item attempting to be inserted into persistence store already exists and is not expired
517
*/
6-
class IdempotencyItemAlreadyExistsError extends Error {
18+
class IdempotencyItemAlreadyExistsError extends IdempotencyUnknownError {
719
public existingRecord?: IdempotencyRecord;
820

9-
public constructor(message?: string, existingRecord?: IdempotencyRecord) {
10-
super(message);
21+
public constructor(
22+
message?: string,
23+
existingRecord?: IdempotencyRecord,
24+
options?: ErrorOptions
25+
) {
26+
super(message, options);
27+
this.name = 'IdempotencyItemAlreadyExistsError';
1128
this.existingRecord = existingRecord;
1229
}
1330
}
1431

1532
/**
1633
* Item does not exist in persistence store
1734
*/
18-
class IdempotencyItemNotFoundError extends Error {}
35+
class IdempotencyItemNotFoundError extends IdempotencyUnknownError {
36+
public constructor(message?: string, options?: ErrorOptions) {
37+
super(message, options);
38+
this.name = 'IdempotencyItemNotFoundError';
39+
}
40+
}
1941

2042
/**
2143
* Execution with idempotency key is already in progress
2244
*/
23-
class IdempotencyAlreadyInProgressError extends Error {}
45+
class IdempotencyAlreadyInProgressError extends IdempotencyUnknownError {
46+
public existingRecord?: IdempotencyRecord;
47+
48+
public constructor(
49+
message?: string,
50+
existingRecord?: IdempotencyRecord,
51+
options?: ErrorOptions
52+
) {
53+
super(message, options);
54+
this.name = 'IdempotencyAlreadyInProgressError';
55+
this.existingRecord = existingRecord;
56+
}
57+
}
2458

2559
/**
2660
* An invalid status was provided
2761
*/
28-
class IdempotencyInvalidStatusError extends Error {}
62+
class IdempotencyInvalidStatusError extends IdempotencyUnknownError {
63+
public constructor(message?: string, options?: ErrorOptions) {
64+
super(message, options);
65+
this.name = 'IdempotencyInvalidStatusError';
66+
}
67+
}
2968

3069
/**
3170
* Payload does not match stored idempotency record
3271
*/
33-
class IdempotencyValidationError extends Error {
72+
class IdempotencyValidationError extends IdempotencyUnknownError {
3473
public existingRecord?: IdempotencyRecord;
3574

36-
public constructor(message?: string, existingRecord?: IdempotencyRecord) {
37-
super(message);
75+
public constructor(
76+
message?: string,
77+
existingRecord?: IdempotencyRecord,
78+
options?: ErrorOptions
79+
) {
80+
super(message, options);
81+
this.name = 'IdempotencyValidationError';
3882
this.existingRecord = existingRecord;
3983
}
4084
}
4185

4286
/**
4387
* State is inconsistent across multiple requests to persistence store
4488
*/
45-
class IdempotencyInconsistentStateError extends Error {}
89+
class IdempotencyInconsistentStateError extends IdempotencyUnknownError {
90+
public constructor(message?: string, options?: ErrorOptions) {
91+
super(message, options);
92+
this.name = 'IdempotencyInconsistentStateError';
93+
}
94+
}
4695

4796
/**
4897
* Unrecoverable error from the data store
4998
*/
50-
class IdempotencyPersistenceLayerError extends Error {
99+
class IdempotencyPersistenceLayerError extends IdempotencyUnknownError {
51100
public readonly cause: Error | undefined;
52101

53-
public constructor(message: string, cause: Error) {
54-
const errorMessage = `${message}. This error was caused by: ${cause.message}.`;
55-
super(errorMessage);
56-
this.cause = cause;
102+
public constructor(message: string, options?: ErrorOptions) {
103+
super(message, options);
104+
this.name = 'IdempotencyPersistenceLayerError';
57105
}
58106
}
59107

60108
/**
61109
* Payload does not contain an idempotent key
62110
*/
63-
class IdempotencyKeyError extends Error {}
111+
class IdempotencyKeyError extends IdempotencyUnknownError {
112+
public constructor(message?: string, options?: ErrorOptions) {
113+
super(message, options);
114+
this.name = 'IdempotencyKeyError';
115+
}
116+
}
64117

65118
export {
119+
IdempotencyUnknownError,
66120
IdempotencyItemAlreadyExistsError,
67121
IdempotencyItemNotFoundError,
68122
IdempotencyAlreadyInProgressError,

packages/idempotency/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
IdempotencyInconsistentStateError,
88
IdempotencyPersistenceLayerError,
99
IdempotencyKeyError,
10+
IdempotencyUnknownError,
1011
} from './errors.js';
1112
export { IdempotencyConfig } from './IdempotencyConfig.js';
1213
export { makeIdempotent } from './makeIdempotent.js';

packages/idempotency/tests/unit/makeHandlerIdempotent.test.ts

+34
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
IdempotencyPersistenceLayerError,
1313
IdempotencyConfig,
1414
IdempotencyRecordStatus,
15+
IdempotencyUnknownError,
1516
} from '../../src/index.js';
1617
import middy from '@middy/core';
1718
import { MAX_RETRIES } from '../../src/constants.js';
@@ -246,6 +247,39 @@ describe('Middleware: makeHandlerIdempotent', () => {
246247
);
247248
expect(getRecordSpy).toHaveBeenCalledTimes(MAX_RETRIES + 1);
248249
});
250+
it('throws immediately if an object other than an error was thrown', async () => {
251+
// Prepare
252+
const handler = middy(
253+
async (_event: unknown, _context: Context): Promise<boolean> => {
254+
return true;
255+
}
256+
).use(makeHandlerIdempotent(mockIdempotencyOptions));
257+
jest
258+
.spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress')
259+
.mockImplementationOnce(() => {
260+
// eslint-disable-next-line no-throw-literal
261+
throw 'Something went wrong';
262+
});
263+
const stubRecordInconsistent = new IdempotencyRecord({
264+
idempotencyKey: 'idempotencyKey',
265+
expiryTimestamp: Date.now() + 10000,
266+
inProgressExpiryTimestamp: 0,
267+
responseData: { response: false },
268+
payloadHash: 'payloadHash',
269+
status: IdempotencyRecordStatus.EXPIRED,
270+
});
271+
const getRecordSpy = jest
272+
.spyOn(mockIdempotencyOptions.persistenceStore, 'getRecord')
273+
.mockResolvedValue(stubRecordInconsistent);
274+
275+
// Act & Assess
276+
await expect(handler(event, context)).rejects.toThrow(
277+
new IdempotencyUnknownError(
278+
'An unknown error occurred while processing the request.'
279+
)
280+
);
281+
expect(getRecordSpy).toHaveBeenCalledTimes(0);
282+
});
249283
it('does not do anything if idempotency is disabled', async () => {
250284
// Prepare
251285
process.env.POWERTOOLS_IDEMPOTENCY_DISABLED = 'true';

packages/idempotency/tests/unit/makeIdempotent.test.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
IdempotencyPersistenceLayerError,
1212
IdempotencyConfig,
1313
IdempotencyRecordStatus,
14+
IdempotencyUnknownError,
1415
} from '../../src/index.js';
1516
import context from '@aws-lambda-powertools/testing-utils/context';
1617
import { MAX_RETRIES } from '../../src/constants.js';
@@ -265,13 +266,38 @@ describe('Function: makeIdempotent', () => {
265266
.mockResolvedValue(stubRecordInconsistent);
266267

267268
// Act & Assess
268-
await expect(handler(event, context)).rejects.toThrowError(
269+
await expect(handler(event, context)).rejects.toThrow(
269270
new IdempotencyInconsistentStateError(
270271
'Item has expired during processing and may not longer be valid.'
271272
)
272273
);
273274
expect(getRecordSpy).toHaveBeenCalledTimes(MAX_RETRIES + 1);
274275
});
276+
it('throws immediately if an object other than an error was thrown', async () => {
277+
// Prepare
278+
const handler = makeIdempotent(
279+
async (_event: unknown, _context: Context) => {
280+
// eslint-disable-next-line no-throw-literal
281+
throw 'Something went wrong';
282+
},
283+
{
284+
...mockIdempotencyOptions,
285+
config: new IdempotencyConfig({}),
286+
}
287+
);
288+
const saveSuccessSpy = jest.spyOn(
289+
mockIdempotencyOptions.persistenceStore,
290+
'saveSuccess'
291+
);
292+
293+
// Act & Assess
294+
await expect(handler(event, context)).rejects.toThrow(
295+
new IdempotencyUnknownError(
296+
'An unknown error occurred while processing the request.'
297+
)
298+
);
299+
expect(saveSuccessSpy).toHaveBeenCalledTimes(0);
300+
});
275301
it('does not do anything if idempotency is disabled', async () => {
276302
// Prepare
277303
process.env.POWERTOOLS_IDEMPOTENCY_DISABLED = 'true';

0 commit comments

Comments
 (0)