diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/SnsToUrlStack.assets.json b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/SnsToUrlStack.assets.json new file mode 100644 index 0000000000000..156438fef8333 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/SnsToUrlStack.assets.json @@ -0,0 +1,19 @@ +{ + "version": "36.0.0", + "files": { + "8a2787919c117033d0683a025f8e14a095ddca0e52f77112498af03dd6bd96ec": { + "source": { + "path": "SnsToUrlStack.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "8a2787919c117033d0683a025f8e14a095ddca0e52f77112498af03dd6bd96ec.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/SnsToUrlStack.template.json b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/SnsToUrlStack.template.json new file mode 100644 index 0000000000000..56c33a78db24a --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/SnsToUrlStack.template.json @@ -0,0 +1,64 @@ +{ + "Resources": { + "MyTopic86869434": { + "Type": "AWS::SNS::Topic" + }, + "MyTopichttpsfoobarcomDEA92AB5": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "DeliveryPolicy": { + "healthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 21, + "numRetries": 10 + }, + "throttlePolicy": { + "maxReceivesPerSecond": 10 + }, + "requestPolicy": { + "headerContentType": "application/json" + } + }, + "Endpoint": "https://foobar.com/", + "Protocol": "https", + "TopicArn": { + "Ref": "MyTopic86869434" + } + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdk.out b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdk.out new file mode 100644 index 0000000000000..1f0068d32659a --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"36.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdkintegDefaultTestDeployAssert83AD650C.assets.json b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdkintegDefaultTestDeployAssert83AD650C.assets.json new file mode 100644 index 0000000000000..59e2cd2727a59 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdkintegDefaultTestDeployAssert83AD650C.assets.json @@ -0,0 +1,19 @@ +{ + "version": "36.0.0", + "files": { + "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": { + "source": { + "path": "cdkintegDefaultTestDeployAssert83AD650C.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdkintegDefaultTestDeployAssert83AD650C.template.json b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdkintegDefaultTestDeployAssert83AD650C.template.json new file mode 100644 index 0000000000000..ad9d0fb73d1dd --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/cdkintegDefaultTestDeployAssert83AD650C.template.json @@ -0,0 +1,36 @@ +{ + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/integ.json b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/integ.json new file mode 100644 index 0000000000000..cebe64cd92ad3 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/integ.json @@ -0,0 +1,12 @@ +{ + "version": "36.0.0", + "testCases": { + "cdk-integ/DefaultTest": { + "stacks": [ + "SnsToUrlStack" + ], + "assertionStack": "cdk-integ/DefaultTest/DeployAssert", + "assertionStackName": "cdkintegDefaultTestDeployAssert83AD650C" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/manifest.json b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/manifest.json new file mode 100644 index 0000000000000..172b65b13adae --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/manifest.json @@ -0,0 +1,119 @@ +{ + "version": "36.0.0", + "artifacts": { + "SnsToUrlStack.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "SnsToUrlStack.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "SnsToUrlStack": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "SnsToUrlStack.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/8a2787919c117033d0683a025f8e14a095ddca0e52f77112498af03dd6bd96ec.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "SnsToUrlStack.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "SnsToUrlStack.assets" + ], + "metadata": { + "/SnsToUrlStack/MyTopic/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "MyTopic86869434" + } + ], + "/SnsToUrlStack/MyTopic/https:----foobar.com--/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "MyTopichttpsfoobarcomDEA92AB5" + } + ], + "/SnsToUrlStack/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/SnsToUrlStack/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "SnsToUrlStack" + }, + "cdkintegDefaultTestDeployAssert83AD650C.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "cdkintegDefaultTestDeployAssert83AD650C.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "cdkintegDefaultTestDeployAssert83AD650C": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "cdkintegDefaultTestDeployAssert83AD650C.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "cdkintegDefaultTestDeployAssert83AD650C.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "cdkintegDefaultTestDeployAssert83AD650C.assets" + ], + "metadata": { + "/cdk-integ/DefaultTest/DeployAssert/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/cdk-integ/DefaultTest/DeployAssert/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "cdk-integ/DefaultTest/DeployAssert" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/tree.json b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/tree.json new file mode 100644 index 0000000000000..bcb578d89a04f --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.js.snapshot/tree.json @@ -0,0 +1,164 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "SnsToUrlStack": { + "id": "SnsToUrlStack", + "path": "SnsToUrlStack", + "children": { + "MyTopic": { + "id": "MyTopic", + "path": "SnsToUrlStack/MyTopic", + "children": { + "Resource": { + "id": "Resource", + "path": "SnsToUrlStack/MyTopic/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::SNS::Topic", + "aws:cdk:cloudformation:props": {} + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "https:----foobar.com--": { + "id": "https:----foobar.com--", + "path": "SnsToUrlStack/MyTopic/https:----foobar.com--", + "children": { + "Resource": { + "id": "Resource", + "path": "SnsToUrlStack/MyTopic/https:----foobar.com--/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::SNS::Subscription", + "aws:cdk:cloudformation:props": { + "deliveryPolicy": { + "healthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 21, + "numRetries": 10 + }, + "throttlePolicy": { + "maxReceivesPerSecond": 10 + }, + "requestPolicy": { + "headerContentType": "application/json" + } + }, + "endpoint": "https://foobar.com/", + "protocol": "https", + "topicArn": { + "Ref": "MyTopic86869434" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "SnsToUrlStack/BootstrapVersion", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "SnsToUrlStack/CheckBootstrapVersion", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "cdk-integ": { + "id": "cdk-integ", + "path": "cdk-integ", + "children": { + "DefaultTest": { + "id": "DefaultTest", + "path": "cdk-integ/DefaultTest", + "children": { + "Default": { + "id": "Default", + "path": "cdk-integ/DefaultTest/Default", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "DeployAssert": { + "id": "DeployAssert", + "path": "cdk-integ/DefaultTest/DeployAssert", + "children": { + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "cdk-integ/DefaultTest/DeployAssert/BootstrapVersion", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "cdk-integ/DefaultTest/DeployAssert/CheckBootstrapVersion", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.ts b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.ts new file mode 100644 index 0000000000000..18f602207f2e8 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-sns-subscriptions/test/integ.sns-url.ts @@ -0,0 +1,40 @@ +import * as sns from 'aws-cdk-lib/aws-sns'; +import * as cdk from 'aws-cdk-lib'; +import * as subs from 'aws-cdk-lib/aws-sns-subscriptions'; +import * as integ from '@aws-cdk/integ-tests-alpha'; + +class SnsToUrlStack extends cdk.Stack { + topic: sns.Topic; + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + this.topic = new sns.Topic(this, 'MyTopic'); + + this.topic.addSubscription( + new subs.UrlSubscription( + 'https://foobar.com/', + { + deliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(20), + maxDelayTarget: cdk.Duration.seconds(21), + numRetries: 10, + }, + throttlePolicy: { + maxReceivesPerSecond: 10, + }, + requestPolicy: { + headerContentType: 'application/json', + }, + }, + }, + ), + ); + } +} + +const app = new cdk.App(); +const stack = new SnsToUrlStack(app, 'SnsToUrlStack'); + +new integ.IntegTest(app, 'cdk-integ', { + testCases: [stack], +}); diff --git a/packages/aws-cdk-lib/aws-sns-subscriptions/README.md b/packages/aws-cdk-lib/aws-sns-subscriptions/README.md index c7ab5ea003638..15ebca379d55d 100644 --- a/packages/aws-cdk-lib/aws-sns-subscriptions/README.md +++ b/packages/aws-cdk-lib/aws-sns-subscriptions/README.md @@ -44,6 +44,35 @@ const url = new CfnParameter(this, 'url-param'); myTopic.addSubscription(new subscriptions.UrlSubscription(url.valueAsString)); ``` +The [delivery policy](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html) can also be set like so: + +```ts +const myTopic = new sns.Topic(this, 'MyTopic'); + +myTopic.addSubscription( + new subscriptions.UrlSubscription( + 'https://foobar.com/', + { + deliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: Duration.seconds(5), + maxDelayTarget: Duration.seconds(10), + numRetries: 6, + backoffFunction: sns.BackoffFunction.EXPONENTIAL, + }, + throttlePolicy: { + maxReceivesPerSecond: 10, + }, + requestPolicy: { + headerContentType: 'application/json', + }, + }, + }, + ), +); +``` + + ### Amazon SQS Subscribe a queue to your topic: diff --git a/packages/aws-cdk-lib/aws-sns-subscriptions/lib/url.ts b/packages/aws-cdk-lib/aws-sns-subscriptions/lib/url.ts index 3eb582c1a8b7a..59bb26724f716 100644 --- a/packages/aws-cdk-lib/aws-sns-subscriptions/lib/url.ts +++ b/packages/aws-cdk-lib/aws-sns-subscriptions/lib/url.ts @@ -21,6 +21,13 @@ export interface UrlSubscriptionProps extends SubscriptionProps { * @default - Protocol is derived from url */ readonly protocol?: sns.SubscriptionProtocol; + + /** + * The delivery policy. + * + * @default - if the initial delivery of the message fails, three retries with a delay between failed attempts set at 20 seconds + */ + readonly deliveryPolicy?: sns.DeliveryPolicy; } /** @@ -63,6 +70,7 @@ export class UrlSubscription implements sns.ITopicSubscription { filterPolicy: this.props.filterPolicy, filterPolicyWithMessageBody: this.props.filterPolicyWithMessageBody, deadLetterQueue: this.props.deadLetterQueue, + deliveryPolicy: this.props.deliveryPolicy, }; } } diff --git a/packages/aws-cdk-lib/aws-sns/lib/delivery-policy.ts b/packages/aws-cdk-lib/aws-sns/lib/delivery-policy.ts new file mode 100644 index 0000000000000..cecc4b51b194c --- /dev/null +++ b/packages/aws-cdk-lib/aws-sns/lib/delivery-policy.ts @@ -0,0 +1,127 @@ +import { Duration } from '../../core'; + +/** + * Algorithms which can be used by SNS to calculate the delays associated with all of the retry attempts between the first and last retries in the backoff phase. + */ +export enum BackoffFunction { + /** + * Arithmetic, see {@link https://docs.aws.amazon.com/images/sns/latest/dg/images/backoff-graph.png|this image} for how this function compares to others + */ + ARITHMETIC = 'ARITHMETIC', + /** + * Exponential, see {@link https://docs.aws.amazon.com/images/sns/latest/dg/images/backoff-graph.png|this image} for how this function compares to others + */ + EXPONENTIAL = 'EXPONENTIAL', + /** + * Geometric, see {@link https://docs.aws.amazon.com/images/sns/latest/dg/images/backoff-graph.png|this image} for how this function compares to others + */ + GEOMETRIC = 'GEOMETRIC', + /** + * Linear, see {@link https://docs.aws.amazon.com/images/sns/latest/dg/images/backoff-graph.png|this image} for how this function compares to others + */ + LINEAR = 'LINEAR', +} + +/** + * Options for customising AWS SNS HTTP/S delivery throttling. + */ +export interface ThrottlePolicy { + /** + * The maximum number of deliveries per second, per subscription. + * + * @default - no throttling + */ + readonly maxReceivesPerSecond?: number; +} + +/** + * Options for customising aspects of the content sent in AWS SNS HTTP/S requests. + */ +export interface RequestPolicy { + /** + * The content type of the notification being sent to HTTP/S endpoints. + * + * @default - text/plain; charset=UTF-8 + */ + readonly headerContentType?: string; +} + +/** + * Options for customising the retry policy of the delivery of SNS messages to HTTP/S endpoints. + */ +export interface HealthyRetryPolicy { + /** + * The minimum delay for a retry. Must be at least one second, not exceed `maxDelayTarget`, and correspond to a whole number of seconds. + * + * @default - 20 seconds + */ + readonly minDelayTarget?: Duration; + /** + * The maximum delay for a retry. Must be at least `minDelayTarget` less than 3,600 seconds, and correspond to a whole number of seconds, + * + * @default - 20 seconds + */ + readonly maxDelayTarget?: Duration; + + /** + * The total number of retries, including immediate, pre-backoff, backoff, and post-backoff retries. Must be greater than or equal to zero and not exceed 100. + * + * @default 3 + */ + readonly numRetries?: number; + + /** + * The number of retries to be done immediately, with no delay between them. Must be zero or greater. + * + * @default 0 + */ + readonly numNoDelayRetries?: number; + + /** + * The number of retries in the pre-backoff phase, with the specified minimum delay between them. Must be zero or greater + * + * @default 0 + */ + readonly numMinDelayRetries?: number; + + /** + * The number of retries in the post-backoff phase, with the maximum delay between them. Must be zero or greater + * + * @default 0 + */ + readonly numMaxDelayRetries?: number; + + /** + * The model for backoff between retries. + * + * @default - linear + */ + readonly backoffFunction?: BackoffFunction; +} + +/** + * Options for customising the delivery of SNS messages to HTTP/S endpoints. + */ +export interface DeliveryPolicy { + + /** + * The retry policy of the delivery of SNS messages to HTTP/S endpoints. + * + * @default - Amazon SNS attempts up to three retries with a delay between failed attempts set at 20 seconds + */ + readonly healthyRetryPolicy?: HealthyRetryPolicy; + + /** + * The throttling policy of the delivery of SNS messages to HTTP/S endpoints. + * + * @default - No throttling + */ + readonly throttlePolicy?: ThrottlePolicy; + + /** + * The request of the content sent in AWS SNS HTTP/S requests. + * + * @default - The content type is set to 'text/plain; charset=UTF-8' + */ + readonly requestPolicy?: RequestPolicy; +} diff --git a/packages/aws-cdk-lib/aws-sns/lib/index.ts b/packages/aws-cdk-lib/aws-sns/lib/index.ts index cdcb67084921e..2ea429718b107 100644 --- a/packages/aws-cdk-lib/aws-sns/lib/index.ts +++ b/packages/aws-cdk-lib/aws-sns/lib/index.ts @@ -4,6 +4,7 @@ export * from './topic-base'; export * from './subscription'; export * from './subscriber'; export * from './subscription-filter'; +export * from './delivery-policy'; // AWS::SNS CloudFormation Resources: export * from './sns.generated'; diff --git a/packages/aws-cdk-lib/aws-sns/lib/subscription.ts b/packages/aws-cdk-lib/aws-sns/lib/subscription.ts index d03b1d942df28..2b6cc1c073bc7 100644 --- a/packages/aws-cdk-lib/aws-sns/lib/subscription.ts +++ b/packages/aws-cdk-lib/aws-sns/lib/subscription.ts @@ -1,4 +1,5 @@ import { Construct } from 'constructs'; +import { DeliveryPolicy } from './delivery-policy'; import { CfnSubscription } from './sns.generated'; import { SubscriptionFilter } from './subscription-filter'; import { ITopic } from './topic-base'; @@ -67,6 +68,13 @@ export interface SubscriptionOptions { * @default - No subscription role is provided */ readonly subscriptionRoleArn?: string; + + /** + * The delivery policy. + * + * @default - if the initial delivery of the message fails, three retries with a delay between failed attempts set at 20 seconds + */ + readonly deliveryPolicy?: DeliveryPolicy; } /** * Properties for creating a new subscription @@ -152,10 +160,85 @@ export class Subscription extends Resource { region: props.region, redrivePolicy: this.buildDeadLetterConfig(this.deadLetterQueue), subscriptionRoleArn: props.subscriptionRoleArn, + deliveryPolicy: props.deliveryPolicy ? this.renderDeliveryPolicy(props.deliveryPolicy, props.protocol): undefined, }); } + private renderDeliveryPolicy(deliveryPolicy: DeliveryPolicy, protocol: SubscriptionProtocol): any { + if (![SubscriptionProtocol.HTTP, SubscriptionProtocol.HTTPS].includes(protocol)) { + throw new Error(`Delivery policy is only supported for HTTP and HTTPS subscriptions, got: ${protocol}`); + } + const { healthyRetryPolicy, throttlePolicy } = deliveryPolicy; + if (healthyRetryPolicy) { + const delayTargetLimitSecs = 3600; + const minDelayTarget = healthyRetryPolicy.minDelayTarget; + const maxDelayTarget = healthyRetryPolicy.maxDelayTarget; + if (minDelayTarget !== undefined) { + if (minDelayTarget.toMilliseconds() % 1000 !== 0) { + throw new Error(`minDelayTarget must be a whole number of seconds, got: ${minDelayTarget}`); + } + const minDelayTargetSecs = minDelayTarget.toSeconds(); + if (minDelayTargetSecs < 1 || minDelayTargetSecs > delayTargetLimitSecs) { + throw new Error(`minDelayTarget must be between 1 and ${delayTargetLimitSecs} seconds inclusive, got: ${minDelayTargetSecs}s`); + } + } + if (maxDelayTarget !== undefined) { + if (maxDelayTarget.toMilliseconds() % 1000 !== 0) { + throw new Error(`maxDelayTarget must be a whole number of seconds, got: ${maxDelayTarget}`); + } + const maxDelayTargetSecs = maxDelayTarget.toSeconds(); + if (maxDelayTargetSecs < 1 || maxDelayTargetSecs > delayTargetLimitSecs) { + throw new Error(`maxDelayTarget must be between 1 and ${delayTargetLimitSecs} seconds inclusive, got: ${maxDelayTargetSecs}s`); + } + if ((minDelayTarget !== undefined) && minDelayTarget.toSeconds() > maxDelayTargetSecs) { + throw new Error('minDelayTarget must not exceed maxDelayTarget'); + } + } + + const numRetriesLimit = 100; + if (healthyRetryPolicy.numRetries && (healthyRetryPolicy.numRetries < 0 || healthyRetryPolicy.numRetries > numRetriesLimit)) { + throw new Error(`numRetries must be between 0 and ${numRetriesLimit} inclusive, got: ${healthyRetryPolicy.numRetries}`); + } + const { numNoDelayRetries, numMinDelayRetries, numMaxDelayRetries } = healthyRetryPolicy; + if (numNoDelayRetries && (numNoDelayRetries < 0 || !Number.isInteger(numNoDelayRetries))) { + throw new Error(`numNoDelayRetries must be an integer zero or greater, got: ${numNoDelayRetries}`); + } + if (numMinDelayRetries && (numMinDelayRetries < 0 || !Number.isInteger(numMinDelayRetries))) { + throw new Error(`numMinDelayRetries must be an integer zero or greater, got: ${numMinDelayRetries}`); + } + if (numMaxDelayRetries && (numMaxDelayRetries < 0 || !Number.isInteger(numMaxDelayRetries))) { + throw new Error(`numMaxDelayRetries must be an integer zero or greater, got: ${numMaxDelayRetries}`); + } + } + if (throttlePolicy) { + const maxReceivesPerSecond = throttlePolicy.maxReceivesPerSecond; + if (maxReceivesPerSecond !== undefined && (maxReceivesPerSecond < 1 || !Number.isInteger(maxReceivesPerSecond))) { + throw new Error(`maxReceivesPerSecond must be an integer greater than zero, got: ${maxReceivesPerSecond}`); + } + } + return { + healthyRetryPolicy: healthyRetryPolicy ? { + // minDelayTarget, maxDelayTarget and numRetries are (empirically) mandatory when healthyRetryPolicy is specified, + // but for user-friendliness we allow them to be undefined and set them here instead. + // The defaults we use here are the same used in the event healthyRetryPolicy is not specified, see https://docs.aws.amazon.com/sns/latest/dg/creating-delivery-policy.html. + minDelayTarget: (healthyRetryPolicy.minDelayTarget === undefined) ? 20 : healthyRetryPolicy.minDelayTarget.toSeconds(), + maxDelayTarget: (healthyRetryPolicy.maxDelayTarget === undefined) ? 20 : healthyRetryPolicy.maxDelayTarget.toSeconds(), + numRetries: (healthyRetryPolicy.numRetries === undefined) ? 3: healthyRetryPolicy.numRetries, + numNoDelayRetries: healthyRetryPolicy.numNoDelayRetries, + numMinDelayRetries: healthyRetryPolicy.numMinDelayRetries, + numMaxDelayRetries: healthyRetryPolicy.numMaxDelayRetries, + backoffFunction: healthyRetryPolicy.backoffFunction, + }: undefined, + throttlePolicy: deliveryPolicy.throttlePolicy ? { + maxReceivesPerSecond: deliveryPolicy.throttlePolicy.maxReceivesPerSecond, + }: undefined, + requestPolicy: deliveryPolicy.requestPolicy ? { + headerContentType: deliveryPolicy.requestPolicy.headerContentType, + }: undefined, + }; + } + private buildDeadLetterQueue(props: SubscriptionProps) { if (!props.deadLetterQueue) { return undefined; diff --git a/packages/aws-cdk-lib/aws-sns/test/subscription.test.ts b/packages/aws-cdk-lib/aws-sns/test/subscription.test.ts index 193f530b27704..78facdd63dc11 100644 --- a/packages/aws-cdk-lib/aws-sns/test/subscription.test.ts +++ b/packages/aws-cdk-lib/aws-sns/test/subscription.test.ts @@ -264,6 +264,81 @@ describe('Subscription', () => { }); + test('with delivery policy', () => { + // GIVEN + const stack = new cdk.Stack(); + const topic = new sns.Topic(stack, 'Topic'); + + // WHEN + new sns.Subscription(stack, 'Subscription', { + endpoint: 'endpoint', + deliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(5), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + backoffFunction: sns.BackoffFunction.EXPONENTIAL, + }, + throttlePolicy: { + maxReceivesPerSecond: 10, + }, + requestPolicy: { + headerContentType: 'application/json', + }, + }, + protocol: sns.SubscriptionProtocol.HTTPS, + topic, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::SNS::Subscription', { + DeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: 5, + maxDelayTarget: 10, + numRetries: 6, + backoffFunction: sns.BackoffFunction.EXPONENTIAL, + }, + throttlePolicy: { + maxReceivesPerSecond: 10, + }, + requestPolicy: { + headerContentType: 'application/json', + }, + }, + }); + }); + + test('sets correct healthyRetryPolicy defaults for attributes required by Cloudformation', () => { + // GIVEN + const stack = new cdk.Stack(); + const topic = new sns.Topic(stack, 'Topic'); + + // WHEN + new sns.Subscription(stack, 'Subscription', { + endpoint: 'endpoint', + deliveryPolicy: { + healthyRetryPolicy: { + backoffFunction: sns.BackoffFunction.EXPONENTIAL, + }, + }, + protocol: sns.SubscriptionProtocol.HTTPS, + topic, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::SNS::Subscription', { + DeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: 20, + maxDelayTarget: 20, + numRetries: 3, + backoffFunction: sns.BackoffFunction.EXPONENTIAL, + }, + }, + }); + }); + test.each( [ SubscriptionProtocol.LAMBDA, @@ -357,4 +432,238 @@ describe('Subscription', () => { topic, })).toThrow(/Subscription role arn is required field for subscriptions with a firehose protocol./); }); + + test.each([ + sns.SubscriptionProtocol.APPLICATION, + sns.SubscriptionProtocol.EMAIL, + sns.SubscriptionProtocol.EMAIL_JSON, + sns.SubscriptionProtocol.FIREHOSE, + sns.SubscriptionProtocol.LAMBDA, + sns.SubscriptionProtocol.SMS, + sns.SubscriptionProtocol.SQS, + ])('throws an error when deliveryPolicy is specified with protocol %s', (protocol) => { + // GIVEN + const stack = new cdk.Stack(); + const topic = new sns.Topic(stack, 'Topic'); + + //THEN + expect(() => new sns.Subscription(stack, 'Subscription', { + endpoint: 'endpoint', + deliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(11), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + }, + }, + protocol: protocol, + subscriptionRoleArn: '???', + topic, + })).toThrow(new RegExp(`Delivery policy is only supported for HTTP and HTTPS subscriptions, got: ${protocol}`)); + }); + + test('throws an error when deliveryPolicy minDelayTarget exceeds maxDelayTarget', () => { + // GIVEN + const stack = new cdk.Stack(); + const topic = new sns.Topic(stack, 'Topic'); + + //THEN + expect(() => new sns.Subscription(stack, 'Subscription', { + endpoint: 'endpoint', + deliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(11), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + }, + }, + protocol: sns.SubscriptionProtocol.HTTPS, + topic, + })).toThrow(/minDelayTarget must not exceed maxDelayTarget/); + }); + + const delayTestCases = [ + { + prop: 'minDelayTarget', + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(0), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + }, + }, + }, + { + prop: 'maxDelayTarget', + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(10), + maxDelayTarget: cdk.Duration.seconds(0), + numRetries: 6, + }, + }, + }, + { + prop: 'minDelayTarget', + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(3601), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + }, + }, + }, + { + prop: 'maxDelayTarget', + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(10), + maxDelayTarget: cdk.Duration.seconds(3601), + numRetries: 6, + }, + }, + }, + ]; + + delayTestCases.forEach(({ prop, invalidDeliveryPolicy }) => { + const invalidValue = invalidDeliveryPolicy.healthyRetryPolicy[prop]; + test(`throws an error when ${prop} is ${invalidValue}`, () => { + // GIVEN + const stack = new cdk.Stack(); + const topic = new sns.Topic(stack, 'Topic'); + + //THEN + expect(() => new sns.Subscription(stack, 'Subscription', { + endpoint: 'endpoint', + deliveryPolicy: invalidDeliveryPolicy, + protocol: sns.SubscriptionProtocol.HTTPS, + topic, + })).toThrow(new RegExp(`${prop} must be between 1 and 3600 seconds inclusive`)); + }); + }); + + test.each([-1, 101])('throws an error when deliveryPolicy numRetries is %d', (invalidValue: number) => { + // GIVEN + const stack = new cdk.Stack(); + const topic = new sns.Topic(stack, 'Topic'); + + //THEN + expect(() => new sns.Subscription(stack, 'Subscription', { + endpoint: 'endpoint', + deliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(10), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: invalidValue, + }, + }, + protocol: sns.SubscriptionProtocol.HTTPS, + topic, + })).toThrow(/numRetries must be between 0 and 100 inclusive/); + }); + + test.each([ + { + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(1), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + numNoDelayRetries: -1, + }, + }, + prop: 'numNoDelayRetries', + value: -1, + }, + { + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(1), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + numMinDelayRetries: -1, + }, + }, + prop: 'numMinDelayRetries', + value: -1, + }, + { + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(1), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + numMaxDelayRetries: -1, + }, + }, + prop: 'numMaxDelayRetries', + value: -1, + }, + { + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(1), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + numNoDelayRetries: 1.5, + }, + }, + prop: 'numNoDelayRetries', + value: 1.5, + }, + { + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(1), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + numMinDelayRetries: 1.5, + }, + }, + prop: 'numMinDelayRetries', + value: 1.5, + }, + { + invalidDeliveryPolicy: { + healthyRetryPolicy: { + minDelayTarget: cdk.Duration.seconds(1), + maxDelayTarget: cdk.Duration.seconds(10), + numRetries: 6, + numMaxDelayRetries: 1.5, + }, + }, + prop: 'numMaxDelayRetries', + value: 1.5, + }, + ])('throws an error when $prop = $value', ({ invalidDeliveryPolicy, prop }) => { + // GIVEN + const stack = new cdk.Stack(); + const topic = new sns.Topic(stack, 'Topic'); + + //THEN + expect(() => new sns.Subscription(stack, 'Subscription', { + endpoint: 'endpoint', + deliveryPolicy: invalidDeliveryPolicy, + protocol: sns.SubscriptionProtocol.HTTPS, + topic, + })).toThrow(new RegExp(`${prop} must be an integer zero or greater`)); + }); + + test('throws an error when throttlePolicy < 1', () => { + // GIVEN + const stack = new cdk.Stack(); + const topic = new sns.Topic(stack, 'Topic'); + + //THEN + expect(() => new sns.Subscription(stack, 'Subscription', { + endpoint: 'endpoint', + deliveryPolicy: { + throttlePolicy: { + maxReceivesPerSecond: 0, + }, + }, + protocol: sns.SubscriptionProtocol.HTTPS, + topic, + })).toThrow(/maxReceivesPerSecond must be an integer greater than zero/); + }); });