Skip to content

Commit 21d1a91

Browse files
authored
docs(idempotency): bring your own persistent store (#1681)
* docs(idempotency): bring your own persistent store * docs: moved all snippets to dedicated files * chore: fix non-null assertion * chore: remove redundant try/catch * chore: remove redundant try/catch * chore: refactor generic persistence layer * chore: refactor generic persistence layer * docs: added entry & handler to CDK sample
1 parent cabee4d commit 21d1a91

14 files changed

+524
-194
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
IdempotencyItemAlreadyExistsError,
3+
IdempotencyItemNotFoundError,
4+
IdempotencyRecordStatus,
5+
} from '@aws-lambda-powertools/idempotency';
6+
import { IdempotencyRecordOptions } from '@aws-lambda-powertools/idempotency/types';
7+
import {
8+
IdempotencyRecord,
9+
BasePersistenceLayer,
10+
} from '@aws-lambda-powertools/idempotency/persistence';
11+
import { getSecret } from '@aws-lambda-powertools/parameters/secrets';
12+
import { Transform } from '@aws-lambda-powertools/parameters';
13+
import {
14+
ProviderClient,
15+
ProviderItemAlreadyExists,
16+
} from './advancedBringYourOwnPersistenceLayerProvider';
17+
import type { ApiSecret, ProviderItem } from './types';
18+
19+
class CustomPersistenceLayer extends BasePersistenceLayer {
20+
#collectionName: string;
21+
#client?: ProviderClient;
22+
23+
public constructor(config: { collectionName: string }) {
24+
super();
25+
this.#collectionName = config.collectionName;
26+
}
27+
28+
protected async _deleteRecord(record: IdempotencyRecord): Promise<void> {
29+
await (
30+
await this.#getClient()
31+
).delete(this.#collectionName, record.idempotencyKey);
32+
}
33+
34+
protected async _getRecord(
35+
idempotencyKey: string
36+
): Promise<IdempotencyRecord> {
37+
try {
38+
const item = await (
39+
await this.#getClient()
40+
).get(this.#collectionName, idempotencyKey);
41+
42+
return new IdempotencyRecord({
43+
...(item as unknown as IdempotencyRecordOptions),
44+
});
45+
} catch (error) {
46+
throw new IdempotencyItemNotFoundError();
47+
}
48+
}
49+
50+
protected async _putRecord(record: IdempotencyRecord): Promise<void> {
51+
const item: Partial<ProviderItem> = {
52+
status: record.getStatus(),
53+
};
54+
55+
if (record.inProgressExpiryTimestamp !== undefined) {
56+
item.in_progress_expiration = record.inProgressExpiryTimestamp;
57+
}
58+
59+
if (this.isPayloadValidationEnabled() && record.payloadHash !== undefined) {
60+
item.validation = record.payloadHash;
61+
}
62+
63+
const ttl = record.expiryTimestamp
64+
? Math.floor(new Date(record.expiryTimestamp * 1000).getTime() / 1000) -
65+
Math.floor(new Date().getTime() / 1000)
66+
: this.getExpiresAfterSeconds();
67+
68+
let existingItem: ProviderItem | undefined;
69+
try {
70+
existingItem = await (
71+
await this.#getClient()
72+
).put(this.#collectionName, record.idempotencyKey, item, {
73+
ttl,
74+
});
75+
} catch (error) {
76+
if (error instanceof ProviderItemAlreadyExists) {
77+
if (
78+
existingItem &&
79+
existingItem.status !== IdempotencyRecordStatus.INPROGRESS &&
80+
(existingItem.in_progress_expiration || 0) < Date.now()
81+
) {
82+
throw new IdempotencyItemAlreadyExistsError(
83+
`Failed to put record for already existing idempotency key: ${record.idempotencyKey}`
84+
);
85+
}
86+
}
87+
}
88+
}
89+
90+
protected async _updateRecord(record: IdempotencyRecord): Promise<void> {
91+
const value: Partial<ProviderItem> = {
92+
data: JSON.stringify(record.responseData),
93+
status: record.getStatus(),
94+
};
95+
96+
if (this.isPayloadValidationEnabled()) {
97+
value.validation = record.payloadHash;
98+
}
99+
100+
await (
101+
await this.#getClient()
102+
).update(this.#collectionName, record.idempotencyKey, value);
103+
}
104+
105+
async #getClient(): Promise<ProviderClient> {
106+
if (this.#client) return this.#client;
107+
108+
const secretName = process.env.API_SECRET;
109+
if (!secretName) {
110+
throw new Error('API_SECRET environment variable is not set');
111+
}
112+
113+
const apiSecret = await getSecret<ApiSecret>(secretName, {
114+
transform: Transform.JSON,
115+
});
116+
117+
if (!apiSecret) {
118+
throw new Error(`Could not retrieve secret ${secretName}`);
119+
}
120+
121+
this.#client = new ProviderClient({
122+
apiKey: apiSecret.apiKey,
123+
defaultTtlSeconds: this.getExpiresAfterSeconds(),
124+
});
125+
126+
return this.#client;
127+
}
128+
}
129+
130+
export { CustomPersistenceLayer };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ProviderItem } from './types';
2+
3+
/**
4+
* This is a mock implementation of an SDK client for a generic key-value store.
5+
*/
6+
class ProviderClient {
7+
public constructor(_config: { apiKey: string; defaultTtlSeconds: number }) {
8+
// ...
9+
}
10+
11+
public async delete(_collectionName: string, _key: string): Promise<void> {
12+
// ...
13+
}
14+
15+
public async get(
16+
_collectionName: string,
17+
_key: string
18+
): Promise<ProviderItem> {
19+
// ...
20+
return {} as ProviderItem;
21+
}
22+
23+
public async put(
24+
_collectionName: string,
25+
_key: string,
26+
_value: Partial<ProviderItem>,
27+
_options: { ttl: number }
28+
): Promise<ProviderItem> {
29+
// ...
30+
return {} as ProviderItem;
31+
}
32+
33+
public async update(
34+
_collectionName: string,
35+
_key: string,
36+
_value: Partial<ProviderItem>
37+
): Promise<void> {
38+
// ...
39+
}
40+
}
41+
42+
class ProviderItemAlreadyExists extends Error {}
43+
44+
export { ProviderClient, ProviderItemAlreadyExists };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Context } from 'aws-lambda';
2+
import { randomUUID } from 'node:crypto';
3+
import { CustomPersistenceLayer } from './advancedBringYourOwnPersistenceLayer';
4+
import {
5+
IdempotencyConfig,
6+
makeIdempotent,
7+
} from '@aws-lambda-powertools/idempotency';
8+
import type { Request, Response, SubscriptionResult } from './types';
9+
10+
const persistenceStore = new CustomPersistenceLayer({
11+
collectionName: 'powertools',
12+
});
13+
const config = new IdempotencyConfig({
14+
expiresAfterSeconds: 60,
15+
});
16+
17+
const createSubscriptionPayment = makeIdempotent(
18+
async (
19+
_transactionId: string,
20+
event: Request
21+
): Promise<SubscriptionResult> => {
22+
// ... create payment
23+
return {
24+
id: randomUUID(),
25+
productId: event.productId,
26+
};
27+
},
28+
{
29+
persistenceStore,
30+
dataIndexArgument: 1,
31+
config,
32+
}
33+
);
34+
35+
export const handler = async (
36+
event: Request,
37+
context: Context
38+
): Promise<Response> => {
39+
config.registerLambdaContext(context);
40+
41+
try {
42+
const transactionId = randomUUID();
43+
const payment = await createSubscriptionPayment(transactionId, event);
44+
45+
return {
46+
paymentId: payment.id,
47+
message: 'success',
48+
statusCode: 200,
49+
};
50+
} catch (error) {
51+
throw new Error('Error creating payment');
52+
}
53+
};

