From a558f100209978a075e98a8c8a3763c4145c8a94 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Mon, 5 Jun 2023 16:48:41 +0200 Subject: [PATCH] feat(idempotency): `makeHandlerIdempotent` middy middleware (#1474) * feat: makeHandlerIdempotent middy middleware --- packages/idempotency/src/IdempotencyConfig.ts | 14 + .../idempotency/src/IdempotencyHandler.ts | 37 ++- .../src/config/ConfigServiceInterface.ts | 2 + .../src/config/EnvironmentVariablesService.ts | 12 + packages/idempotency/src/constants.ts | 10 + .../idempotency/src/makeFunctionIdempotent.ts | 35 ++- packages/idempotency/src/middleware/index.ts | 1 + .../src/middleware/makeHandlerIdempotent.ts | 175 +++++++++++ packages/idempotency/src/types/AnyFunction.ts | 10 +- .../tests/helpers/idempotencyUtils.ts | 15 + .../tests/unit/IdempotencyHandler.test.ts | 37 +-- .../tests/unit/makeHandlerIdempotent.test.ts | 271 ++++++++++++++++++ 12 files changed, 577 insertions(+), 42 deletions(-) create mode 100644 packages/idempotency/src/constants.ts create mode 100644 packages/idempotency/src/middleware/index.ts create mode 100644 packages/idempotency/src/middleware/makeHandlerIdempotent.ts create mode 100644 packages/idempotency/tests/unit/makeHandlerIdempotent.test.ts diff --git a/packages/idempotency/src/IdempotencyConfig.ts b/packages/idempotency/src/IdempotencyConfig.ts index 8483dbd981..f8a00ed8d4 100644 --- a/packages/idempotency/src/IdempotencyConfig.ts +++ b/packages/idempotency/src/IdempotencyConfig.ts @@ -1,3 +1,4 @@ +import { EnvironmentVariablesService } from './config'; import type { Context } from 'aws-lambda'; import type { IdempotencyConfigOptions } from './types'; @@ -10,6 +11,8 @@ class IdempotencyConfig { public payloadValidationJmesPath?: string; public throwOnNoIdempotencyKey: boolean; public useLocalCache: boolean; + readonly #envVarsService: EnvironmentVariablesService; + readonly #enabled: boolean = true; public constructor(config: IdempotencyConfigOptions) { this.eventKeyJmesPath = config.eventKeyJmesPath ?? ''; @@ -20,6 +23,17 @@ class IdempotencyConfig { this.maxLocalCacheSize = config.maxLocalCacheSize ?? 1000; this.hashFunction = config.hashFunction ?? 'md5'; this.lambdaContext = config.lambdaContext; + this.#envVarsService = new EnvironmentVariablesService(); + this.#enabled = this.#envVarsService.getIdempotencyEnabled(); + } + + /** + * Determines if the idempotency feature is enabled. + * + * @returns {boolean} Returns true if the idempotency feature is enabled. + */ + public isEnabled(): boolean { + return this.#enabled; } public registerLambdaContext(context: Context): void { diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index 274d714a20..f8983e59c0 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -8,6 +8,7 @@ import { } from './Exceptions'; import { BasePersistenceLayer, IdempotencyRecord } from './persistence'; import { IdempotencyConfig } from './IdempotencyConfig'; +import { MAX_RETRIES } from './constants'; export class IdempotencyHandler { private readonly fullFunctionPayload: Record; @@ -36,9 +37,9 @@ export class IdempotencyHandler { }); } - public determineResultFromIdempotencyRecord( + public static determineResultFromIdempotencyRecord( idempotencyRecord: IdempotencyRecord - ): Promise | U { + ): Promise | unknown { if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.EXPIRED) { throw new IdempotencyInconsistentStateError( 'Item has expired during processing and may not longer be valid.' @@ -61,7 +62,7 @@ export class IdempotencyHandler { } } - return idempotencyRecord.getResponse() as U; + return idempotencyRecord.getResponse(); } public async getFunctionResult(): Promise { @@ -96,26 +97,30 @@ export class IdempotencyHandler { /** * Main entry point for the handler - * IdempotencyInconsistentStateError can happen under rare but expected cases - * when persistent state changes in the small time between put & get requests. - * In most cases we can retry successfully on this exception. + * + * In some rare cases, when the persistent state changes in small time + * window, we might get an `IdempotencyInconsistentStateError`. In such + * cases we can safely retry the handling a few times. */ public async handle(): Promise { - const MAX_RETRIES = 2; - for (let i = 1; i <= MAX_RETRIES; i++) { + let e; + for (let retryNo = 0; retryNo <= MAX_RETRIES; retryNo++) { try { return await this.processIdempotency(); - } catch (e) { + } catch (error) { if ( - !(e instanceof IdempotencyAlreadyInProgressError) || - i === MAX_RETRIES + error instanceof IdempotencyInconsistentStateError && + retryNo < MAX_RETRIES ) { - throw e; + // Retry + continue; } + // Retries exhausted or other error + e = error; + break; } } - /* istanbul ignore next */ - throw new Error('This should never happen'); + throw e; } public async processIdempotency(): Promise { @@ -128,7 +133,9 @@ export class IdempotencyHandler { const idempotencyRecord: IdempotencyRecord = await this.persistenceStore.getRecord(this.functionPayloadToBeHashed); - return this.determineResultFromIdempotencyRecord(idempotencyRecord); + return IdempotencyHandler.determineResultFromIdempotencyRecord( + idempotencyRecord + ) as U; } else { throw new IdempotencyPersistenceLayerError(); } diff --git a/packages/idempotency/src/config/ConfigServiceInterface.ts b/packages/idempotency/src/config/ConfigServiceInterface.ts index c40e302b73..f4938baeaf 100644 --- a/packages/idempotency/src/config/ConfigServiceInterface.ts +++ b/packages/idempotency/src/config/ConfigServiceInterface.ts @@ -4,6 +4,8 @@ interface ConfigServiceInterface { getServiceName(): string; getFunctionName(): string; + + getIdempotencyEnabled(): boolean; } export { ConfigServiceInterface }; diff --git a/packages/idempotency/src/config/EnvironmentVariablesService.ts b/packages/idempotency/src/config/EnvironmentVariablesService.ts index e73b9775ae..3c3ba48e1c 100644 --- a/packages/idempotency/src/config/EnvironmentVariablesService.ts +++ b/packages/idempotency/src/config/EnvironmentVariablesService.ts @@ -21,6 +21,7 @@ class EnvironmentVariablesService { // Reserved environment variables private functionNameVariable = 'AWS_LAMBDA_FUNCTION_NAME'; + private idempotencyDisabledVariable = 'POWERTOOLS_IDEMPOTENCY_DISABLED'; /** * It returns the value of the AWS_LAMBDA_FUNCTION_NAME environment variable. @@ -30,6 +31,17 @@ class EnvironmentVariablesService public getFunctionName(): string { return this.get(this.functionNameVariable); } + + /** + * It returns whether the idempotency feature is enabled or not. + * + * Reads the value of the POWERTOOLS_IDEMPOTENCY_DISABLED environment variable. + * + * @returns {boolean} + */ + public getIdempotencyEnabled(): boolean { + return !this.isValueTrue(this.get(this.idempotencyDisabledVariable)); + } } export { EnvironmentVariablesService }; diff --git a/packages/idempotency/src/constants.ts b/packages/idempotency/src/constants.ts new file mode 100644 index 0000000000..ed100116b2 --- /dev/null +++ b/packages/idempotency/src/constants.ts @@ -0,0 +1,10 @@ +/** + * Number of times to retry a request in case of `IdempotencyInconsistentStateError` + * + * Used in `IdempotencyHandler` and `makeHandlerIdempotent` + * + * @internal + */ +const MAX_RETRIES = 2; + +export { MAX_RETRIES }; diff --git a/packages/idempotency/src/makeFunctionIdempotent.ts b/packages/idempotency/src/makeFunctionIdempotent.ts index 3415cb0d6d..8b4bf6145e 100644 --- a/packages/idempotency/src/makeFunctionIdempotent.ts +++ b/packages/idempotency/src/makeFunctionIdempotent.ts @@ -1,41 +1,58 @@ +import type { Context } from 'aws-lambda'; import type { AnyFunctionWithRecord, AnyIdempotentFunction, - GenericTempRecord, IdempotencyFunctionOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; import { IdempotencyConfig } from './IdempotencyConfig'; +const isContext = (arg: unknown): arg is Context => { + return ( + arg !== undefined && + arg !== null && + typeof arg === 'object' && + 'getRemainingTimeInMillis' in arg + ); +}; + const makeFunctionIdempotent = function ( fn: AnyFunctionWithRecord, options: IdempotencyFunctionOptions -): AnyIdempotentFunction { +): AnyIdempotentFunction | AnyFunctionWithRecord { + const idempotencyConfig = options.config + ? options.config + : new IdempotencyConfig({}); + const wrappedFn: AnyIdempotentFunction = function ( - record: GenericTempRecord + ...args: Parameters> ): Promise { + const payload = args[0]; + const context = args[1]; + if (options.dataKeywordArgument === undefined) { throw new Error( `Missing data keyword argument ${options.dataKeywordArgument}` ); } - const idempotencyConfig = options.config - ? options.config - : new IdempotencyConfig({}); + if (isContext(context)) { + idempotencyConfig.registerLambdaContext(context); + } const idempotencyHandler: IdempotencyHandler = new IdempotencyHandler( { functionToMakeIdempotent: fn, - functionPayloadToBeHashed: record[options.dataKeywordArgument], + functionPayloadToBeHashed: payload[options.dataKeywordArgument], idempotencyConfig: idempotencyConfig, persistenceStore: options.persistenceStore, - fullFunctionPayload: record, + fullFunctionPayload: payload, } ); return idempotencyHandler.handle(); }; - return wrappedFn; + if (idempotencyConfig.isEnabled()) return wrappedFn; + else return fn; }; export { makeFunctionIdempotent }; diff --git a/packages/idempotency/src/middleware/index.ts b/packages/idempotency/src/middleware/index.ts new file mode 100644 index 0000000000..c95b47d06a --- /dev/null +++ b/packages/idempotency/src/middleware/index.ts @@ -0,0 +1 @@ +export * from './makeHandlerIdempotent'; diff --git a/packages/idempotency/src/middleware/makeHandlerIdempotent.ts b/packages/idempotency/src/middleware/makeHandlerIdempotent.ts new file mode 100644 index 0000000000..38e2eaa704 --- /dev/null +++ b/packages/idempotency/src/middleware/makeHandlerIdempotent.ts @@ -0,0 +1,175 @@ +import { IdempotencyHandler } from '../IdempotencyHandler'; +import { IdempotencyConfig } from '../IdempotencyConfig'; +import { cleanupMiddlewares } from '@aws-lambda-powertools/commons/lib/middleware'; +import { + IdempotencyItemAlreadyExistsError, + IdempotencyPersistenceLayerError, + IdempotencyInconsistentStateError, +} from '../Exceptions'; +import { IdempotencyRecord } from '../persistence'; +import { MAX_RETRIES } from '../constants'; +import type { + MiddlewareLikeObj, + MiddyLikeRequest, +} from '@aws-lambda-powertools/commons'; +import type { IdempotencyLambdaHandlerOptions } from '../types'; + +/** + * A middy middleware to make your Lambda Handler idempotent. + * + * @example + * ```typescript + * import { + * makeHandlerIdempotent, + * DynamoDBPersistenceLayer, + * } from '@aws-lambda-powertools/idempotency'; + * import middy from '@middy/core'; + * + * const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ + * tableName: 'idempotencyTable', + * }); + * + * const lambdaHandler = async (_event: unknown, _context: unknown) => { + * //... + * }; + * + * export const handler = middy(lambdaHandler) + * .use(makeHandlerIdempotent({ persistenceStore: dynamoDBPersistenceLayer })); + * ``` + * + * @param options - Options for the idempotency middleware + */ +const makeHandlerIdempotent = ( + options: IdempotencyLambdaHandlerOptions +): MiddlewareLikeObj => { + const idempotencyConfig = options.config + ? options.config + : new IdempotencyConfig({}); + const persistenceStore = options.persistenceStore; + persistenceStore.configure({ + config: idempotencyConfig, + }); + + /** + * Function called before the handler is executed. + * + * Before the handler is executed, we need to check if there is already an + * execution in progress for the given idempotency key. If there is, we + * need to determine its status and return the appropriate response or + * throw an error. + * + * If there is no execution in progress, we need to save a record to the + * idempotency store to indicate that an execution is in progress. + * + * In some rare cases, when the persistent state changes in small time + * window, we might get an `IdempotencyInconsistentStateError`. In such + * cases we can safely retry the handling a few times. + * + * @param request - The Middy request object + * @param retryNo - The number of times the handler has been retried + */ + const before = async ( + request: MiddyLikeRequest, + retryNo = 0 + ): Promise => { + try { + await persistenceStore.saveInProgress( + request.event as Record, + request.context.getRemainingTimeInMillis() + ); + } catch (error) { + if (error instanceof IdempotencyItemAlreadyExistsError) { + const idempotencyRecord: IdempotencyRecord = + await persistenceStore.getRecord( + request.event as Record + ); + + try { + const response = + await IdempotencyHandler.determineResultFromIdempotencyRecord( + idempotencyRecord + ); + if (response) { + // Cleanup other middlewares + cleanupMiddlewares(request); + + return response; + } + } catch (error) { + if ( + error instanceof IdempotencyInconsistentStateError && + retryNo < MAX_RETRIES + ) { + // Retry + return await before(request, retryNo + 1); + } else { + // Retries exhausted or other error + throw error; + } + } + } else { + throw new IdempotencyPersistenceLayerError( + 'Failed to save in progress record to idempotency store' + ); + } + } + }; + + /** + * Function called after the handler has executed successfully. + * + * When the handler returns successfully, we need to update the record in the + * idempotency store to indicate that the execution has completed and + * store its result. + * + * @param request - The Middy request object + */ + const after = async (request: MiddyLikeRequest): Promise => { + try { + await persistenceStore.saveSuccess( + request.event as Record, + request.response as Record + ); + } catch (e) { + throw new IdempotencyPersistenceLayerError( + 'Failed to update success record to idempotency store' + ); + } + }; + + /** + * Function called when an error occurs in the handler. + * + * When an error is thrown in the handler, we need to delete the record from the + * idempotency store. + * + * @param request - The Middy request object + */ + const onError = async (request: MiddyLikeRequest): Promise => { + try { + await persistenceStore.deleteRecord( + request.event as Record + ); + } catch (error) { + throw new IdempotencyPersistenceLayerError( + 'Failed to delete record from idempotency store' + ); + } + }; + + if (idempotencyConfig.isEnabled()) { + return { + before, + after, + onError, + }; + } else { + return { + before: () => { + return undefined; + }, + }; + } +}; + +export { makeHandlerIdempotent }; diff --git a/packages/idempotency/src/types/AnyFunction.ts b/packages/idempotency/src/types/AnyFunction.ts index 744efdf20c..097deab39a 100644 --- a/packages/idempotency/src/types/AnyFunction.ts +++ b/packages/idempotency/src/types/AnyFunction.ts @@ -2,8 +2,14 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericTempRecord = Record; -type AnyFunctionWithRecord = (record: GenericTempRecord) => Promise | U; +type AnyFunctionWithRecord = ( + payload: GenericTempRecord, + ...args: unknown[] +) => Promise | U; -type AnyIdempotentFunction = (record: GenericTempRecord) => Promise; +type AnyIdempotentFunction = ( + payload: GenericTempRecord, + ...args: unknown[] +) => Promise; export { GenericTempRecord, AnyFunctionWithRecord, AnyIdempotentFunction }; diff --git a/packages/idempotency/tests/helpers/idempotencyUtils.ts b/packages/idempotency/tests/helpers/idempotencyUtils.ts index fbf0e3f4b1..46e328f76a 100644 --- a/packages/idempotency/tests/helpers/idempotencyUtils.ts +++ b/packages/idempotency/tests/helpers/idempotencyUtils.ts @@ -3,6 +3,7 @@ import { v4 } from 'uuid'; import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { TEST_RUNTIMES } from '../../../commons/tests/utils/e2eUtils'; +import { BasePersistenceLayer } from '../../src/persistence'; import path from 'path'; export const createIdempotencyResources = ( @@ -40,3 +41,17 @@ export const createIdempotencyResources = ( ddbTable.grantReadWriteData(nodeJsFunction); }; + +/** + * Dummy class to test the abstract class BasePersistenceLayer. + * + * This class is used in the unit tests. + */ +class PersistenceLayerTestClass extends BasePersistenceLayer { + protected _deleteRecord = jest.fn(); + protected _getRecord = jest.fn(); + protected _putRecord = jest.fn(); + protected _updateRecord = jest.fn(); +} + +export { PersistenceLayerTestClass }; diff --git a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts index f47bc9c3ec..dd1a60d9d1 100644 --- a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts +++ b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts @@ -12,7 +12,8 @@ import { import { IdempotencyRecordStatus } from '../../src/types'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { IdempotencyHandler } from '../../src/IdempotencyHandler'; -import { IdempotencyConfig } from '../../src'; +import { IdempotencyConfig } from '../../src/IdempotencyConfig'; +import { MAX_RETRIES } from '../../src/constants'; class PersistenceLayerTestClass extends BasePersistenceLayer { protected _deleteRecord = jest.fn(); @@ -56,7 +57,7 @@ describe('Class IdempotencyHandler', () => { expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS); try { - await idempotentHandler.determineResultFromIdempotencyRecord( + await IdempotencyHandler.determineResultFromIdempotencyRecord( stubRecord ); } catch (e) { @@ -78,7 +79,7 @@ describe('Class IdempotencyHandler', () => { expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS); try { - await idempotentHandler.determineResultFromIdempotencyRecord( + await IdempotencyHandler.determineResultFromIdempotencyRecord( stubRecord ); } catch (e) { @@ -100,7 +101,7 @@ describe('Class IdempotencyHandler', () => { expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.EXPIRED); try { - await idempotentHandler.determineResultFromIdempotencyRecord( + await IdempotencyHandler.determineResultFromIdempotencyRecord( stubRecord ); } catch (e) { @@ -112,7 +113,7 @@ describe('Class IdempotencyHandler', () => { describe('Method: handle', () => { afterAll(() => jest.restoreAllMocks()); // restore processIdempotency for other tests - test('when IdempotencyAlreadyInProgressError is thrown, it retries two times', async () => { + test('when IdempotencyAlreadyInProgressError is thrown, it retries once', async () => { const mockProcessIdempotency = jest .spyOn(IdempotencyHandler.prototype, 'processIdempotency') .mockRejectedValue( @@ -123,7 +124,17 @@ describe('Class IdempotencyHandler', () => { await expect(idempotentHandler.handle()).rejects.toThrow( IdempotencyAlreadyInProgressError ); - expect(mockProcessIdempotency).toHaveBeenCalledTimes(2); + expect(mockProcessIdempotency).toHaveBeenCalledTimes(1); + }); + + test('when IdempotencyInconsistentStateError is thrown, it retries until max retries are exhausted', async () => { + const mockProcessIdempotency = jest + .spyOn(IdempotencyHandler.prototype, 'processIdempotency') + .mockRejectedValue(new IdempotencyInconsistentStateError()); + await expect(idempotentHandler.handle()).rejects.toThrow( + IdempotencyInconsistentStateError + ); + expect(mockProcessIdempotency).toHaveBeenCalledTimes(MAX_RETRIES + 1); }); test('when non IdempotencyAlreadyInProgressError is thrown, it rejects', async () => { @@ -157,11 +168,8 @@ describe('Class IdempotencyHandler', () => { .spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress') .mockRejectedValue(new Error('Some error')); const mockDetermineResultFromIdempotencyRecord = jest - .spyOn( - IdempotencyHandler.prototype, - 'determineResultFromIdempotencyRecord' - ) - .mockResolvedValue('result'); + .spyOn(IdempotencyHandler, 'determineResultFromIdempotencyRecord') + .mockImplementation(() => 'result'); await expect(idempotentHandler.processIdempotency()).rejects.toThrow( IdempotencyPersistenceLayerError @@ -191,11 +199,8 @@ describe('Class IdempotencyHandler', () => { .spyOn(mockIdempotencyOptions.persistenceStore, 'getRecord') .mockImplementation(() => Promise.resolve(stubRecord)); const mockDetermineResultFromIdempotencyRecord = jest - .spyOn( - IdempotencyHandler.prototype, - 'determineResultFromIdempotencyRecord' - ) - .mockResolvedValue('result'); + .spyOn(IdempotencyHandler, 'determineResultFromIdempotencyRecord') + .mockImplementation(() => 'result'); await expect(idempotentHandler.processIdempotency()).resolves.toBe( 'result' diff --git a/packages/idempotency/tests/unit/makeHandlerIdempotent.test.ts b/packages/idempotency/tests/unit/makeHandlerIdempotent.test.ts new file mode 100644 index 0000000000..387a9e0fd9 --- /dev/null +++ b/packages/idempotency/tests/unit/makeHandlerIdempotent.test.ts @@ -0,0 +1,271 @@ +/** + * Test Idempotency middleware + * + * @group unit/idempotency/makeHandlerIdempotent + */ +import { makeHandlerIdempotent } from '../../src/middleware'; +import { helloworldContext as dummyContext } from '../../../commons/src/samples/resources/contexts'; +import { Custom as dummyEvent } from '../../../commons/src/samples/resources/events'; +import { IdempotencyRecordStatus } from '../../src/types'; +import { IdempotencyRecord } from '../../src/persistence'; +import { + IdempotencyPersistenceLayerError, + IdempotencyItemAlreadyExistsError, + IdempotencyInconsistentStateError, +} from '../../src/Exceptions'; +import { IdempotencyConfig } from '../../src/IdempotencyConfig'; +import middy from '@middy/core'; +import { MAX_RETRIES } from '../../src/constants'; +import { PersistenceLayerTestClass } from '../helpers/idempotencyUtils'; +import type { Context } from 'aws-lambda'; + +const mockIdempotencyOptions = { + persistenceStore: new PersistenceLayerTestClass(), +}; +const remainingTImeInMillis = 10000; + +describe('Middleware: makeHandlerIdempotent', () => { + const ENVIRONMENT_VARIABLES = process.env; + const context = dummyContext; + context.getRemainingTimeInMillis = jest + .fn() + .mockReturnValue(remainingTImeInMillis); + const event = dummyEvent.CustomEvent; + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + process.env = { ...ENVIRONMENT_VARIABLES }; + jest.spyOn(console, 'debug').mockImplementation(() => null); + jest.spyOn(console, 'warn').mockImplementation(() => null); + jest.spyOn(console, 'error').mockImplementation(() => null); + }); + + afterAll(() => { + process.env = ENVIRONMENT_VARIABLES; + }); + + it('handles a successful execution', async () => { + // Prepare + const handler = middy( + async (_event: unknown, _context: Context): Promise => true + ).use( + makeHandlerIdempotent({ + ...mockIdempotencyOptions, + config: new IdempotencyConfig({}), + }) + ); + const saveInProgressSpy = jest.spyOn( + mockIdempotencyOptions.persistenceStore, + 'saveInProgress' + ); + const saveSuccessSpy = jest.spyOn( + mockIdempotencyOptions.persistenceStore, + 'saveSuccess' + ); + + // Act + const result = await handler(event, context); + + // Assess + expect(result).toBe(true); + expect(saveInProgressSpy).toHaveBeenCalledTimes(1); + expect(saveInProgressSpy).toHaveBeenCalledWith( + event, + remainingTImeInMillis + ); + expect(saveSuccessSpy).toHaveBeenCalledTimes(1); + expect(saveSuccessSpy).toHaveBeenCalledWith(event, true); + }); + it('handles an execution that throws an error', async () => { + // Prepare + const handler = middy( + async (_event: unknown, _context: Context): Promise => { + throw new Error('Something went wrong'); + } + ).use(makeHandlerIdempotent(mockIdempotencyOptions)); + const saveInProgressSpy = jest.spyOn( + mockIdempotencyOptions.persistenceStore, + 'saveInProgress' + ); + const deleteRecordSpy = jest.spyOn( + mockIdempotencyOptions.persistenceStore, + 'deleteRecord' + ); + + // Act && Assess + await expect(handler(event, context)).rejects.toThrow(); + expect(saveInProgressSpy).toHaveBeenCalledTimes(1); + expect(saveInProgressSpy).toHaveBeenCalledWith( + event, + remainingTImeInMillis + ); + expect(deleteRecordSpy).toHaveBeenCalledTimes(1); + expect(deleteRecordSpy).toHaveBeenCalledWith(event); + }); + it('thows an error if the persistence layer throws an error when saving in progress', async () => { + // Prepare + const handler = middy( + async (_event: unknown, _context: Context): Promise => true + ).use(makeHandlerIdempotent(mockIdempotencyOptions)); + jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress') + .mockRejectedValue(new Error('Something went wrong')); + + // Act && Assess + await expect(handler(event, context)).rejects.toThrowError( + new IdempotencyPersistenceLayerError( + 'Failed to save in progress record to idempotency store' + ) + ); + }); + it('thows an error if the persistence layer throws an error when saving a successful operation', async () => { + // Prepare + const handler = middy( + async (_event: unknown, _context: Context): Promise => true + ).use(makeHandlerIdempotent(mockIdempotencyOptions)); + jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'saveSuccess') + .mockRejectedValue(new Error('Something went wrong')); + + // Act && Assess + await expect(handler(event, context)).rejects.toThrowError( + new IdempotencyPersistenceLayerError( + 'Failed to update success record to idempotency store' + ) + ); + }); + it('thows an error if the persistence layer throws an error when deleting a record', async () => { + // Prepare + const handler = middy( + async (_event: unknown, _context: Context): Promise => { + throw new Error('Something went wrong'); + } + ).use(makeHandlerIdempotent(mockIdempotencyOptions)); + jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'deleteRecord') + .mockRejectedValue(new Error('Something went wrong')); + + // Act && Assess + await expect(handler(event, context)).rejects.toThrow( + new IdempotencyPersistenceLayerError( + 'Failed to delete record from idempotency store' + ) + ); + }); + it('returns the stored response if the operation has already been executed', async () => { + // Prepare + const handler = middy( + async (_event: unknown, _context: Context): Promise => true + ).use(makeHandlerIdempotent(mockIdempotencyOptions)); + jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress') + .mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const stubRecord = new IdempotencyRecord({ + idempotencyKey: 'idempotencyKey', + expiryTimestamp: Date.now() + 10000, + inProgressExpiryTimestamp: 0, + responseData: { response: false }, + payloadHash: 'payloadHash', + status: IdempotencyRecordStatus.COMPLETED, + }); + const getRecordSpy = jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'getRecord') + .mockResolvedValue(stubRecord); + + // Act + const result = await handler(event, context); + + // Assess + expect(result).toStrictEqual({ response: false }); + expect(getRecordSpy).toHaveBeenCalledTimes(1); + expect(getRecordSpy).toHaveBeenCalledWith(event); + }); + it('retries if the record is in an inconsistent state', async () => { + // Prepare + const handler = middy( + async (_event: unknown, _context: Context): Promise => true + ).use(makeHandlerIdempotent(mockIdempotencyOptions)); + jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress') + .mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const stubRecordInconsistent = new IdempotencyRecord({ + idempotencyKey: 'idempotencyKey', + expiryTimestamp: Date.now() + 10000, + inProgressExpiryTimestamp: 0, + responseData: { response: false }, + payloadHash: 'payloadHash', + status: IdempotencyRecordStatus.EXPIRED, + }); + const stubRecord = new IdempotencyRecord({ + idempotencyKey: 'idempotencyKey', + expiryTimestamp: Date.now() + 10000, + inProgressExpiryTimestamp: 0, + responseData: { response: false }, + payloadHash: 'payloadHash', + status: IdempotencyRecordStatus.COMPLETED, + }); + const getRecordSpy = jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'getRecord') + .mockResolvedValueOnce(stubRecordInconsistent) + .mockResolvedValueOnce(stubRecord); + + // Act + const result = await handler(event, context); + + // Assess + expect(result).toStrictEqual({ response: false }); + expect(getRecordSpy).toHaveBeenCalledTimes(2); + }); + it('throws after all the retries have been exhausted if the record is in an inconsistent state', async () => { + // Prepare + const handler = middy( + async (_event: unknown, _context: Context): Promise => true + ).use(makeHandlerIdempotent(mockIdempotencyOptions)); + jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress') + .mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const stubRecordInconsistent = new IdempotencyRecord({ + idempotencyKey: 'idempotencyKey', + expiryTimestamp: Date.now() + 10000, + inProgressExpiryTimestamp: 0, + responseData: { response: false }, + payloadHash: 'payloadHash', + status: IdempotencyRecordStatus.EXPIRED, + }); + const getRecordSpy = jest + .spyOn(mockIdempotencyOptions.persistenceStore, 'getRecord') + .mockResolvedValue(stubRecordInconsistent); + + // Act & Assess + await expect(handler(event, context)).rejects.toThrowError( + new IdempotencyInconsistentStateError( + 'Item has expired during processing and may not longer be valid.' + ) + ); + expect(getRecordSpy).toHaveBeenCalledTimes(MAX_RETRIES + 1); + }); + it('does not do anything if idempotency is disabled', async () => { + // Prepare + process.env.POWERTOOLS_IDEMPOTENCY_DISABLED = 'true'; + const handler = middy( + async (_event: unknown, _context: Context): Promise => true + ).use(makeHandlerIdempotent(mockIdempotencyOptions)); + const saveInProgressSpy = jest.spyOn( + mockIdempotencyOptions.persistenceStore, + 'saveInProgress' + ); + const saveSuccessSpy = jest.spyOn( + mockIdempotencyOptions.persistenceStore, + 'saveSuccess' + ); + + // Act + const result = await handler(event, context); + + // Assess + expect(result).toBe(true); + expect(saveInProgressSpy).toHaveBeenCalledTimes(0); + expect(saveSuccessSpy).toHaveBeenCalledTimes(0); + }); +});