Skip to content

feat(idempotency): makeHandlerIdempotent middy middleware #1474

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 5, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions packages/idempotency/src/IdempotencyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ export class IdempotencyHandler<U> {
});
}

public determineResultFromIdempotencyRecord(
public static determineResultFromIdempotencyRecord(
idempotencyRecord: IdempotencyRecord
): Promise<U> | U {
): Promise<unknown> | unknown {
if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.EXPIRED) {
throw new IdempotencyInconsistentStateError(
'Item has expired during processing and may not longer be valid.'
Expand All @@ -61,7 +61,7 @@ export class IdempotencyHandler<U> {
}
}

return idempotencyRecord.getResponse() as U;
return idempotencyRecord.getResponse();
}

public async getFunctionResult(): Promise<U> {
Expand Down Expand Up @@ -115,7 +115,9 @@ export class IdempotencyHandler<U> {
}
}
/* istanbul ignore next */
throw new Error('This should never happen');
throw new Error(
'This should never happen, if you see this please open an issue.'
);
}

public async processIdempotency(): Promise<U> {
Expand All @@ -128,7 +130,9 @@ export class IdempotencyHandler<U> {
const idempotencyRecord: IdempotencyRecord =
await this.persistenceStore.getRecord(this.functionPayloadToBeHashed);

return this.determineResultFromIdempotencyRecord(idempotencyRecord);
return IdempotencyHandler.determineResultFromIdempotencyRecord(
idempotencyRecord
) as U;
} else {
throw new IdempotencyPersistenceLayerError();
}
Expand Down
1 change: 1 addition & 0 deletions packages/idempotency/src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './makeHandlerIdempotent';
144 changes: 144 additions & 0 deletions packages/idempotency/src/middleware/makeHandlerIdempotent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { IdempotencyHandler } from '../IdempotencyHandler';
import { IdempotencyConfig } from '../IdempotencyConfig';
import { cleanupMiddlewares } from '@aws-lambda-powertools/commons/lib/middleware';
import {
IdempotencyItemAlreadyExistsError,
IdempotencyPersistenceLayerError,
} from '../Exceptions';
import { IdempotencyRecord } from '../persistence';
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.
*
* @param request - The Middy request object
*/
const before = async (request: MiddyLikeRequest): Promise<unknown | void> => {
try {
await persistenceStore.saveInProgress(
request.event as Record<string, unknown>,
request.context.getRemainingTimeInMillis()
);
} catch (error) {
if (error instanceof IdempotencyItemAlreadyExistsError) {
const idempotencyRecord: IdempotencyRecord =
await persistenceStore.getRecord(
request.event as Record<string, unknown>
);

const response =
await IdempotencyHandler.determineResultFromIdempotencyRecord(
idempotencyRecord
);
if (response) {
// Cleanup other middlewares
cleanupMiddlewares(request);

return response;
}
} 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<void> => {
try {
await persistenceStore.saveSuccess(
request.event as Record<string, unknown>,
request.response as Record<string, unknown>
);
} 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<void> => {
try {
await persistenceStore.deleteRecord(
request.event as Record<string, unknown>
);
} catch (error) {
throw new IdempotencyPersistenceLayerError(
'Failed to delete record from idempotency store'
);
}
};

return {
before,
after,
onError,
};
};

export { makeHandlerIdempotent };
20 changes: 7 additions & 13 deletions packages/idempotency/tests/unit/IdempotencyHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('Class IdempotencyHandler', () => {
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS);

try {
await idempotentHandler.determineResultFromIdempotencyRecord(
await IdempotencyHandler.determineResultFromIdempotencyRecord(
stubRecord
);
} catch (e) {
Expand All @@ -78,7 +78,7 @@ describe('Class IdempotencyHandler', () => {
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.INPROGRESS);

try {
await idempotentHandler.determineResultFromIdempotencyRecord(
await IdempotencyHandler.determineResultFromIdempotencyRecord(
stubRecord
);
} catch (e) {
Expand All @@ -100,7 +100,7 @@ describe('Class IdempotencyHandler', () => {
expect(stubRecord.getStatus()).toBe(IdempotencyRecordStatus.EXPIRED);

try {
await idempotentHandler.determineResultFromIdempotencyRecord(
await IdempotencyHandler.determineResultFromIdempotencyRecord(
stubRecord
);
} catch (e) {
Expand Down Expand Up @@ -157,11 +157,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
Expand Down Expand Up @@ -191,11 +188,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'
Expand Down
Loading