Skip to content

Commit d138673

Browse files
authored
feat(idempotency): add idempotency decorator (#1723)
1 parent be16b59 commit d138673

File tree

8 files changed

+1121
-1
lines changed

8 files changed

+1121
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Context } from 'aws-lambda';
2+
import { LambdaInterface } from '@aws-lambda-powertools/commons';
3+
import {
4+
IdempotencyConfig,
5+
idempotent,
6+
} from '@aws-lambda-powertools/idempotency';
7+
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
8+
import { Request, Response } from './types';
9+
10+
const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({
11+
tableName: 'idempotencyTableName',
12+
});
13+
14+
const config = new IdempotencyConfig({});
15+
16+
class MyLambda implements LambdaInterface {
17+
@idempotent({ persistenceStore: dynamoDBPersistenceLayer, config: config })
18+
public async handler(_event: Request, _context: Context): Promise<Response> {
19+
// ... process your event
20+
return {
21+
message: 'success',
22+
statusCode: 200,
23+
};
24+
}
25+
}
26+
27+
const defaultLambda = new MyLambda();
28+
export const handler = defaultLambda.handler.bind(defaultLambda);

docs/utilities/idempotency.md

+16
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,22 @@ When using `makeIdempotent` on arbitrary functions, you can tell us which argume
160160

161161
The function this example has two arguments, note that while wrapping it with the `makeIdempotent` high-order function, we specify the `dataIndexArgument` as `1` to tell the decorator that the second argument is the one that contains the data we should use to make the function idempotent. Remember that arguments are zero-indexed, so the first argument is `0`, the second is `1`, and so on.
162162

