diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index 06387ed0c8..c8bd4c387f 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -1,4 +1,7 @@ -import type { JSONValue } from '@aws-lambda-powertools/commons'; +import type { + JSONValue, + MiddyLikeRequest, +} from '@aws-lambda-powertools/commons'; import type { AnyFunction, IdempotencyHandlerOptions } from './types'; import { IdempotencyRecordStatus } from './types'; import { @@ -34,7 +37,7 @@ export class IdempotencyHandler { * * This is the argument that is used for the idempotency. */ - readonly #functionPayloadToBeHashed: JSONValue; + #functionPayloadToBeHashed: JSONValue; /** * Reference to the function to be made idempotent. */ @@ -68,9 +71,17 @@ export class IdempotencyHandler { }); } + /** + * Takes an idempotency key and returns the idempotency record from the persistence layer. + * + * If the idempotency record is not COMPLETE, then it will throw an error based on the status of the record. + * + * @param idempotencyRecord The idempotency record stored in the persistence layer + * @returns The result of the function if the idempotency record is in a terminal state + */ public static determineResultFromIdempotencyRecord( idempotencyRecord: IdempotencyRecord - ): Promise | unknown { + ): JSONValue { if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.EXPIRED) { throw new IdempotencyInconsistentStateError( 'Item has expired during processing and may not longer be valid.' @@ -96,50 +107,55 @@ export class IdempotencyHandler { return idempotencyRecord.getResponse(); } + /** + * Execute the handler and return the result. + * + * If the handler fails, the idempotency record will be deleted. + * If it succeeds, the idempotency record will be updated with the result. + * + * @returns The result of the function execution + */ public async getFunctionResult(): Promise> { let result; try { result = await this.#functionToMakeIdempotent(...this.#functionArguments); - } catch (e) { - try { - await this.#persistenceStore.deleteRecord( - this.#functionPayloadToBeHashed - ); - } catch (e) { - throw new IdempotencyPersistenceLayerError( - 'Failed to delete record from idempotency store', - e as Error - ); - } - throw e; - } - try { - await this.#persistenceStore.saveSuccess( - this.#functionPayloadToBeHashed, - result - ); - } catch (e) { - throw new IdempotencyPersistenceLayerError( - 'Failed to update success record to idempotency store', - e as Error - ); + } catch (error) { + await this.#deleteInProgressRecord(); + throw error; } + await this.#saveSuccessfullResult(result); return result; } /** - * Main entry point for the handler + * Entry point to handle the idempotency logic. + * + * 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. */ public async handle(): Promise> { + // early return if we should skip idempotency completely + if (this.shouldSkipIdempotency()) { + return await this.#functionToMakeIdempotent(...this.#functionArguments); + } + let e; for (let retryNo = 0; retryNo <= MAX_RETRIES; retryNo++) { try { - return await this.processIdempotency(); + const result = await this.#saveInProgressOrReturnExistingResult(); + if (result) return result as ReturnType; + + return await this.getFunctionResult(); } catch (error) { if ( error instanceof IdempotencyInconsistentStateError && @@ -156,60 +172,183 @@ export class IdempotencyHandler { throw e; } - public async processIdempotency(): Promise> { - // early return if we should skip idempotency completely + /** + * Handle the idempotency operations needed after the handler has returned. + * + * 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. + * + * To avoid duplication of code, we expose this method so that it can be + * called from the `after` phase of the Middy middleware. + * + * @param response The response returned by the handler. + */ + public async handleMiddyAfter(response: unknown): Promise { + await this.#saveSuccessfullResult(response as ReturnType); + } + + /** + * Handle the idempotency operations needed after the handler has returned. + * + * 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 request object passed to the handler. + * @param callback Callback function to cleanup pending middlewares when returning early. + */ + public async handleMiddyBefore( + request: MiddyLikeRequest, + callback: (request: MiddyLikeRequest) => Promise + ): Promise | void> { + for (let retryNo = 0; retryNo <= MAX_RETRIES; retryNo++) { + try { + const result = await this.#saveInProgressOrReturnExistingResult(); + if (result) { + await callback(request); + + return result as ReturnType; + } + break; + } catch (error) { + if ( + error instanceof IdempotencyInconsistentStateError && + retryNo < MAX_RETRIES + ) { + // Retry + continue; + } + // Retries exhausted or other error + throw error; + } + } + } + + /** + * Handle the idempotency operations needed when an error is thrown in the handler. + * + * When an error is thrown in the handler, we need to delete the record from the + * idempotency store. + * + * To avoid duplication of code, we expose this method so that it can be + * called from the `onError` phase of the Middy middleware. + */ + public async handleMiddyOnError(): Promise { + await this.#deleteInProgressRecord(); + } + + /** + * Setter for the payload to be hashed to generate the idempotency key. + * + * This is useful if you want to use a different payload than the one + * used to instantiate the `IdempotencyHandler`, for example when using + * it within a Middy middleware. + * + * @param functionPayloadToBeHashed The payload to be hashed to generate the idempotency key + */ + public setFunctionPayloadToBeHashed( + functionPayloadToBeHashed: JSONValue + ): void { + this.#functionPayloadToBeHashed = functionPayloadToBeHashed; + } + + /** + * Avoid idempotency if the eventKeyJmesPath is not present in the payload and throwOnNoIdempotencyKey is false + */ + public shouldSkipIdempotency(): boolean { + if (!this.#idempotencyConfig.isEnabled()) return true; + if ( - IdempotencyHandler.shouldSkipIdempotency( - this.#idempotencyConfig.eventKeyJmesPath, - this.#idempotencyConfig.throwOnNoIdempotencyKey, - this.#functionPayloadToBeHashed - ) + this.#idempotencyConfig.eventKeyJmesPath !== '' && + !this.#idempotencyConfig.throwOnNoIdempotencyKey ) { - return await this.#functionToMakeIdempotent(...this.#functionArguments); + const selection = search( + this.#functionPayloadToBeHashed, + this.#idempotencyConfig.eventKeyJmesPath + ); + + return selection === undefined || selection === null; + } else { + return false; } + } + /** + * Delete an in progress record from the idempotency store. + * + * This is called when the handler throws an error. + */ + #deleteInProgressRecord = async (): Promise => { try { - await this.#persistenceStore.saveInProgress( - this.#functionPayloadToBeHashed, - this.#idempotencyConfig.lambdaContext?.getRemainingTimeInMillis() + await this.#persistenceStore.deleteRecord( + this.#functionPayloadToBeHashed ); } catch (e) { - if (e instanceof IdempotencyItemAlreadyExistsError) { - const idempotencyRecord: IdempotencyRecord = - await this.#persistenceStore.getRecord( - this.#functionPayloadToBeHashed - ); + throw new IdempotencyPersistenceLayerError( + 'Failed to delete record from idempotency store', + e as Error + ); + } + }; - return IdempotencyHandler.determineResultFromIdempotencyRecord( - idempotencyRecord - ) as ReturnType; - } else { - throw new IdempotencyPersistenceLayerError( - 'Failed to save in progress record to idempotency store', - e as Error + /** + * Save an in progress record to the idempotency store or return an existing result. + * + * If the record already exists, return the result from the record. + */ + #saveInProgressOrReturnExistingResult = + async (): Promise => { + try { + await this.#persistenceStore.saveInProgress( + this.#functionPayloadToBeHashed, + this.#idempotencyConfig.lambdaContext?.getRemainingTimeInMillis() ); - } - } + } catch (e) { + if (e instanceof IdempotencyItemAlreadyExistsError) { + const idempotencyRecord: IdempotencyRecord = + await this.#persistenceStore.getRecord( + this.#functionPayloadToBeHashed + ); - return this.getFunctionResult(); - } + return IdempotencyHandler.determineResultFromIdempotencyRecord( + idempotencyRecord + ); + } else { + throw new IdempotencyPersistenceLayerError( + 'Failed to save in progress record to idempotency store', + e as Error + ); + } + } + }; /** - * avoid idempotency if the eventKeyJmesPath is not present in the payload and throwOnNoIdempotencyKey is false - * static so {@link makeHandlerIdempotent} middleware can use it - * TOOD: refactor so middy uses IdempotencyHandler internally wihtout reimplementing the logic - * @param eventKeyJmesPath - * @param throwOnNoIdempotencyKey - * @param fullFunctionPayload - * @private - */ - public static shouldSkipIdempotency( - eventKeyJmesPath: string, - throwOnNoIdempotencyKey: boolean, - fullFunctionPayload: JSONValue - ): boolean { - return (eventKeyJmesPath && - !throwOnNoIdempotencyKey && - !search(fullFunctionPayload, eventKeyJmesPath)) as boolean; - } + * Save a successful result to the idempotency store. + * + * This is called when the handler returns successfully. + * + * @param result The result returned by the handler. + */ + #saveSuccessfullResult = async (result: ReturnType): Promise => { + try { + await this.#persistenceStore.saveSuccess( + this.#functionPayloadToBeHashed, + result + ); + } catch (e) { + throw new IdempotencyPersistenceLayerError( + 'Failed to update success record to idempotency store', + e as Error + ); + } + }; } diff --git a/packages/idempotency/src/middleware/makeHandlerIdempotent.ts b/packages/idempotency/src/middleware/makeHandlerIdempotent.ts index c9e750e53c..25c4525f58 100644 --- a/packages/idempotency/src/middleware/makeHandlerIdempotent.ts +++ b/packages/idempotency/src/middleware/makeHandlerIdempotent.ts @@ -4,16 +4,8 @@ import { cleanupMiddlewares, IDEMPOTENCY_KEY, } from '@aws-lambda-powertools/commons/lib/middleware'; -import { - IdempotencyInconsistentStateError, - IdempotencyItemAlreadyExistsError, - IdempotencyPersistenceLayerError, -} from '../errors'; -import { IdempotencyRecord } from '../persistence'; -import { MAX_RETRIES } from '../constants'; -import type { IdempotencyLambdaHandlerOptions } from '../types'; -import type { BasePersistenceLayerInterface } from '../persistence'; -import { +import type { AnyFunction, IdempotencyLambdaHandlerOptions } from '../types'; +import type { MiddlewareLikeObj, MiddyLikeRequest, JSONValue, @@ -21,34 +13,34 @@ import { /** * @internal - * Utility function to get the persistence store from the request internal storage + * Utility function to get the idempotency handler from the request internal storage * * @param request The Middy request object - * @returns The persistence store from the request internal + * @returns The idempotency handler from the request internal */ -const getPersistenceStoreFromRequestInternal = ( +const getIdempotencyHandlerFromRequestInternal = ( request: MiddyLikeRequest -): BasePersistenceLayerInterface => { - const persistenceStore = request.internal[ - `${IDEMPOTENCY_KEY}.idempotencyPersistenceStore` - ] as BasePersistenceLayerInterface; +): IdempotencyHandler => { + const idempotencyHandler = request.internal[ + `${IDEMPOTENCY_KEY}.idempotencyHandler` + ] as IdempotencyHandler; - return persistenceStore; + return idempotencyHandler; }; /** * @internal - * Utility function to set the persistence store in the request internal storage + * Utility function to set the idempotency handler in the request internal storage * * @param request The Middy request object - * @param persistenceStore The persistence store to set in the request internal + * @param idempotencyHandler The idempotency handler to set in the request internal */ -const setPersistenceStoreInRequestInternal = ( +const setIdempotencyHandlerInRequestInternal = ( request: MiddyLikeRequest, - persistenceStore: BasePersistenceLayerInterface + idempotencyHandler: IdempotencyHandler ): void => { - request.internal[`${IDEMPOTENCY_KEY}.idempotencyPersistenceStore`] = - persistenceStore; + request.internal[`${IDEMPOTENCY_KEY}.idempotencyHandler`] = + idempotencyHandler; }; /** @@ -103,25 +95,16 @@ const makeHandlerIdempotent = ( /** * 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. + * Before the handler is executed, we insantiate the {@link IdempotencyHandler} and + * set it in the request internal storage. We then configure the persistence store + * and set the payload to be hashed and Lambda context in the idempotency config. * - * 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. + * If idempotency is enabled and the idempotency key is present in the payload, + * we then run the idempotency operations. These are handled in {@link IdempotencyHandler.handleMiddyBefore}. * * @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 => { + const before = async (request: MiddyLikeRequest): Promise => { const idempotencyConfig = options.config ? options.config : new IdempotencyConfig({}); @@ -130,66 +113,28 @@ const makeHandlerIdempotent = ( config: idempotencyConfig, }); - if ( - !idempotencyConfig.isEnabled() || - IdempotencyHandler.shouldSkipIdempotency( - idempotencyConfig.eventKeyJmesPath, - idempotencyConfig.throwOnNoIdempotencyKey, - request.event as JSONValue - ) - ) { + const idempotencyHandler = new IdempotencyHandler({ + functionToMakeIdempotent: /* istanbul ignore next */ () => ({}), + functionArguments: [], + idempotencyConfig, + persistenceStore, + functionPayloadToBeHashed: undefined, + }); + setIdempotencyHandlerInRequestInternal(request, idempotencyHandler); + + // set the payload to be hashed + idempotencyHandler.setFunctionPayloadToBeHashed(request.event as JSONValue); + // check if we should skip idempotency checks + if (idempotencyHandler.shouldSkipIdempotency()) { // set the flag to skip checks in after and onError setIdempotencySkipFlag(request); return; } - /** - * Store the persistence store in the request internal so that it can be - * used in after and onError - */ - setPersistenceStoreInRequestInternal(request, persistenceStore); + idempotencyConfig.registerLambdaContext(request.context); - try { - await persistenceStore.saveInProgress( - request.event as JSONValue, - request.context.getRemainingTimeInMillis() - ); - } catch (error) { - if (error instanceof IdempotencyItemAlreadyExistsError) { - const idempotencyRecord: IdempotencyRecord = - await persistenceStore.getRecord(request.event as JSONValue); - - 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', - error as Error - ); - } - } + return idempotencyHandler.handleMiddyBefore(request, cleanupMiddlewares); }; /** @@ -197,7 +142,7 @@ const makeHandlerIdempotent = ( * * 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. + * store its result. This is handled in {@link IdempotencyHandler.handleMiddyAfter}. * * @param request - The Middy request object */ @@ -205,25 +150,16 @@ const makeHandlerIdempotent = ( if (shouldSkipIdempotency(request)) { return; } - const persistenceStore = getPersistenceStoreFromRequestInternal(request); - try { - await persistenceStore.saveSuccess( - request.event as JSONValue, - request.response as JSONValue - ); - } catch (e) { - throw new IdempotencyPersistenceLayerError( - 'Failed to update success record to idempotency store', - e as Error - ); - } + const idempotencyHandler = + getIdempotencyHandlerFromRequestInternal(request); + await idempotencyHandler.handleMiddyAfter(request.response); }; /** * 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. + * idempotency store. This is handled in {@link IdempotencyHandler.handleMiddyOnError}. * * @param request - The Middy request object */ @@ -231,15 +167,9 @@ const makeHandlerIdempotent = ( if (shouldSkipIdempotency(request)) { return; } - const persistenceStore = getPersistenceStoreFromRequestInternal(request); - try { - await persistenceStore.deleteRecord(request.event as JSONValue); - } catch (error) { - throw new IdempotencyPersistenceLayerError( - 'Failed to delete record from idempotency store', - error as Error - ); - } + const idempotencyHandler = + getIdempotencyHandlerFromRequestInternal(request); + await idempotencyHandler.handleMiddyOnError(); }; return { diff --git a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts index 977623e14b..d9196169c4 100644 --- a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts +++ b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts @@ -15,12 +15,12 @@ import { IdempotencyHandler } from '../../src/IdempotencyHandler'; import { IdempotencyConfig } from '../../src/'; import { MAX_RETRIES } from '../../src/constants'; import { PersistenceLayerTestClass } from '../helpers/idempotencyUtils'; -import { Context } from 'aws-lambda'; const mockFunctionToMakeIdempotent = jest.fn(); const mockFunctionPayloadToBeHashed = {}; +const persistenceStore = new PersistenceLayerTestClass(); const mockIdempotencyOptions = { - persistenceStore: new PersistenceLayerTestClass(), + persistenceStore, dataKeywordArgument: 'testKeywordArgument', config: new IdempotencyConfig({}), }; @@ -51,6 +51,7 @@ describe('Class IdempotencyHandler', () => { describe('Method: determineResultFromIdempotencyRecord', () => { test('when record is in progress and within expiry window, it rejects with IdempotencyAlreadyInProgressError', async () => { + // Prepare const stubRecord = new IdempotencyRecord({ idempotencyKey: 'idempotencyKey', expiryTimestamp: Date.now() + 1000, // should be in the future @@ -60,19 +61,16 @@ describe('Class IdempotencyHandler', () => { status: IdempotencyRecordStatus.INPROGRESS, }); + // Act & Assess expect(stubRecord.isExpired()).toBe(false); expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS); - - try { - await IdempotencyHandler.determineResultFromIdempotencyRecord( - stubRecord - ); - } catch (e) { - expect(e).toBeInstanceOf(IdempotencyAlreadyInProgressError); - } + expect(() => + IdempotencyHandler.determineResultFromIdempotencyRecord(stubRecord) + ).toThrow(IdempotencyAlreadyInProgressError); }); test('when record is in progress and outside expiry window, it rejects with IdempotencyInconsistentStateError', async () => { + // Prepare const stubRecord = new IdempotencyRecord({ idempotencyKey: 'idempotencyKey', expiryTimestamp: Date.now() + 1000, // should be in the future @@ -82,19 +80,16 @@ describe('Class IdempotencyHandler', () => { status: IdempotencyRecordStatus.INPROGRESS, }); + // Act & Assess expect(stubRecord.isExpired()).toBe(false); expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS); - - try { - await IdempotencyHandler.determineResultFromIdempotencyRecord( - stubRecord - ); - } catch (e) { - expect(e).toBeInstanceOf(IdempotencyInconsistentStateError); - } + expect(() => + IdempotencyHandler.determineResultFromIdempotencyRecord(stubRecord) + ).toThrow(IdempotencyInconsistentStateError); }); test('when record is expired, it rejects with IdempotencyInconsistentStateError', async () => { + // Prepare const stubRecord = new IdempotencyRecord({ idempotencyKey: 'idempotencyKey', expiryTimestamp: new Date().getUTCMilliseconds() - 1000, // should be in the past @@ -104,204 +99,56 @@ describe('Class IdempotencyHandler', () => { status: IdempotencyRecordStatus.EXPIRED, }); + // Act & Assess expect(stubRecord.isExpired()).toBe(true); expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.EXPIRED); - - try { - await IdempotencyHandler.determineResultFromIdempotencyRecord( - stubRecord - ); - } catch (e) { - expect(e).toBeInstanceOf(IdempotencyInconsistentStateError); - } + expect(() => + IdempotencyHandler.determineResultFromIdempotencyRecord(stubRecord) + ).toThrow(IdempotencyInconsistentStateError); }); }); describe('Method: handle', () => { - afterAll(() => jest.restoreAllMocks()); // restore processIdempotency for other tests - test('when IdempotencyAlreadyInProgressError is thrown, it retries once', async () => { - const mockProcessIdempotency = jest - .spyOn(IdempotencyHandler.prototype, 'processIdempotency') - .mockRejectedValue( - new IdempotencyAlreadyInProgressError( - 'There is already an execution in progress' - ) - ); - await expect(idempotentHandler.handle()).rejects.toThrow( - IdempotencyAlreadyInProgressError - ); - expect(mockProcessIdempotency).toHaveBeenCalledTimes(1); + // Prepare + const saveInProgressSpy = jest + .spyOn(persistenceStore, 'saveInProgress') + .mockRejectedValueOnce(new IdempotencyItemAlreadyExistsError()); + + // Act & Assess + await expect(idempotentHandler.handle()).rejects.toThrow(); + expect(saveInProgressSpy).toHaveBeenCalledTimes(1); }); test('when IdempotencyInconsistentStateError is thrown, it retries until max retries are exhausted', async () => { + // Prepare const mockProcessIdempotency = jest - .spyOn(IdempotencyHandler.prototype, 'processIdempotency') - .mockRejectedValue(new IdempotencyInconsistentStateError()); + .spyOn(persistenceStore, 'saveInProgress') + .mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + jest.spyOn(persistenceStore, 'getRecord').mockResolvedValue( + new IdempotencyRecord({ + status: IdempotencyRecordStatus.EXPIRED, + idempotencyKey: 'idempotencyKey', + }) + ); + + // Act & Assess await expect(idempotentHandler.handle()).rejects.toThrow( IdempotencyInconsistentStateError ); expect(mockProcessIdempotency).toHaveBeenCalledTimes(MAX_RETRIES + 1); }); - - test('when non IdempotencyAlreadyInProgressError is thrown, it rejects', async () => { - const mockProcessIdempotency = jest - .spyOn(IdempotencyHandler.prototype, 'processIdempotency') - .mockRejectedValue(new Error('Some other error')); - - await expect(idempotentHandler.handle()).rejects.toThrow(Error); - expect(mockProcessIdempotency).toHaveBeenCalledTimes(1); - }); - }); - - describe('Method: processIdempotency', () => { - test('when persistenceStore saves successfuly, it resolves', async () => { - const mockSaveInProgress = jest - .spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress') - .mockResolvedValue(); - - mockFunctionToMakeIdempotent.mockImplementation(() => - Promise.resolve('result') - ); - - await expect(idempotentHandler.processIdempotency()).resolves.toBe( - 'result' - ); - expect(mockSaveInProgress).toHaveBeenCalledTimes(1); - }); - - test('when persistences store throws any error, it wraps the error to IdempotencyPersistencesLayerError', async () => { - const innerError = new Error('Some error'); - const mockSaveInProgress = jest - .spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress') - .mockRejectedValue(innerError); - const mockDetermineResultFromIdempotencyRecord = jest - .spyOn(IdempotencyHandler, 'determineResultFromIdempotencyRecord') - .mockImplementation(() => 'result'); - await expect(idempotentHandler.processIdempotency()).rejects.toThrow( - new IdempotencyPersistenceLayerError( - 'Failed to save in progress record to idempotency store', - innerError - ) - ); - - expect(mockSaveInProgress).toHaveBeenCalledTimes(1); - expect(mockDetermineResultFromIdempotencyRecord).toHaveBeenCalledTimes(0); - }); - - test('when idempotency item already exists, it returns the existing record', async () => { - const mockSaveInProgress = jest - .spyOn(mockIdempotencyOptions.persistenceStore, 'saveInProgress') - .mockRejectedValue( - new IdempotencyItemAlreadyExistsError( - 'There is already an execution in progress' - ) - ); - - const stubRecord = new IdempotencyRecord({ - idempotencyKey: 'idempotencyKey', - expiryTimestamp: 0, - inProgressExpiryTimestamp: 0, - responseData: { responseData: 'responseData' }, - payloadHash: 'payloadHash', - status: IdempotencyRecordStatus.INPROGRESS, - }); - const mockGetRecord = jest - .spyOn(mockIdempotencyOptions.persistenceStore, 'getRecord') - .mockImplementation(() => Promise.resolve(stubRecord)); - const mockDetermineResultFromIdempotencyRecord = jest - .spyOn(IdempotencyHandler, 'determineResultFromIdempotencyRecord') - .mockImplementation(() => 'result'); - - await expect(idempotentHandler.processIdempotency()).resolves.toBe( - 'result' - ); - expect(mockSaveInProgress).toHaveBeenCalledTimes(1); - expect(mockGetRecord).toHaveBeenCalledTimes(1); - expect(mockDetermineResultFromIdempotencyRecord).toHaveBeenCalledTimes(1); - }); - - test('when throwOnNoIdempotencyKey is false and the key is missing, we skip idempotency', async () => { - const idempotentHandlerSkips = new IdempotencyHandler({ - functionToMakeIdempotent: mockFunctionToMakeIdempotent, - functionPayloadToBeHashed: mockFunctionPayloadToBeHashed, - persistenceStore: mockIdempotencyOptions.persistenceStore, - functionArguments: [], - idempotencyConfig: new IdempotencyConfig({ - throwOnNoIdempotencyKey: false, - eventKeyJmesPath: 'idempotencyKey', - }), - }); - - const mockSaveInProgress = jest.spyOn( - mockIdempotencyOptions.persistenceStore, - 'saveInProgress' - ); - - const mockSaveSuccessfulResult = jest.spyOn( - mockIdempotencyOptions.persistenceStore, - 'saveSuccess' - ); - const mockGetRecord = jest.spyOn( - mockIdempotencyOptions.persistenceStore, - 'getRecord' - ); - - mockFunctionToMakeIdempotent.mockImplementation(() => { - return 'result'; - }); - - await expect(idempotentHandlerSkips.processIdempotency()).resolves.toBe( - 'result' - ); - expect(mockSaveInProgress).toHaveBeenCalledTimes(0); - expect(mockGetRecord).toHaveBeenCalledTimes(0); - expect(mockSaveSuccessfulResult).toHaveBeenCalledTimes(0); - }); - - test('when lambdaContext is registered, we pass it to saveInProgress', async () => { - const mockSaveInProgress = jest.spyOn( - mockIdempotencyOptions.persistenceStore, - 'saveInProgress' - ); - - const mockLambaContext: Context = { - getRemainingTimeInMillis(): number { - return 1000; // we expect this number to be passed to saveInProgress - }, - } as Context; - const idempotencyHandlerWithContext = new IdempotencyHandler({ - functionToMakeIdempotent: mockFunctionToMakeIdempotent, - functionPayloadToBeHashed: mockFunctionPayloadToBeHashed, - persistenceStore: mockIdempotencyOptions.persistenceStore, - functionArguments: [], - idempotencyConfig: new IdempotencyConfig({ - lambdaContext: mockLambaContext, - }), - }); - - mockFunctionToMakeIdempotent.mockImplementation(() => - Promise.resolve('result') - ); - - await expect(idempotencyHandlerWithContext.processIdempotency()).resolves; - - expect(mockSaveInProgress).toBeCalledWith( - mockFunctionPayloadToBeHashed, - mockLambaContext.getRemainingTimeInMillis() - ); - }); }); describe('Method: getFunctionResult', () => { test('when function returns a result, it saves the successful result and returns it', async () => { + // Prepare + mockFunctionToMakeIdempotent.mockResolvedValue('result'); const mockSaveSuccessfulResult = jest .spyOn(mockIdempotencyOptions.persistenceStore, 'saveSuccess') .mockResolvedValue(); - mockFunctionToMakeIdempotent.mockImplementation(() => - Promise.resolve('result') - ); + // Act & Assess await expect(idempotentHandler.getFunctionResult()).resolves.toBe( 'result' ); @@ -309,14 +156,13 @@ describe('Class IdempotencyHandler', () => { }); test('when function throws an error, it deletes the in progress record and throws the error', async () => { - mockFunctionToMakeIdempotent.mockImplementation(() => - Promise.reject(new Error('Some error')) - ); - + // Prepare + mockFunctionToMakeIdempotent.mockRejectedValue(new Error('Some error')); const mockDeleteInProgress = jest .spyOn(mockIdempotencyOptions.persistenceStore, 'deleteRecord') .mockResolvedValue(); + // Act & Assess await expect(idempotentHandler.getFunctionResult()).rejects.toThrow( Error ); @@ -324,14 +170,13 @@ describe('Class IdempotencyHandler', () => { }); test('when deleteRecord throws an error, it wraps the error to IdempotencyPersistenceLayerError', async () => { - mockFunctionToMakeIdempotent.mockImplementation(() => - Promise.reject(new Error('Some error')) - ); - + // Prepare + mockFunctionToMakeIdempotent.mockRejectedValue(new Error('Some error')); const mockDeleteInProgress = jest .spyOn(mockIdempotencyOptions.persistenceStore, 'deleteRecord') .mockRejectedValue(new Error('Some error')); + // Act & Assess await expect(idempotentHandler.getFunctionResult()).rejects.toThrow( new IdempotencyPersistenceLayerError( 'Failed to delete record from idempotency store', @@ -342,14 +187,13 @@ describe('Class IdempotencyHandler', () => { }); test('when saveSuccessfulResult throws an error, it wraps the error to IdempotencyPersistenceLayerError', async () => { - mockFunctionToMakeIdempotent.mockImplementation(() => - Promise.resolve('result') - ); - + // Prepare + mockFunctionToMakeIdempotent.mockResolvedValue('result'); const mockSaveSuccessfulResult = jest .spyOn(mockIdempotencyOptions.persistenceStore, 'saveSuccess') .mockRejectedValue(new Error('Some error')); + // Act & Assess await expect(idempotentHandler.getFunctionResult()).rejects.toThrow( IdempotencyPersistenceLayerError );