Skip to content

feat(ecs): throw ValidationErrors instead of untyped errors #34427

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 2 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 0 additions & 2 deletions packages/aws-cdk-lib/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ baseConfig.rules['import/no-extraneous-dependencies'] = [
baseConfig.rules['@cdklabs/no-throw-default-error'] = ['error'];
// not yet supported
const noThrowDefaultErrorNotYetSupported = [
'aws-ecs-patterns',
'aws-ecs',
'aws-globalaccelerator',
'aws-globalaccelerator-endpoints',
'aws-iam',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import {
import { IRole } from '../../../aws-iam';
import { ARecord, IHostedZone, RecordTarget, CnameRecord } from '../../../aws-route53';
import { LoadBalancerTarget } from '../../../aws-route53-targets';
import * as cdk from '../../../core';
import { Duration } from '../../../core';
import { CfnOutput, Duration, Stack, Token, ValidationError } from '../../../core';

/**
* Describes the type of DNS record the service should create
Expand Down Expand Up @@ -150,7 +149,7 @@ export interface ApplicationLoadBalancedServiceBaseProps {
*
* @default - defaults to 60 seconds if at least one load balancer is in-use and it is not already set
*/
readonly healthCheckGracePeriod?: cdk.Duration;
readonly healthCheckGracePeriod?: Duration;

/**
* The maximum number of tasks, specified as a percentage of the Amazon ECS
Expand Down Expand Up @@ -423,7 +422,7 @@ export abstract class ApplicationLoadBalancedServiceBase extends Construct {
*/
public get loadBalancer(): ApplicationLoadBalancer {
if (!this._applicationLoadBalancer) {
throw new Error('.loadBalancer can only be accessed if the class was constructed with an owned, not imported, load balancer');
throw new ValidationError('.loadBalancer can only be accessed if the class was constructed with an owned, not imported, load balancer', this);
}
return this._applicationLoadBalancer;
}
Expand Down Expand Up @@ -462,12 +461,12 @@ export abstract class ApplicationLoadBalancedServiceBase extends Construct {
super(scope, id);

if (props.cluster && props.vpc) {
throw new Error('You can only specify either vpc or cluster. Alternatively, you can leave both blank');
throw new ValidationError('You can only specify either vpc or cluster. Alternatively, you can leave both blank', this);
}
this.cluster = props.cluster || this.getDefaultCluster(this, props.vpc);

if (props.desiredCount !== undefined && !cdk.Token.isUnresolved(props.desiredCount) && props.desiredCount < 1) {
throw new Error('You must specify a desiredCount greater than 0');
if (props.desiredCount !== undefined && !Token.isUnresolved(props.desiredCount) && props.desiredCount < 1) {
throw new ValidationError('You must specify a desiredCount greater than 0', this);
}

this.desiredCount = props.desiredCount || 1;
Expand All @@ -478,7 +477,7 @@ export abstract class ApplicationLoadBalancedServiceBase extends Construct {
if (props.idleTimeout) {
const idleTimeout = props.idleTimeout.toSeconds();
if (idleTimeout > Duration.seconds(4000).toSeconds() || idleTimeout < Duration.seconds(1).toSeconds()) {
throw new Error('Load balancer idle timeout must be between 1 and 4000 seconds.');
throw new ValidationError('Load balancer idle timeout must be between 1 and 4000 seconds.', this);
}
}

Expand All @@ -493,12 +492,12 @@ export abstract class ApplicationLoadBalancedServiceBase extends Construct {
const loadBalancer = props.loadBalancer ?? new ApplicationLoadBalancer(this, 'LB', lbProps);

if (props.certificate !== undefined && props.protocol !== undefined && props.protocol !== ApplicationProtocol.HTTPS) {
throw new Error('The HTTPS protocol must be used when a certificate is given');
throw new ValidationError('The HTTPS protocol must be used when a certificate is given', this);
}
const protocol = props.protocol ?? (props.certificate ? ApplicationProtocol.HTTPS : ApplicationProtocol.HTTP);

if (protocol !== ApplicationProtocol.HTTPS && props.redirectHTTP === true) {
throw new Error('The HTTPS protocol must be used when redirecting HTTP traffic');
throw new ValidationError('The HTTPS protocol must be used when redirecting HTTP traffic', this);
}

const targetProps: AddApplicationTargetsProps = {
Expand All @@ -519,7 +518,7 @@ export abstract class ApplicationLoadBalancedServiceBase extends Construct {
this.certificate = props.certificate;
} else {
if (typeof props.domainName === 'undefined' || typeof props.domainZone === 'undefined') {
throw new Error('A domain name and zone is required when using the HTTPS protocol');
throw new ValidationError('A domain name and zone is required when using the HTTPS protocol', this);
}

this.certificate = new Certificate(this, 'Certificate', {
Expand Down Expand Up @@ -547,7 +546,7 @@ export abstract class ApplicationLoadBalancedServiceBase extends Construct {
let domainName = loadBalancer.loadBalancerDnsName;
if (typeof props.domainName !== 'undefined') {
if (typeof props.domainZone === 'undefined') {
throw new Error('A Route53 hosted domain zone name is required to configure the specified domain name');
throw new ValidationError('A Route53 hosted domain zone name is required to configure the specified domain name', this);
}

switch (props.recordType ?? ApplicationLoadBalancedServiceRecordType.ALIAS) {
Expand Down Expand Up @@ -577,8 +576,8 @@ export abstract class ApplicationLoadBalancedServiceBase extends Construct {
this._applicationLoadBalancer = loadBalancer;
}

new cdk.CfnOutput(this, 'LoadBalancerDNS', { value: loadBalancer.loadBalancerDnsName });
new cdk.CfnOutput(this, 'ServiceURL', { value: protocol.toLowerCase() + '://' + domainName });
new CfnOutput(this, 'LoadBalancerDNS', { value: loadBalancer.loadBalancerDnsName });
new CfnOutput(this, 'ServiceURL', { value: protocol.toLowerCase() + '://' + domainName });
}

/**
Expand All @@ -587,7 +586,7 @@ export abstract class ApplicationLoadBalancedServiceBase extends Construct {
protected getDefaultCluster(scope: Construct, vpc?: IVpc): Cluster {
// magic string to avoid collision with user-defined constructs
const DEFAULT_CLUSTER_ID = `EcsDefaultClusterMnL3mNNYN${vpc ? vpc.node.id : ''}`;
const stack = cdk.Stack.of(scope);
const stack = Stack.of(scope);
return stack.node.tryFindChild(DEFAULT_CLUSTER_ID) as Cluster || new Cluster(stack, DEFAULT_CLUSTER_ID, { vpc });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { IRole } from '../../../aws-iam';
import { ARecord, IHostedZone, RecordTarget } from '../../../aws-route53';
import { LoadBalancerTarget } from '../../../aws-route53-targets';
import { CfnOutput, Duration, Stack } from '../../../core';
import { CfnOutput, Duration, Stack, ValidationError } from '../../../core';

/**
* The properties for the base ApplicationMultipleTargetGroupsEc2Service or ApplicationMultipleTargetGroupsFargateService service.
Expand Down Expand Up @@ -431,7 +431,7 @@ export abstract class ApplicationMultipleTargetGroupsServiceBase extends Constru
for (const listenerProps of lbProps.listeners) {
const protocol = this.createListenerProtocol(listenerProps.protocol, listenerProps.certificate);
if (listenerProps.certificate !== undefined && protocol !== undefined && protocol !== ApplicationProtocol.HTTPS) {
throw new Error('The HTTPS protocol must be used when a certificate is given');
throw new ValidationError('The HTTPS protocol must be used when a certificate is given', this);
}
protocolType.add(protocol);
const listener = this.configListener(protocol, {
Expand Down Expand Up @@ -491,7 +491,7 @@ export abstract class ApplicationMultipleTargetGroupsServiceBase extends Constru
return listener;
}
}
throw new Error(`Listener ${name} is not defined. Did you define listener with name ${name}?`);
throw new ValidationError(`Listener ${name} is not defined. Did you define listener with name ${name}?`, this);
}

protected registerECSTargets(service: BaseService, container: ContainerDefinition, targets: ApplicationTargetProps[]): ApplicationTargetGroup {
Expand Down Expand Up @@ -519,7 +519,7 @@ export abstract class ApplicationMultipleTargetGroupsServiceBase extends Constru
this.targetGroups.push(targetGroup);
}
if (this.targetGroups.length === 0) {
throw new Error('At least one target group should be specified.');
throw new ValidationError('At least one target group should be specified.', this);
}
return this.targetGroups[0];
}
Expand Down Expand Up @@ -561,20 +561,20 @@ export abstract class ApplicationMultipleTargetGroupsServiceBase extends Constru

private validateInput(props: ApplicationMultipleTargetGroupsServiceBaseProps) {
if (props.cluster && props.vpc) {
throw new Error('You can only specify either vpc or cluster. Alternatively, you can leave both blank');
throw new ValidationError('You can only specify either vpc or cluster. Alternatively, you can leave both blank', this);
}

if (props.desiredCount !== undefined && props.desiredCount < 1) {
throw new Error('You must specify a desiredCount greater than 0');
throw new ValidationError('You must specify a desiredCount greater than 0', this);
}

if (props.loadBalancers) {
if (props.loadBalancers.length === 0) {
throw new Error('At least one load balancer must be specified');
throw new ValidationError('At least one load balancer must be specified', this);
}
for (const lbProps of props.loadBalancers) {
if (lbProps.listeners.length === 0) {
throw new Error('At least one listener must be specified');
throw new ValidationError('At least one listener must be specified', this);
}
}
}
Expand All @@ -585,7 +585,7 @@ export abstract class ApplicationMultipleTargetGroupsServiceBase extends Constru
if (prop.idleTimeout) {
const idleTimeout = prop.idleTimeout.toSeconds();
if (idleTimeout > Duration.seconds(4000).toSeconds() || idleTimeout < Duration.seconds(1).toSeconds()) {
throw new Error('Load balancer idle timeout must be between 1 and 4000 seconds.');
throw new ValidationError('Load balancer idle timeout must be between 1 and 4000 seconds.', this);
}
}
}
Expand All @@ -609,7 +609,7 @@ export abstract class ApplicationMultipleTargetGroupsServiceBase extends Constru

private createListenerCertificate(listenerName: string, certificate?: ICertificate, domainName?: string, domainZone?: IHostedZone): ICertificate {
if (typeof domainName === 'undefined' || typeof domainZone === 'undefined') {
throw new Error('A domain name and zone is required when using the HTTPS protocol');
throw new ValidationError('A domain name and zone is required when using the HTTPS protocol', this);
}

if (certificate !== undefined) {
Expand All @@ -635,7 +635,7 @@ export abstract class ApplicationMultipleTargetGroupsServiceBase extends Constru
let domainName = loadBalancer.loadBalancerDnsName;
if (typeof name !== 'undefined') {
if (typeof zone === 'undefined') {
throw new Error('A Route53 hosted domain zone name is required to configure the specified domain name');
throw new ValidationError('A Route53 hosted domain zone name is required to configure the specified domain name', this);
}

const record = new ARecord(this, `DNS${loadBalancer.node.id}`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { IRole } from '../../../aws-iam';
import { ARecord, CnameRecord, IHostedZone, RecordTarget } from '../../../aws-route53';
import { LoadBalancerTarget } from '../../../aws-route53-targets';
import * as cdk from '../../../core';
import { ValidationError } from '../../../core';

/**
* Describes the type of DNS record the service should create
Expand Down Expand Up @@ -329,7 +330,7 @@ export abstract class NetworkLoadBalancedServiceBase extends Construct {
*/
public get loadBalancer(): NetworkLoadBalancer {
if (!this._networkLoadBalancer) {
throw new Error('.loadBalancer can only be accessed if the class was constructed with an owned, not imported, load balancer');
throw new ValidationError('.loadBalancer can only be accessed if the class was constructed with an owned, not imported, load balancer', this);
}
return this._networkLoadBalancer;
}
Expand Down Expand Up @@ -357,12 +358,12 @@ export abstract class NetworkLoadBalancedServiceBase extends Construct {
super(scope, id);

if (props.cluster && props.vpc) {
throw new Error('You can only specify either vpc or cluster. Alternatively, you can leave both blank');
throw new ValidationError('You can only specify either vpc or cluster. Alternatively, you can leave both blank', this);
}
this.cluster = props.cluster || this.getDefaultCluster(this, props.vpc);

if (props.desiredCount !== undefined && props.desiredCount < 1) {
throw new Error('You must specify a desiredCount greater than 0');
throw new ValidationError('You must specify a desiredCount greater than 0', this);
}

this.desiredCount = props.desiredCount || 1;
Expand Down Expand Up @@ -392,7 +393,7 @@ export abstract class NetworkLoadBalancedServiceBase extends Construct {

if (typeof props.domainName !== 'undefined') {
if (typeof props.domainZone === 'undefined') {
throw new Error('A Route53 hosted domain zone name is required to configure the specified domain name');
throw new ValidationError('A Route53 hosted domain zone name is required to configure the specified domain name', this);
}

switch (props.recordType ?? NetworkLoadBalancedServiceRecordType.ALIAS) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { NetworkListener, NetworkLoadBalancer, NetworkTargetGroup } from '../../
import { IRole } from '../../../aws-iam';
import { ARecord, IHostedZone, RecordTarget } from '../../../aws-route53';
import { LoadBalancerTarget } from '../../../aws-route53-targets';
import { CfnOutput, Duration, Stack } from '../../../core';
import { CfnOutput, Duration, Stack, ValidationError } from '../../../core';

/**
* The properties for the base NetworkMultipleTargetGroupsEc2Service or NetworkMultipleTargetGroupsFargateService service.
Expand Down Expand Up @@ -380,7 +380,7 @@ export abstract class NetworkMultipleTargetGroupsServiceBase extends Construct {
return listener;
}
}
throw new Error(`Listener ${name} is not defined. Did you define listener with name ${name}?`);
throw new ValidationError(`Listener ${name} is not defined. Did you define listener with name ${name}?`, this);
}

protected registerECSTargets(service: BaseService, container: ContainerDefinition, targets: NetworkTargetProps[]): NetworkTargetGroup {
Expand All @@ -397,7 +397,7 @@ export abstract class NetworkMultipleTargetGroupsServiceBase extends Construct {
this.targetGroups.push(targetGroup);
}
if (this.targetGroups.length === 0) {
throw new Error('At least one target group should be specified.');
throw new ValidationError('At least one target group should be specified.', this);
}
return this.targetGroups[0];
}
Expand All @@ -423,20 +423,20 @@ export abstract class NetworkMultipleTargetGroupsServiceBase extends Construct {

private validateInput(props: NetworkMultipleTargetGroupsServiceBaseProps) {
if (props.cluster && props.vpc) {
throw new Error('You can only specify either vpc or cluster. Alternatively, you can leave both blank');
throw new ValidationError('You can only specify either vpc or cluster. Alternatively, you can leave both blank', this);
}

if (props.desiredCount !== undefined && props.desiredCount < 1) {
throw new Error('You must specify a desiredCount greater than 0');
throw new ValidationError('You must specify a desiredCount greater than 0', this);
}

if (props.loadBalancers) {
if (props.loadBalancers.length === 0) {
throw new Error('At least one load balancer must be specified');
throw new ValidationError('At least one load balancer must be specified', this);
}
for (const lbProps of props.loadBalancers) {
if (lbProps.listeners.length === 0) {
throw new Error('At least one listener must be specified');
throw new ValidationError('At least one listener must be specified', this);
}
}
}
Expand All @@ -461,7 +461,7 @@ export abstract class NetworkMultipleTargetGroupsServiceBase extends Construct {
private createDomainName(loadBalancer: NetworkLoadBalancer, name?: string, zone?: IHostedZone) {
if (typeof name !== 'undefined') {
if (typeof zone === 'undefined') {
throw new Error('A Route53 hosted domain zone name is required to configure the specified domain name');
throw new ValidationError('A Route53 hosted domain zone name is required to configure the specified domain name', this);
}

new ARecord(this, `DNS${loadBalancer.node.id}`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
ICluster, LogDriver, PropagatedTagSource, Secret,
} from '../../../aws-ecs';
import { IQueue, Queue } from '../../../aws-sqs';
import { CfnOutput, Duration, FeatureFlags, Stack } from '../../../core';
import { CfnOutput, Duration, FeatureFlags, Stack, ValidationError } from '../../../core';
import * as cxapi from '../../../cx-api';

/**
Expand Down Expand Up @@ -335,13 +335,13 @@ export abstract class QueueProcessingServiceBase extends Construct {
super(scope, id);

if (props.cluster && props.vpc) {
throw new Error('You can only specify either vpc or cluster. Alternatively, you can leave both blank');
throw new ValidationError('You can only specify either vpc or cluster. Alternatively, you can leave both blank', this);
}
this.cluster = props.cluster || this.getDefaultCluster(this, props.vpc);

if (props.queue && (props.retentionPeriod || props.visibilityTimeout || props.maxReceiveCount)) {
const errorProps = ['retentionPeriod', 'visibilityTimeout', 'maxReceiveCount'].filter(prop => props.hasOwnProperty(prop));
throw new Error(`${errorProps.join(', ')} can be set only when queue is not set. Specify them in the QueueProps of the queue`);
throw new ValidationError(`${errorProps.join(', ')} can be set only when queue is not set. Specify them in the QueueProps of the queue`, this);
}
// Create the SQS queue and it's corresponding DLQ if one is not provided
if (props.queue) {
Expand All @@ -367,7 +367,7 @@ export abstract class QueueProcessingServiceBase extends Construct {
this.scalingSteps = props.scalingSteps ?? defaultScalingSteps;

if (props.cooldown && props.cooldown.toSeconds() > 999999999) {
throw new Error(`cooldown cannot be more than 999999999, found: ${props.cooldown.toSeconds()}`);
throw new ValidationError(`cooldown cannot be more than 999999999, found: ${props.cooldown.toSeconds()}`, this);
}
this.cooldown = props.cooldown;

Expand Down Expand Up @@ -398,7 +398,7 @@ export abstract class QueueProcessingServiceBase extends Construct {
}

if (!this.desiredCount && !this.maxCapacity) {
throw new Error('maxScalingCapacity must be set and greater than 0 if desiredCount is 0');
throw new ValidationError('maxScalingCapacity must be set and greater than 0 if desiredCount is 0', this);
}

new CfnOutput(this, 'SQSQueue', { value: this.sqsQueue.queueName });
Expand Down
Loading
Loading