163+
### Idempotent Decorator
164+
165+
You can also use the `@idempotent` decorator to make your Lambda handler idempotent, similar to the `makeIdempotent` function wrapper.
166+
167+
=== "index.ts"
168+
169+
```typescript hl_lines="17"
170+
--8<-- "docs/snippets/idempotency/idempotentDecoratorBase.ts"
171+
```
172+
173+
=== "types.ts"
174+
175+
```typescript
176+
177+
You can use the decorator on your Lambda handler or on any function that returns a response to make it idempotent. This is useful when you want to make a specific logic idempotent, for example when your Lambda handler performs multiple side effects and you only want to make a specific one idempotent.
178+
The configuration options for the `@idempotent` decorator are the same as the ones for the `makeIdempotent` function wrapper.
163179

164180
### MakeHandlerIdempotent Middy middleware
165181

packages/idempotency/README.md

+65-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ You can use the package in both TypeScript and JavaScript code bases.
99
- [Key features](#key-features)
1010
- [Usage](#usage)
1111
- [Function wrapper](#function-wrapper)
12+
- [Decorator](#decorator)
1213
- [Middy middleware](#middy-middleware)
1314
- [DynamoDB persistence layer](#dynamodb-persistence-layer)
1415
- [Contribute](#contribute)
@@ -24,7 +25,7 @@ You can use the package in both TypeScript and JavaScript code bases.
2425
## Intro
2526

2627
This package provides a utility to implement idempotency in your Lambda functions.
27-
You can either use it to wrap a function, or as Middy middleware to make your AWS Lambda handler idempotent.
28+
You can either use it to wrap a function, decorate a function, or as Middy middleware to make your AWS Lambda handler idempotent.
2829

2930
The current implementation provides a persistence layer for Amazon DynamoDB, which offers a variety of configuration options. You can also bring your own persistence layer by extending the `BasePersistenceLayer` class.
3031

@@ -163,6 +164,69 @@ export const handler = makeIdempotent(myHandler, {
163164

164165
Check the [docs](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/idempotency/) for more examples.
165166

167+
### Decorator
168+
169+
You can make any function idempotent, and safe to retry, by decorating it using the `@idempotent` decorator.
170+
171+
```ts
172+
import { idempotent } from '@aws-lambda-powertools/idempotency';
173+
import { LambdaInterface } from '@aws-lambda-powertools/commons';
174+
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
175+
import type { Context, APIGatewayProxyEvent } from 'aws-lambda';
176+
177+
const persistenceStore = new DynamoDBPersistenceLayer({
178+
tableName: 'idempotencyTableName',
179+
});
180+
181+
class MyHandler extends LambdaInterface {
182+
@idempotent({ persistenceStore: dynamoDBPersistenceLayer })
183+
public async handler(
184+
event: APIGatewayProxyEvent,
185+
context: Context
186+
): Promise<void> {
187+
// your code goes here here
188+
}
189+
}
190+
191+
const handlerClass = new MyHandler();
192+
export const handler = handlerClass.handler.bind(handlerClass);
193+
```
194+
195+
Using the same decorator, you can also make any other arbitrary function idempotent.
196+
197+
```ts
198+
import { idempotent } from '@aws-lambda-powertools/idempotency';
199+
import { LambdaInterface } from '@aws-lambda-powertools/commons';
200+
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
201+
import type { Context } from 'aws-lambda';
202+
203+
const persistenceStore = new DynamoDBPersistenceLayer({
204+
tableName: 'idempotencyTableName',
205+
});
206+
207+
class MyHandler extends LambdaInterface {
208+
209+
public async handler(
210+
event: unknown,
211+
context: Context
212+
): Promise<void> {
213+
for(const record of event.Records) {
214+
await this.processIdempotently(record);
215+
}
216+
}
217+
218+
@idempotent({ persistenceStore: dynamoDBPersistenceLayer })
219+
private async process(record: unknown): Promise<void> {
220+
// process each code idempotently
221+
}
222+
}
223+
224+
const handlerClass = new MyHandler();
225+
export const handler = handlerClass.handler.bind(handlerClass);
226+
```
227+
228+
The decorator configuration options are identical with the ones of the `makeIdempotent` function. Check the [docs](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/idempotency/) for more examples.
229+
166230
### Middy middleware
167231

168232
If instead you use Middy, you can use the `makeHandlerIdempotent` middleware. When using the middleware your Lambda handler becomes idempotent.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { AnyFunction, ItempotentFunctionOptions } from './types';
2+
import { makeIdempotent } from './makeIdempotent';
3+
4+
/**
5+
* Use this decorator to make your lambda handler itempotent.
6+
* You need to provide a peristance layer to store the idempotency information.
7+
* At the moment we only support `DynamodbPersistenceLayer`.
8+
*
9+
* @example
10+
* ```ts
11+
* import {
12+
* DynamoDBPersistenceLayer,
13+
* idempotentLambdaHandler
14+
* } from '@aws-lambda-powertools/idempotency'
15+
*
16+
* class MyLambdaFunction {
17+
* @idempotent({ persistenceStore: new DynamoDBPersistenceLayer() })
18+
* async handler(event: any, context: any) {
19+
* return "Hello World";
20+
* }
21+
* }
22+
* export myLambdaHandler new MyLambdaFunction();
23+
* export const handler = myLambdaHandler.handler.bind(myLambdaHandler);
24+
* ```
25+
*
26+
* Similar to decoratoring a handler you can use the decorator on any other function.
27+
* @example
28+
* ```ts
29+
* import {
30+
* DynamoDBPersistenceLayer,
31+
* idempotentFunction
32+
* } from '@aws-lambda-powertools/idempotency'
33+
*
34+
* class MyClass {
35+
*
36+
* public async handler(_event: any, _context: any) {
37+
* for(const record of _event.records){
38+
* await this.process(record);
39+
* }
40+
* }
41+
*
42+
* @idemptent({ persistenceStore: new DynamoDBPersistenceLayer() })
43+
* public async process(record: Record<stiring, unknown) {
44+
* // do some processing
45+
* }
46+
* ```
47+
* @see {@link DynamoDBPersistenceLayer}
48+
* @see https://www.typescriptlang.org/docs/handbook/decorators.html
49+
*/
50+
const idempotent = function (
51+
options: ItempotentFunctionOptions<Parameters<AnyFunction>>
52+
): (
53+
target: unknown,
54+
propertyKey: string,
55+
descriptor: PropertyDescriptor
56+
) => PropertyDescriptor {
57+
return function (
58+
_target: unknown,
59+
_propertyKey: string,
60+
descriptor: PropertyDescriptor
61+
) {
62+
const childFunction = descriptor.value;
63+
descriptor.value = makeIdempotent(childFunction, options);
64+
65+
return descriptor;
66+
};
67+
};
68+
69+
export { idempotent };

packages/idempotency/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './errors';
22
export * from './IdempotencyConfig';
33
export * from './makeIdempotent';
4+
export * from './idempotencyDecorator';
45
export { IdempotencyRecordStatus } from './constants';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type { Context } from 'aws-lambda';
2+
import { LambdaInterface } from '@aws-lambda-powertools/commons';
3+
import { idempotent } from '../../src';
4+
import { Logger } from '../../../logger';
5+
import { DynamoDBPersistenceLayer } from '../../src/persistence/DynamoDBPersistenceLayer';
6+
import { IdempotencyConfig } from '../../src/';
7+
8+
const IDEMPOTENCY_TABLE_NAME =
9+
process.env.IDEMPOTENCY_TABLE_NAME || 'table_name';
10+
const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({
11+
tableName: IDEMPOTENCY_TABLE_NAME,
12+
});
13+
14+
const dynamoDBPersistenceLayerCustomized = new DynamoDBPersistenceLayer({
15+
tableName: IDEMPOTENCY_TABLE_NAME,
16+
dataAttr: 'dataAttr',
17+
keyAttr: 'customId',
18+
expiryAttr: 'expiryAttr',
19+
statusAttr: 'statusAttr',
20+
inProgressExpiryAttr: 'inProgressExpiryAttr',
21+
staticPkValue: 'staticPkValue',
22+
validationKeyAttr: 'validationKeyAttr',
23+
});
24+
25+
const config = new IdempotencyConfig({});
26+
27+
class DefaultLambda implements LambdaInterface {
28+
@idempotent({ persistenceStore: dynamoDBPersistenceLayer })
29+
public async handler(
30+
_event: Record<string, unknown>,
31+
_context: Context
32+
): Promise<string> {
33+
logger.info(`Got test event: ${JSON.stringify(_event)}`);
34+
// sleep to enforce error with parallel execution
35+
await new Promise((resolve) => setTimeout(resolve, 1000));
36+
37+
return 'Hello World';
38+
}
39+
40+
@idempotent({
41+
persistenceStore: dynamoDBPersistenceLayerCustomized,
42+
config: config,
43+
})
44+
public async handlerCustomized(
45+
event: { foo: string },
46+
context: Context
47+
): Promise<string> {
48+
config.registerLambdaContext(context);
49+
logger.info('Processed event', { details: event.foo });
50+
51+
return event.foo;
52+
}
53+
54+
@idempotent({
55+
persistenceStore: dynamoDBPersistenceLayer,
56+
config: new IdempotencyConfig({
57+
useLocalCache: false,
58+
expiresAfterSeconds: 1,
59+
eventKeyJmesPath: 'foo',
60+
}),
61+
})
62+
public async handlerExpired(
63+
event: { foo: string; invocation: number },
64+
context: Context
65+
): Promise<{ foo: string; invocation: number }> {
66+
logger.addContext(context);
67+
68+
logger.info('Processed event', { details: event.foo });
69+
70+
return {
71+
foo: event.foo,
72+
invocation: event.invocation,
73+
};
74+
}
75+
76+
@idempotent({ persistenceStore: dynamoDBPersistenceLayer })
77+
public async handlerParallel(
78+
event: { foo: string },
79+
context: Context
80+
): Promise<string> {
81+
logger.addContext(context);
82+
83+
await new Promise((resolve) => setTimeout(resolve, 1500));
84+
85+
logger.info('Processed event', { details: event.foo });
86+
87+
return event.foo;
88+
}
89+
90+
@idempotent({
91+
persistenceStore: dynamoDBPersistenceLayer,
92+
config: new IdempotencyConfig({
93+
eventKeyJmesPath: 'foo',
94+
}),
95+
})
96+
public async handlerTimeout(
97+
event: { foo: string; invocation: number },
98+
context: Context
99+
): Promise<{ foo: string; invocation: number }> {
100+
logger.addContext(context);
101+
102+
if (event.invocation === 0) {
103+
await new Promise((resolve) => setTimeout(resolve, 4000));
104+
}
105+
106+
logger.info('Processed event', {
107+
details: event.foo,
108+
});
109+
110+
return {
111+
foo: event.foo,
112+
invocation: event.invocation,
113+
};
114+
}
115+
}
116+
117+
const defaultLambda = new DefaultLambda();
118+
const handler = defaultLambda.handler.bind(defaultLambda);
119+
const handlerParallel = defaultLambda.handlerParallel.bind(defaultLambda);
120+
121+
const handlerCustomized = defaultLambda.handlerCustomized.bind(defaultLambda);
122+
123+
const handlerTimeout = defaultLambda.handlerTimeout.bind(defaultLambda);
124+
125+
const handlerExpired = defaultLambda.handlerExpired.bind(defaultLambda);
126+
127+
const logger = new Logger();
128+
129+
class LambdaWithKeywordArgument implements LambdaInterface {
130+
public async handler(
131+
event: { id: string },
132+
_context: Context
133+
): Promise<string> {
134+
config.registerLambdaContext(_context);
135+
await this.process(event.id, 'bar');
136+
137+
return 'Hello World Keyword Argument';
138+
}
139+
140+
@idempotent({
141+
persistenceStore: dynamoDBPersistenceLayer,
142+
config: config,
143+
dataIndexArgument: 1,
144+
})
145+
public async process(id: string, foo: string): Promise<string> {
146+
logger.info('Got test event', { id, foo });
147+
148+
return 'idempotent result: ' + foo;
149+
}
150+
}
151+
152+
const handlerDataIndexArgument = new LambdaWithKeywordArgument();
153+
const handlerWithKeywordArgument = handlerDataIndexArgument.handler.bind(
154+
handlerDataIndexArgument
155+
);
156+
157+
export {
158+
handler,
159+
handlerCustomized,
160+
handlerExpired,
161+
handlerWithKeywordArgument,
162+
handlerTimeout,
163+
handlerParallel,
164+
};

0 commit comments

Comments
 (0)