Diff for: docs/snippets/idempotency/makeIdempotentJmes.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const persistenceStore = new DynamoDBPersistenceLayer({
1212
});
1313

1414
const createSubscriptionPayment = async (
15-
user: string,
15+
_user: string,
1616
productId: string
1717
): Promise<SubscriptionResult> => {
1818
// ... create payment
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"version": "2.0",
3+
"routeKey": "ANY /createpayment",
4+
"rawPath": "/createpayment",
5+
"rawQueryString": "",
6+
"headers": {
7+
"Header1": "value1",
8+
"X-Idempotency-Key": "abcdefg"
9+
},
10+
"requestContext": {
11+
"accountId": "123456789012",
12+
"apiId": "api-id",
13+
"domainName": "id.execute-api.us-east-1.amazonaws.com",
14+
"domainPrefix": "id",
15+
"http": {
16+
"method": "POST",
17+
"path": "/createpayment",
18+
"protocol": "HTTP/1.1",
19+
"sourceIp": "ip",
20+
"userAgent": "agent"
21+
},
22+
"requestId": "id",
23+
"routeKey": "ANY /createpayment",
24+
"stage": "$default",
25+
"time": "10/Feb/2021:13:40:43 +0000",
26+
"timeEpoch": 1612964443723
27+
},
28+
"body": "{\"user\":\"xyz\",\"productId\":\"123456789\"}",
29+
"isBase64Encoded": false
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"user": {
3+
"uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1",
4+
"name": "foo",
5+
"productId": 10000
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"user": {
3+
"uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1",
4+
"name": "Foo"
5+
},
6+
"productId": 10000
7+
}
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"Records": [
3+
{
4+
"messageId": "059f36b4-87a3-44ab-83d2-661975830a7d",
5+
"receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...",
6+
"body": "Test message.",
7+
"attributes": {
8+
"ApproximateReceiveCount": "1",
9+
"SentTimestamp": "1545082649183",
10+
"SenderId": "AIDAIENQZJOLO23YVJ4VO",
11+
"ApproximateFirstReceiveTimestamp": "1545082649185"
12+
},
13+
"messageAttributes": {
14+
"testAttr": {
15+
"stringValue": "100",
16+
"binaryValue": "base64Str",
17+
"dataType": "Number"
18+
}
19+
},
20+
"md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3",
21+
"eventSource": "aws:sqs",
22+
"eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue",
23+
"awsRegion": "us-east-2"
24+
}
25+
]
26+
}

