UNPKG

@aws-lambda-powertools/idempotency

Version:

The idempotency package for the Powertools for AWS Lambda (TypeScript) library. It provides options to make your Lambda functions idempotent and safe to retry.

327 lines (326 loc) 14.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.IdempotencyHandler = void 0; const jmespath_1 = require("@aws-lambda-powertools/jmespath"); const constants_js_1 = require("./constants.js"); const errors_js_1 = require("./errors.js"); /** * @internal * * Class that handles the idempotency lifecycle. * * This class is used under the hood by the Idempotency utility * and provides several methods that are called at different stages * to orchestrate the idempotency logic. */ class IdempotencyHandler { /** * The arguments passed to the function. * * For example, if the function is `foo(a, b)`, then `functionArguments` will be `[a, b]`. * We need to keep track of the arguments so that we can pass them to the function when we call it. */ #functionArguments; /** * The payload to be hashed. * * This is the argument that is used for the idempotency. */ #functionPayloadToBeHashed; /** * Reference to the function to be made idempotent. */ #functionToMakeIdempotent; /** * Idempotency configuration options. */ #idempotencyConfig; /** * Custom prefix to be used when generating the idempotency key. */ #keyPrefix; /** * Persistence layer used to store the idempotency records. */ #persistenceStore; /** * The `this` context to be used when calling the function. * * When decorating a class method, this will be the instance of the class. */ #thisArg; constructor(options) { const { functionToMakeIdempotent, functionPayloadToBeHashed, idempotencyConfig, functionArguments, persistenceStore, keyPrefix, thisArg, } = options; this.#functionToMakeIdempotent = functionToMakeIdempotent; this.#functionPayloadToBeHashed = functionPayloadToBeHashed; this.#idempotencyConfig = idempotencyConfig; this.#keyPrefix = keyPrefix; this.#functionArguments = functionArguments; this.#thisArg = thisArg; this.#persistenceStore = persistenceStore; this.#persistenceStore.configure({ config: this.#idempotencyConfig, keyPrefix: this.#keyPrefix, }); } /** * Takes an idempotency key and returns the idempotency record from the persistence layer. * * If a response hook is provided in the idempotency configuration, it will be called before returning the response. * * 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 */ determineResultFromIdempotencyRecord(idempotencyRecord) { if (idempotencyRecord.getStatus() === constants_js_1.IdempotencyRecordStatus.EXPIRED) { throw new errors_js_1.IdempotencyInconsistentStateError('Item has expired during processing and may not longer be valid.'); } if (idempotencyRecord.getStatus() === constants_js_1.IdempotencyRecordStatus.INPROGRESS) { if (idempotencyRecord.inProgressExpiryTimestamp && idempotencyRecord.inProgressExpiryTimestamp < new Date().getUTCMilliseconds()) { throw new errors_js_1.IdempotencyInconsistentStateError('Item is in progress but the in progress expiry timestamp has expired.'); } throw new errors_js_1.IdempotencyAlreadyInProgressError(`There is already an execution in progress with idempotency key: ${idempotencyRecord.idempotencyKey}${idempotencyRecord.sortKey ? ` and sort key: ${idempotencyRecord.sortKey}` : ''}`); } const response = idempotencyRecord.getResponse(); // If a response hook is provided, call it to allow the user to modify the response if (this.#idempotencyConfig.responseHook) { return this.#idempotencyConfig.responseHook(response, idempotencyRecord); } return response; } /** * 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 */ async getFunctionResult() { let result; try { result = await this.#functionToMakeIdempotent.apply(this.#thisArg, this.#functionArguments); } catch (error) { await this.#deleteInProgressRecord(); throw error; } await this.#saveSuccessfulResult(result); return result; } /** * 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. */ async handle() { // early return if we should skip idempotency completely if (this.shouldSkipIdempotency()) { return await this.#functionToMakeIdempotent.apply(this.#thisArg, this.#functionArguments); } let e; for (let retryNo = 0; retryNo <= constants_js_1.MAX_RETRIES; retryNo++) { try { const { isIdempotent, result } = await this.#saveInProgressOrReturnExistingResult(); if (isIdempotent) return result; return await this.getFunctionResult(); } catch (error) { if (!(error instanceof Error)) throw new errors_js_1.IdempotencyUnknownError('An unknown error occurred while processing the request.', { cause: error }); if (error.name === 'IdempotencyInconsistentStateError' && retryNo < constants_js_1.MAX_RETRIES) { // Retry continue; } // Retries exhausted or other error e = error; break; } } throw e; } /** * 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. */ async handleMiddyAfter(response) { await this.#saveSuccessfulResult(response); } /** * 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. */ async handleMiddyBefore(request, callback) { for (let retryNo = 0; retryNo <= constants_js_1.MAX_RETRIES; retryNo++) { try { const { isIdempotent, result } = await this.#saveInProgressOrReturnExistingResult(); if (isIdempotent) { await callback(request); return result; } break; } catch (error) { if ( /** * It's safe to cast the error here because this catch block is only * reached when an error is thrown in code paths that we control, * and we only throw instances of `Error`. */ error.name === 'IdempotencyInconsistentStateError' && retryNo < constants_js_1.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. */ async handleMiddyOnError() { 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 */ setFunctionPayloadToBeHashed(functionPayloadToBeHashed) { this.#functionPayloadToBeHashed = functionPayloadToBeHashed; } /** * Avoid idempotency if the eventKeyJmesPath is not present in the payload and throwOnNoIdempotencyKey is false */ shouldSkipIdempotency() { if (!this.#idempotencyConfig.isEnabled()) return true; if (this.#idempotencyConfig.eventKeyJmesPath !== '' && !this.#idempotencyConfig.throwOnNoIdempotencyKey) { const selection = (0, jmespath_1.search)(this.#idempotencyConfig.eventKeyJmesPath, this.#functionPayloadToBeHashed, this.#idempotencyConfig.jmesPathOptions); return selection === undefined || selection === null; } return false; } /** * Delete an in progress record from the idempotency store. * * This is called when the handler throws an error. */ #deleteInProgressRecord = async () => { try { await this.#persistenceStore.deleteRecord(this.#functionPayloadToBeHashed); } catch (error) { throw new errors_js_1.IdempotencyPersistenceLayerError('Failed to delete record from idempotency store', { cause: error }); } }; /** * Save an in progress record to the idempotency store or return an stored result. * * Before returning a result, we might neede to look up the idempotency record * and validate it to ensure that it is consistent with the payload to be hashed. */ #saveInProgressOrReturnExistingResult = async () => { const returnValue = { isIdempotent: false, result: undefined, }; try { await this.#persistenceStore.saveInProgress(this.#functionPayloadToBeHashed, this.#idempotencyConfig.lambdaContext?.getRemainingTimeInMillis()); return returnValue; } catch (error) { if (!(error instanceof Error)) throw new errors_js_1.IdempotencyUnknownError('An unknown error occurred while processing the request.', { cause: error }); if (error.name === 'IdempotencyItemAlreadyExistsError') { let idempotencyRecord = error .existingRecord; if (idempotencyRecord !== undefined) { // If the error includes the existing record, we can use it to validate // the record being processed and cache it in memory. idempotencyRecord = this.#persistenceStore.processExistingRecord(idempotencyRecord, this.#functionPayloadToBeHashed); // If the error doesn't include the existing record, we need to fetch // it from the persistence layer. In doing so, we also call the processExistingRecord // method to validate the record and cache it in memory. } else { idempotencyRecord = await this.#persistenceStore.getRecord(this.#functionPayloadToBeHashed); } returnValue.isIdempotent = true; returnValue.result = this.determineResultFromIdempotencyRecord(idempotencyRecord); return returnValue; } throw new errors_js_1.IdempotencyPersistenceLayerError('Failed to save in progress record to idempotency store', { cause: error }); } }; /** * Save a successful result to the idempotency store. * * This is called when the handler returns successfully. * * @param result The result returned by the handler. */ #saveSuccessfulResult = async (result) => { try { await this.#persistenceStore.saveSuccess(this.#functionPayloadToBeHashed, result); } catch (error) { throw new errors_js_1.IdempotencyPersistenceLayerError('Failed to update success record to idempotency store', { cause: error }); } }; } exports.IdempotencyHandler = IdempotencyHandler;