Skip to content

Commit 611a2d8

Browse files
committed
fix(idempotency): check error identity via names
1 parent 783c3d8 commit 611a2d8

File tree

5 files changed

+157
-29
lines changed

5 files changed

+157
-29
lines changed

packages/idempotency/src/IdempotencyHandler.ts

+26-12
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,11 +356,14 @@ export class IdempotencyHandler<Func extends AnyFunction> {
345356
);
346357

347358
return returnValue;
348-
} catch (e) {
349-
/* if (!(e instanceof Error)) throw e;
350-
if (e.name === 'IdempotencyItemAlreadyExistsError') { */
351-
if (e instanceof IdempotencyItemAlreadyExistsError) {
352-
let idempotencyRecord = (e as IdempotencyItemAlreadyExistsError)
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)
353367
.existingRecord;
354368
if (idempotencyRecord !== undefined) {
355369
// If the error includes the existing record, we can use it to validate
@@ -377,7 +391,7 @@ export class IdempotencyHandler<Func extends AnyFunction> {
377391
} else {
378392
throw new IdempotencyPersistenceLayerError(
379393
'Failed to save in progress record to idempotency store',
380-
e as Error
394+
{ cause: error }
381395
);
382396
}
383397
}
@@ -396,10 +410,10 @@ export class IdempotencyHandler<Func extends AnyFunction> {
396410
this.#functionPayloadToBeHashed,
397411
result
398412
);
399-
} catch (e) {
413+
} catch (error) {
400414
throw new IdempotencyPersistenceLayerError(
401415
'Failed to update success record to idempotency store',
402-
e as Error
416+
{ cause: error }
403417
);
404418
}
405419
};

packages/idempotency/src/errors.ts

+69-16
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
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 = 'IdempotencyError';
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);
1127
this.name = 'IdempotencyItemAlreadyExistsError';
1228
this.existingRecord = existingRecord;
1329
}
@@ -16,54 +32,91 @@ class IdempotencyItemAlreadyExistsError extends Error {
1632
/**
1733
* Item does not exist in persistence store
1834
*/
19-
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+
}
2041

2142
/**
2243
* Execution with idempotency key is already in progress
2344
*/
24-
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+
}
2558

2659
/**
2760
* An invalid status was provided
2861
*/
29-
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+
}
3068

3169
/**
3270
* Payload does not match stored idempotency record
3371
*/
34-
class IdempotencyValidationError extends Error {
72+
class IdempotencyValidationError extends IdempotencyUnknownError {
3573
public existingRecord?: IdempotencyRecord;
3674

37-
public constructor(message?: string, existingRecord?: IdempotencyRecord) {
38-
super(message);
75+
public constructor(
76+
message?: string,
77+
existingRecord?: IdempotencyRecord,
78+
options?: ErrorOptions
79+
) {
80+
super(message, options);
81+
this.name = 'IdempotencyValidationError';
3982
this.existingRecord = existingRecord;
4083
}
4184
}
4285

4386
/**
4487
* State is inconsistent across multiple requests to persistence store
4588
*/
46-
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+
}
4795

4896
/**
4997
* Unrecoverable error from the data store
5098
*/
51-
class IdempotencyPersistenceLayerError extends Error {
99+
class IdempotencyPersistenceLayerError extends IdempotencyUnknownError {
52100
public readonly cause: Error | undefined;
53101

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

61108
/**
62109
* Payload does not contain an idempotent key
63110
*/
64-
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+
}
65117

66118
export {
119+
IdempotencyUnknownError,
67120
IdempotencyItemAlreadyExistsError,
68121
IdempotencyItemNotFoundError,
69122
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)