Diff for: docs/snippets/idempotency/templates/tableCdk.ts

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Stack, type StackProps } from 'aws-cdk-lib';
2+
import { Construct } from 'constructs';
3+
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
4+
import { Runtime } from 'aws-cdk-lib/aws-lambda';
5+
import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb';
6+
7+
export class IdempotencyStack extends Stack {
8+
public constructor(scope: Construct, id: string, props?: StackProps) {
9+
super(scope, id, props);
10+
11+
const table = new Table(this, 'idempotencyTable', {
12+
partitionKey: {
13+
name: 'id',
14+
type: AttributeType.STRING,
15+
},
16+
timeToLiveAttribute: 'expiration',
17+
billingMode: BillingMode.PAY_PER_REQUEST,
18+
});
19+
20+
const fnHandler = new NodejsFunction(this, 'helloWorldFunction', {
21+
runtime: Runtime.NODEJS_18_X,
22+
handler: 'handler',
23+
entry: 'src/index.ts',
24+
environment: {
25+
IDEMPOTENCY_TABLE_NAME: table.tableName,
26+
},
27+
});
28+
table.grantReadWriteData(fnHandler);
29+
}
30+
}

Diff for: docs/snippets/idempotency/templates/tableSam.yaml

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
Transform: AWS::Serverless-2016-10-31
2+
Resources:
3+
IdempotencyTable:
4+
Type: AWS::DynamoDB::Table
5+
Properties:
6+
AttributeDefinitions:
7+
- AttributeName: id
8+
AttributeType: S
9+
KeySchema:
10+
- AttributeName: id
11+
KeyType: HASH
12+
TimeToLiveSpecification:
13+
AttributeName: expiration
14+
Enabled: true
15+
BillingMode: PAY_PER_REQUEST
16+
17+
HelloWorldFunction:
18+
Type: AWS::Serverless::Function
19+
Properties:
20+
Runtime: python3.11
21+
Handler: app.py
22+
Policies:
23+
- Statement:
24+
- Sid: AllowDynamodbReadWrite
25+
Effect: Allow
26+
Action:
27+
- dynamodb:PutItem
28+
- dynamodb:GetItem
29+
- dynamodb:UpdateItem
30+
- dynamodb:DeleteItem
31+
Resource: !GetAtt IdempotencyTable.Arn

0 commit comments

Comments
 (0)