diff --git a/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayer.ts b/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayer.ts new file mode 100644 index 0000000000..d65d2f5b8c --- /dev/null +++ b/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayer.ts @@ -0,0 +1,130 @@ +import { + IdempotencyItemAlreadyExistsError, + IdempotencyItemNotFoundError, + IdempotencyRecordStatus, +} from '@aws-lambda-powertools/idempotency'; +import { IdempotencyRecordOptions } from '@aws-lambda-powertools/idempotency/types'; +import { + IdempotencyRecord, + BasePersistenceLayer, +} from '@aws-lambda-powertools/idempotency/persistence'; +import { getSecret } from '@aws-lambda-powertools/parameters/secrets'; +import { Transform } from '@aws-lambda-powertools/parameters'; +import { + ProviderClient, + ProviderItemAlreadyExists, +} from './advancedBringYourOwnPersistenceLayerProvider'; +import type { ApiSecret, ProviderItem } from './types'; + +class CustomPersistenceLayer extends BasePersistenceLayer { + #collectionName: string; + #client?: ProviderClient; + + public constructor(config: { collectionName: string }) { + super(); + this.#collectionName = config.collectionName; + } + + protected async _deleteRecord(record: IdempotencyRecord): Promise { + await ( + await this.#getClient() + ).delete(this.#collectionName, record.idempotencyKey); + } + + protected async _getRecord( + idempotencyKey: string + ): Promise { + try { + const item = await ( + await this.#getClient() + ).get(this.#collectionName, idempotencyKey); + + return new IdempotencyRecord({ + ...(item as unknown as IdempotencyRecordOptions), + }); + } catch (error) { + throw new IdempotencyItemNotFoundError(); + } + } + + protected async _putRecord(record: IdempotencyRecord): Promise { + const item: Partial = { + status: record.getStatus(), + }; + + if (record.inProgressExpiryTimestamp !== undefined) { + item.in_progress_expiration = record.inProgressExpiryTimestamp; + } + + if (this.isPayloadValidationEnabled() && record.payloadHash !== undefined) { + item.validation = record.payloadHash; + } + + const ttl = record.expiryTimestamp + ? Math.floor(new Date(record.expiryTimestamp * 1000).getTime() / 1000) - + Math.floor(new Date().getTime() / 1000) + : this.getExpiresAfterSeconds(); + + let existingItem: ProviderItem | undefined; + try { + existingItem = await ( + await this.#getClient() + ).put(this.#collectionName, record.idempotencyKey, item, { + ttl, + }); + } catch (error) { + if (error instanceof ProviderItemAlreadyExists) { + if ( + existingItem && + existingItem.status !== IdempotencyRecordStatus.INPROGRESS && + (existingItem.in_progress_expiration || 0) < Date.now() + ) { + throw new IdempotencyItemAlreadyExistsError( + `Failed to put record for already existing idempotency key: ${record.idempotencyKey}` + ); + } + } + } + } + + protected async _updateRecord(record: IdempotencyRecord): Promise { + const value: Partial = { + data: JSON.stringify(record.responseData), + status: record.getStatus(), + }; + + if (this.isPayloadValidationEnabled()) { + value.validation = record.payloadHash; + } + + await ( + await this.#getClient() + ).update(this.#collectionName, record.idempotencyKey, value); + } + + async #getClient(): Promise { + if (this.#client) return this.#client; + + const secretName = process.env.API_SECRET; + if (!secretName) { + throw new Error('API_SECRET environment variable is not set'); + } + + const apiSecret = await getSecret(secretName, { + transform: Transform.JSON, + }); + + if (!apiSecret) { + throw new Error(`Could not retrieve secret ${secretName}`); + } + + this.#client = new ProviderClient({ + apiKey: apiSecret.apiKey, + defaultTtlSeconds: this.getExpiresAfterSeconds(), + }); + + return this.#client; + } +} + +export { CustomPersistenceLayer }; diff --git a/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayerProvider.ts b/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayerProvider.ts new file mode 100644 index 0000000000..3dedecf864 --- /dev/null +++ b/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayerProvider.ts @@ -0,0 +1,44 @@ +import type { ProviderItem } from './types'; + +/** + * This is a mock implementation of an SDK client for a generic key-value store. + */ +class ProviderClient { + public constructor(_config: { apiKey: string; defaultTtlSeconds: number }) { + // ... + } + + public async delete(_collectionName: string, _key: string): Promise { + // ... + } + + public async get( + _collectionName: string, + _key: string + ): Promise { + // ... + return {} as ProviderItem; + } + + public async put( + _collectionName: string, + _key: string, + _value: Partial, + _options: { ttl: number } + ): Promise { + // ... + return {} as ProviderItem; + } + + public async update( + _collectionName: string, + _key: string, + _value: Partial + ): Promise { + // ... + } +} + +class ProviderItemAlreadyExists extends Error {} + +export { ProviderClient, ProviderItemAlreadyExists }; diff --git a/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayerUsage.ts b/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayerUsage.ts new file mode 100644 index 0000000000..2e8b5fa29e --- /dev/null +++ b/docs/snippets/idempotency/advancedBringYourOwnPersistenceLayerUsage.ts @@ -0,0 +1,53 @@ +import type { Context } from 'aws-lambda'; +import { randomUUID } from 'node:crypto'; +import { CustomPersistenceLayer } from './advancedBringYourOwnPersistenceLayer'; +import { + IdempotencyConfig, + makeIdempotent, +} from '@aws-lambda-powertools/idempotency'; +import type { Request, Response, SubscriptionResult } from './types'; + +const persistenceStore = new CustomPersistenceLayer({ + collectionName: 'powertools', +}); +const config = new IdempotencyConfig({ + expiresAfterSeconds: 60, +}); + +const createSubscriptionPayment = makeIdempotent( + async ( + _transactionId: string, + event: Request + ): Promise => { + // ... create payment + return { + id: randomUUID(), + productId: event.productId, + }; + }, + { + persistenceStore, + dataIndexArgument: 1, + config, + } +); + +export const handler = async ( + event: Request, + context: Context +): Promise => { + config.registerLambdaContext(context); + + try { + const transactionId = randomUUID(); + const payment = await createSubscriptionPayment(transactionId, event); + + return { + paymentId: payment.id, + message: 'success', + statusCode: 200, + }; + } catch (error) { + throw new Error('Error creating payment'); + } +}; diff --git a/docs/snippets/idempotency/makeIdempotentJmes.ts b/docs/snippets/idempotency/makeIdempotentJmes.ts index 32675c1687..b4d0d165d7 100644 --- a/docs/snippets/idempotency/makeIdempotentJmes.ts +++ b/docs/snippets/idempotency/makeIdempotentJmes.ts @@ -12,7 +12,7 @@ const persistenceStore = new DynamoDBPersistenceLayer({ }); const createSubscriptionPayment = async ( - user: string, + _user: string, productId: string ): Promise => { // ... create payment diff --git a/docs/snippets/idempotency/samples/makeIdempotentJmes.json b/docs/snippets/idempotency/samples/makeIdempotentJmes.json new file mode 100644 index 0000000000..9f608983da --- /dev/null +++ b/docs/snippets/idempotency/samples/makeIdempotentJmes.json @@ -0,0 +1,30 @@ +{ + "version": "2.0", + "routeKey": "ANY /createpayment", + "rawPath": "/createpayment", + "rawQueryString": "", + "headers": { + "Header1": "value1", + "X-Idempotency-Key": "abcdefg" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "api-id", + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "http": { + "method": "POST", + "path": "/createpayment", + "protocol": "HTTP/1.1", + "sourceIp": "ip", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "ANY /createpayment", + "stage": "$default", + "time": "10/Feb/2021:13:40:43 +0000", + "timeEpoch": 1612964443723 + }, + "body": "{\"user\":\"xyz\",\"productId\":\"123456789\"}", + "isBase64Encoded": false +} \ No newline at end of file diff --git a/docs/snippets/idempotency/samples/workingWIthIdempotencyRequiredKeyError.json b/docs/snippets/idempotency/samples/workingWIthIdempotencyRequiredKeyError.json new file mode 100644 index 0000000000..a905b83e7a --- /dev/null +++ b/docs/snippets/idempotency/samples/workingWIthIdempotencyRequiredKeyError.json @@ -0,0 +1,7 @@ +{ + "user": { + "uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1", + "name": "foo", + "productId": 10000 + } +} \ No newline at end of file diff --git a/docs/snippets/idempotency/samples/workingWIthIdempotencyRequiredKeySuccess.json b/docs/snippets/idempotency/samples/workingWIthIdempotencyRequiredKeySuccess.json new file mode 100644 index 0000000000..e721b2c24c --- /dev/null +++ b/docs/snippets/idempotency/samples/workingWIthIdempotencyRequiredKeySuccess.json @@ -0,0 +1,7 @@ +{ + "user": { + "uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1", + "name": "Foo" + }, + "productId": 10000 +} \ No newline at end of file diff --git a/docs/snippets/idempotency/samples/workingWithBatch.json b/docs/snippets/idempotency/samples/workingWithBatch.json new file mode 100644 index 0000000000..44bd07a141 --- /dev/null +++ b/docs/snippets/idempotency/samples/workingWithBatch.json @@ -0,0 +1,26 @@ +{ + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185" + }, + "messageAttributes": { + "testAttr": { + "stringValue": "100", + "binaryValue": "base64Str", + "dataType": "Number" + } + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + } + ] +} \ No newline at end of file diff --git a/docs/snippets/idempotency/templates/tableCdk.ts b/docs/snippets/idempotency/templates/tableCdk.ts new file mode 100644 index 0000000000..8a07d5dc3c --- /dev/null +++ b/docs/snippets/idempotency/templates/tableCdk.ts @@ -0,0 +1,30 @@ +import { Stack, type StackProps } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { Runtime } from 'aws-cdk-lib/aws-lambda'; +import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; + +export class IdempotencyStack extends Stack { + public constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const table = new Table(this, 'idempotencyTable', { + partitionKey: { + name: 'id', + type: AttributeType.STRING, + }, + timeToLiveAttribute: 'expiration', + billingMode: BillingMode.PAY_PER_REQUEST, + }); + + const fnHandler = new NodejsFunction(this, 'helloWorldFunction', { + runtime: Runtime.NODEJS_18_X, + handler: 'handler', + entry: 'src/index.ts', + environment: { + IDEMPOTENCY_TABLE_NAME: table.tableName, + }, + }); + table.grantReadWriteData(fnHandler); + } +} diff --git a/docs/snippets/idempotency/templates/tableSam.yaml b/docs/snippets/idempotency/templates/tableSam.yaml new file mode 100644 index 0000000000..010ecc89ca --- /dev/null +++ b/docs/snippets/idempotency/templates/tableSam.yaml @@ -0,0 +1,31 @@ +Transform: AWS::Serverless-2016-10-31 +Resources: + IdempotencyTable: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + TimeToLiveSpecification: + AttributeName: expiration + Enabled: true + BillingMode: PAY_PER_REQUEST + + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: python3.11 + Handler: app.py + Policies: + - Statement: + - Sid: AllowDynamodbReadWrite + Effect: Allow + Action: + - dynamodb:PutItem + - dynamodb:GetItem + - dynamodb:UpdateItem + - dynamodb:DeleteItem + Resource: !GetAtt IdempotencyTable.Arn diff --git a/docs/snippets/idempotency/templates/tableTerraform.tf b/docs/snippets/idempotency/templates/tableTerraform.tf new file mode 100644 index 0000000000..4856f2b0e6 --- /dev/null +++ b/docs/snippets/idempotency/templates/tableTerraform.tf @@ -0,0 +1,78 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.0" + } + } +} + +provider "aws" { + region = "us-east-1" # Replace with your desired AWS region +} + +resource "aws_dynamodb_table" "IdempotencyTable" { + name = "IdempotencyTable" + billing_mode = "PAY_PER_REQUEST" + hash_key = "id" + attribute { + name = "id" + type = "S" + } + ttl { + attribute_name = "expiration" + enabled = true + } +} + +resource "aws_lambda_function" "IdempotencyFunction" { + function_name = "IdempotencyFunction" + role = aws_iam_role.IdempotencyFunctionRole.arn + runtime = "nodejs18.x" + handler = "index.handler" + filename = "lambda.zip" +} + +resource "aws_iam_role" "IdempotencyFunctionRole" { + name = "IdempotencyFunctionRole" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + Action = "sts:AssumeRole" + }, + ] + }) +} + +resource "aws_iam_policy" "LambdaDynamoDBPolicy" { + name = "LambdaDynamoDBPolicy" + description = "IAM policy for Lambda function to access DynamoDB" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowDynamodbReadWrite" + Effect = "Allow" + Action = [ + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + ] + Resource = aws_dynamodb_table.IdempotencyTable.arn + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "IdempotencyFunctionRoleAttachment" { + role = aws_iam_role.IdempotencyFunctionRole.name + policy_arn = aws_iam_policy.LambdaDynamoDBPolicy.arn +} \ No newline at end of file diff --git a/docs/snippets/idempotency/types.ts b/docs/snippets/idempotency/types.ts index 42d2cd63bd..e769dcf2dd 100644 --- a/docs/snippets/idempotency/types.ts +++ b/docs/snippets/idempotency/types.ts @@ -1,15 +1,29 @@ -type Request = { +import { IdempotencyRecordStatusValue } from '@aws-lambda-powertools/idempotency/types'; + +export type Request = { user: string; productId: string; }; -type Response = { +export type Response = { [key: string]: unknown; }; -type SubscriptionResult = { +export type SubscriptionResult = { id: string; productId: string; }; -export { Request, Response, SubscriptionResult }; +export type ApiSecret = { + apiKey: string; + refreshToken: string; + validUntil: number; + restEndpoint: string; +}; + +export type ProviderItem = { + validation?: string; + in_progress_expiration?: number; + status: IdempotencyRecordStatusValue; + data: string; +}; diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index c06f28a149..0bb45ac7b9 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -88,124 +88,22 @@ If you're not [changing the default configuration for the DynamoDB persistence l ???+ tip "Tip: You can share a single state table for all functions" You can reuse the same DynamoDB table to store idempotency state. We add the Lambda function name in addition to the idempotency key as a hash key. - -=== "AWS Serverless Application Model (SAM) example" +=== "AWS Cloud Development Kit (CDK) example" - ```yaml hl_lines="6-14 24-31" - Transform: AWS::Serverless-2016-10-31 - Resources: - IdempotencyTable: - Type: AWS::DynamoDB::Table - Properties: - AttributeDefinitions: - - AttributeName: id - AttributeType: S - KeySchema: - - AttributeName: id - KeyType: HASH - TimeToLiveSpecification: - AttributeName: expiration - Enabled: true - BillingMode: PAY_PER_REQUEST - - HelloWorldFunction: - Type: AWS::Serverless::Function - Properties: - Runtime: nodejs18.x - Handler: index.handler - Policies: - - Statement: - - Sid: AllowDynamodbReadWrite - Effect: Allow - Action: - - dynamodb:PutItem - - dynamodb:GetItem - - dynamodb:UpdateItem - - dynamodb:DeleteItem - Resource: !GetAtt IdempotencyTable.Arn + ```typescript title="template.tf" hl_lines="11-18 26" + --8<-- "docs/snippets/idempotency/templates/tableCdk.ts" ``` -=== "Terraform" - - ```terraform hl_lines="14-26 64-70" - terraform { - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 4.0" - } - } - } - - provider "aws" { - region = "us-east-1" # Replace with your desired AWS region - } - - resource "aws_dynamodb_table" "IdempotencyTable" { - name = "IdempotencyTable" - billing_mode = "PAY_PER_REQUEST" - hash_key = "id" - attribute { - name = "id" - type = "S" - } - ttl { - attribute_name = "expiration" - enabled = true - } - } - - resource "aws_lambda_function" "IdempotencyFunction" { - function_name = "IdempotencyFunction" - role = aws_iam_role.IdempotencyFunctionRole.arn - runtime = "nodejs18.x" - handler = "index.handler" - filename = "lambda.zip" - } +=== "AWS Serverless Application Model (SAM) example" - resource "aws_iam_role" "IdempotencyFunctionRole" { - name = "IdempotencyFunctionRole" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "" - Effect = "Allow" - Principal = { - Service = "lambda.amazonaws.com" - } - Action = "sts:AssumeRole" - }, - ] - }) - } + ```yaml title="template.yaml" hl_lines="6-14 24-31" + --8<-- "docs/snippets/idempotency/templates/tableSam.yaml" + ``` - resource "aws_iam_policy" "LambdaDynamoDBPolicy" { - name = "LambdaDynamoDBPolicy" - description = "IAM policy for Lambda function to access DynamoDB" - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "AllowDynamodbReadWrite" - Effect = "Allow" - Action = [ - "dynamodb:PutItem", - "dynamodb:GetItem", - "dynamodb:UpdateItem", - "dynamodb:DeleteItem", - ] - Resource = aws_dynamodb_table.IdempotencyTable.arn - }, - ] - }) - } +=== "Terraform example" - resource "aws_iam_role_policy_attachment" "IdempotencyFunctionRoleAttachment" { - role = aws_iam_role.IdempotencyFunctionRole.name - policy_arn = aws_iam_policy.LambdaDynamoDBPolicy.arn - } + ```terraform title="template.tf" hl_lines="14-26 64-70" + --8<-- "docs/snippets/idempotency/templates/tableTerraform.tf" ``` ???+ warning "Warning: Large responses with DynamoDB persistence layer" @@ -232,7 +130,7 @@ You can quickly start by initializing the `DynamoDBPersistenceLayer` class and u === "types.ts" ```typescript - --8<-- "docs/snippets/idempotency/types.ts::13" + --8<-- "docs/snippets/idempotency/types.ts:3:16" ``` After processing this request successfully, a second request containing the exact same payload above will now return the same response, ensuring our customer isn't charged twice. @@ -260,7 +158,7 @@ When using `makeIdempotent` on arbitrary functions, you can tell us which argume === "types.ts" ```typescript - --8<-- "docs/snippets/idempotency/types.ts::13" + --8<-- "docs/snippets/idempotency/types.ts:3:16" ``` 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. @@ -283,7 +181,7 @@ If you are using [Middy](https://middy.js.org){target="_blank"} as your middlewa === "types.ts" ```typescript - --8<-- "docs/snippets/idempotency/types.ts::13" + --8<-- "docs/snippets/idempotency/types.ts:3:16" ``` ### Choosing a payload subset for idempotency @@ -310,42 +208,13 @@ Imagine the function executes successfully, but the client never receives the re === "Example event" ```json hl_lines="28" - { - "version":"2.0", - "routeKey":"ANY /createpayment", - "rawPath":"/createpayment", - "rawQueryString":"", - "headers": { - "Header1": "value1", - "X-Idempotency-Key": "abcdefg" - }, - "requestContext":{ - "accountId":"123456789012", - "apiId":"api-id", - "domainName":"id.execute-api.us-east-1.amazonaws.com", - "domainPrefix":"id", - "http":{ - "method":"POST", - "path":"/createpayment", - "protocol":"HTTP/1.1", - "sourceIp":"ip", - "userAgent":"agent" - }, - "requestId":"id", - "routeKey":"ANY /createpayment", - "stage":"$default", - "time":"10/Feb/2021:13:40:43 +0000", - "timeEpoch":1612964443723 - }, - "body":"{\"user\":\"xyz\",\"productId\":\"123456789\"}", - "isBase64Encoded":false - } + --8<-- "docs/snippets/idempotency/samples/makeIdempotentJmes.json" ``` === "types.ts" ```typescript - --8<-- "docs/snippets/idempotency/types.ts::13" + --8<-- "docs/snippets/idempotency/types.ts:3:16" ``` ### Lambda timeouts @@ -747,25 +616,13 @@ This means that we will raise **`IdempotencyKeyError`** if the evaluation of **` === "Success Event" ```json hl_lines="3 6" - { - "user": { - "uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1", - "name": "Foo" - }, - "productId": 10000 - } + --8<-- "docs/snippets/idempotency/samples/workingWIthIdempotencyRequiredKeySuccess.json" ``` === "Failure Event" ```json hl_lines="3 5" - { - "user": { - "uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1", - "name": "foo", - "productId": 10000 - } - } + --8<-- "docs/snippets/idempotency/samples/workingWIthIdempotencyRequiredKeyError.json" ``` ### Batch integration @@ -788,32 +645,7 @@ This ensures that you process each record in an idempotent manner, and guard aga === "Sample event" ```json hl_lines="4" - { - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "Test message.", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDAIENQZJOLO23YVJ4VO", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "testAttr": { - "stringValue": "100", - "binaryValue": "base64Str", - "dataType": "Number" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", - "awsRegion": "us-east-2" - } - ] - } + --8<-- "docs/snippets/idempotency/samples/workingWithBatch.json" ``` ### Customizing AWS SDK configuration @@ -854,6 +686,42 @@ The example function above would cause data to be stored in DynamoDB like this: | idempotency#MyLambdaFunction | 2b2cdb5f86361e97b4383087c1ffdf27 | 1636549571 | COMPLETED | {"paymentId": "527212", "message": "success", "statusCode": 200} | | idempotency#MyLambdaFunction | f091d2527ad1c78f05d54cc3f363be80 | 1636549585 | IN_PROGRESS | | +### Bring your own persistent store + +This utility provides an abstract base class (ABC), so that you can implement your choice of persistent storage layer. + +You can create your own persistent store from scratch by inheriting the `BasePersistenceLayer` class, and implementing `_getRecord()`, `_putRecord()`, `_updateRecord()` and `_deleteRecord()`. + +* `_getRecord()` – Retrieves an item from the persistence store using an idempotency key and returns it as a `IdempotencyRecord` instance. +* `_putRecord()` – Adds a `IdempotencyRecord` to the persistence store if it doesn't already exist with that key. Throws an `IdempotencyItemAlreadyExistsError` error if a non-expired entry already exists. +* `_updateRecord()` – Updates an item in the persistence store. +* `_deleteRecord()` – Removes an item from the persistence store. + +Below an example implementation of a custom persistence layer backed by a generic key-value store. + +=== "CustomPersistenceLayer" + + ```typescript hl_lines="9 19 28 34 50 90" + --8<-- "docs/snippets/idempotency/advancedBringYourOwnPersistenceLayer.ts" + ``` + +=== "index.ts" + + ```typescript hl_lines="10" + --8<-- "docs/snippets/idempotency/advancedBringYourOwnPersistenceLayerUsage.ts" + ``` + +=== "types.ts" + + ```typescript + --8<-- "docs/snippets/idempotency/types.ts" + ``` + +???+ danger + Pay attention to the documentation for each - you may need to perform additional checks inside these methods to ensure the idempotency guarantees remain intact. + + For example, the `_putRecord()` method needs to throw an error if a non-expired record already exists in the data store with a matching key. + ## Extra resources If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out diff --git a/package-lock.json b/package-lock.json index 1e9966a1b5..d3cbbe8aa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5936,6 +5936,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, "engines": { "node": ">= 4.0.0" } @@ -6728,7 +6729,8 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -6834,6 +6836,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7497,7 +7500,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, "node_modules/concat-stream": { "version": "2.0.0", @@ -9517,6 +9521,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -10007,7 +10012,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/graphemer": { "version": "1.4.0", @@ -10273,6 +10279,7 @@ "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, "engines": { "node": ">= 4" } @@ -11740,6 +11747,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -13484,6 +13492,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -15456,6 +15465,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, "engines": { "node": ">=6" } @@ -17486,6 +17496,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, "engines": { "node": ">= 10.0.0" } @@ -17936,6 +17947,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, "engines": { "node": ">= 6" }