Skip to content

Commit 4e63b1a

Browse files
authored
feat(core): throw typed Errors (#34587)
### Issue Closes #32569 ### Description of changes Throw typed errors everywhere. This introduced a new error type `ExecutionError` that is meant for failures from external scripts or code. ### Describe any new or updated permissions being added n/a ### Description of how you validated changes Existing tests. Exemptions granted as this is a refactor of existing code. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 68d6c1f commit 4e63b1a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+335
-244
lines changed

packages/aws-cdk-lib/.eslintrc.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,8 @@ baseConfig.rules['import/no-extraneous-dependencies'] = [
1515

1616
// no-throw-default-error
1717
baseConfig.rules['@cdklabs/no-throw-default-error'] = ['error'];
18-
// not yet supported
19-
const noThrowDefaultErrorNotYetSupported = [
20-
'core',
21-
];
2218
baseConfig.overrides.push({
19+
rules: { "@cdklabs/no-throw-default-error": "off" },
2320
files: [
2421
// Build and test files can have whatever error they like
2522
"./scripts/**",
@@ -28,11 +25,7 @@ baseConfig.overrides.push({
2825

2926
// Lambda Runtime code should use regular errors
3027
"./custom-resources/lib/provider-framework/runtime/**",
31-
32-
// Not yet supported modules
33-
...noThrowDefaultErrorNotYetSupported.map(m => `./${m}/lib/**`)
3428
],
35-
rules: { "@cdklabs/no-throw-default-error": "off" },
3629
});
3730

3831
module.exports = baseConfig;

packages/aws-cdk-lib/core/lib/annotations.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { IConstruct, MetadataEntry } from 'constructs';
22
import { App } from './app';
3+
import { UnscopedValidationError } from './errors';
34
import * as cxschema from '../../cloud-assembly-schema';
45
import * as cxapi from '../../cx-api';
56

@@ -127,7 +128,7 @@ export class Annotations {
127128

128129
// throw if CDK_BLOCK_DEPRECATIONS is set
129130
if (process.env.CDK_BLOCK_DEPRECATIONS) {
130-
throw new Error(`${this.scope.node.path}: ${text}`);
131+
throw new UnscopedValidationError(`${this.scope.node.path}: ${text}`);
131132
}
132133

133134
this.addWarningV2(`Deprecated:${api}`, text);

packages/aws-cdk-lib/core/lib/arn.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Fn } from './cfn-fn';
2+
import { UnscopedValidationError } from './errors';
23
import { Stack } from './stack';
34
import { Token } from './token';
45
import { filterUndefined } from './util';
@@ -141,7 +142,7 @@ export class Arn {
141142

142143
// Catch both 'null' and 'undefined'
143144
if (partition == null || region == null || account == null) {
144-
throw new Error(`Arn.format: partition (${partition}), region (${region}), and account (${account}) must all be passed if stack is not passed.`);
145+
throw new UnscopedValidationError(`Arn.format: partition (${partition}), region (${region}), and account (${account}) must all be passed if stack is not passed.`);
145146
}
146147

147148
const sep = components.sep ?? (components.arnFormat === ArnFormat.COLON_RESOURCE_NAME ? ':' : '/');
@@ -153,7 +154,7 @@ export class Arn {
153154
];
154155

155156
if (sep !== '/' && sep !== ':' && sep !== '') {
156-
throw new Error('resourcePathSep may only be ":", "/" or an empty string');
157+
throw new UnscopedValidationError('resourcePathSep may only be ":", "/" or an empty string');
157158
}
158159

159160
if (components.resourceName != null) {
@@ -324,10 +325,10 @@ export class Arn {
324325
// resource type (to notify authors of incorrect assumptions right away).
325326
const parsed = Arn.split(arn, ArnFormat.SLASH_RESOURCE_NAME);
326327
if (!Token.isUnresolved(parsed.resource) && parsed.resource !== resourceType) {
327-
throw new Error(`Expected resource type '${resourceType}' in ARN, got '${parsed.resource}' in '${arn}'`);
328+
throw new UnscopedValidationError(`Expected resource type '${resourceType}' in ARN, got '${parsed.resource}' in '${arn}'`);
328329
}
329330
if (!parsed.resourceName) {
330-
throw new Error(`Expected resource name in ARN, didn't find one: '${arn}'`);
331+
throw new UnscopedValidationError(`Expected resource name in ARN, didn't find one: '${arn}'`);
331332
}
332333
return parsed.resourceName;
333334
}
@@ -411,7 +412,7 @@ function parseArnShape(arn: string): 'token' | string[] {
411412
if (Token.isUnresolved(arn)) {
412413
return 'token';
413414
} else {
414-
throw new Error(`ARNs must start with "arn:" and have at least 6 components: ${arn}`);
415+
throw new UnscopedValidationError(`ARNs must start with "arn:" and have at least 6 components: ${arn}`);
415416
}
416417
}
417418

@@ -423,17 +424,17 @@ function parseArnShape(arn: string): 'token' | string[] {
423424

424425
const partition = components.length > 1 ? components[1] : undefined;
425426
if (!partition) {
426-
throw new Error('The `partition` component (2nd component) of an ARN is required: ' + arn);
427+
throw new UnscopedValidationError('The `partition` component (2nd component) of an ARN is required: ' + arn);
427428
}
428429

429430
const service = components.length > 2 ? components[2] : undefined;
430431
if (!service) {
431-
throw new Error('The `service` component (3rd component) of an ARN is required: ' + arn);
432+
throw new UnscopedValidationError('The `service` component (3rd component) of an ARN is required: ' + arn);
432433
}
433434

434435
const resource = components.length > 5 ? components[5] : undefined;
435436
if (!resource) {
436-
throw new Error('The `resource` component (6th component) of an ARN is required: ' + arn);
437+
throw new UnscopedValidationError('The `resource` component (6th component) of an ARN is required: ' + arn);
437438
}
438439

439440
// Region can be missing in global ARNs (such as used by IAM)

packages/aws-cdk-lib/core/lib/aspect.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IConstruct } from 'constructs';
2+
import { ValidationError } from './errors';
23

34
const ASPECTS_SYMBOL = Symbol.for('cdk-aspects');
45

@@ -178,7 +179,7 @@ export class AspectApplication {
178179
*/
179180
public set priority(priority: number) {
180181
if (priority < 0) {
181-
throw new Error('Priority must be a non-negative number');
182+
throw new ValidationError('Priority must be a non-negative number', this.construct);
182183
}
183184
this._priority = priority;
184185

packages/aws-cdk-lib/core/lib/asset-staging.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Construct } from 'constructs';
44
import * as fs from 'fs-extra';
55
import { AssetHashType, AssetOptions, FileAssetPackaging } from './assets';
66
import { BundlingFileAccess, BundlingOptions, BundlingOutput } from './bundling';
7+
import { AssumptionError, ValidationError } from './errors';
78
import { FileSystem, FingerprintOptions } from './fs';
89
import { clearLargeFileFingerprintCache } from './fs/fingerprint';
910
import { Names } from './names';
@@ -172,21 +173,21 @@ export class AssetStaging extends Construct {
172173
};
173174

174175
if (!fs.existsSync(this.sourcePath)) {
175-
throw new Error(`Cannot find asset at ${this.sourcePath}`);
176+
throw new ValidationError(`Cannot find asset at ${this.sourcePath}`, this);
176177
}
177178

178179
this.sourceStats = fs.statSync(this.sourcePath);
179180

180181
const outdir = Stage.of(this)?.assetOutdir;
181182
if (!outdir) {
182-
throw new Error('unable to determine cloud assembly asset output directory. Assets must be defined indirectly within a "Stage" or an "App" scope');
183+
throw new ValidationError('unable to determine cloud assembly asset output directory. Assets must be defined indirectly within a "Stage" or an "App" scope', this);
183184
}
184185
this.assetOutdir = outdir;
185186

186187
// Determine the hash type based on the props as props.assetHashType is
187188
// optional from a caller perspective.
188189
this.customSourceFingerprint = props.assetHash;
189-
this.hashType = determineHashType(props.assetHashType, this.customSourceFingerprint);
190+
this.hashType = determineHashType(this, props.assetHashType, this.customSourceFingerprint);
190191

191192
// Decide what we're going to do, without actually doing it yet
192193
let stageThisAsset: () => StagedAsset;
@@ -284,7 +285,7 @@ export class AssetStaging extends Construct {
284285
const stagedPath = this.renderStagedPath(this.sourcePath, targetPath);
285286

286287
if (!this.sourceStats.isDirectory() && !this.sourceStats.isFile()) {
287-
throw new Error(`Asset ${this.sourcePath} is expected to be either a directory or a regular file`);
288+
throw new ValidationError(`Asset ${this.sourcePath} is expected to be either a directory or a regular file`, this);
288289
}
289290

290291
this.stageAsset(this.sourcePath, stagedPath, 'copy');
@@ -304,7 +305,7 @@ export class AssetStaging extends Construct {
304305
*/
305306
private stageByBundling(bundling: BundlingOptions, skip: boolean): StagedAsset {
306307
if (!this.sourceStats.isDirectory()) {
307-
throw new Error(`Asset ${this.sourcePath} is expected to be a directory when bundling`);
308+
throw new ValidationError(`Asset ${this.sourcePath} is expected to be a directory when bundling`, this);
308309
}
309310

310311
if (skip) {
@@ -334,7 +335,7 @@ export class AssetStaging extends Construct {
334335

335336
// Check bundling output content and determine if we will need to archive
336337
const bundlingOutputType = bundling.outputType ?? BundlingOutput.AUTO_DISCOVER;
337-
const bundledAsset = determineBundledAsset(bundleDir, bundlingOutputType);
338+
const bundledAsset = determineBundledAsset(this, bundleDir, bundlingOutputType);
338339

339340
// Calculate assetHash afterwards if we still must
340341
assetHash = assetHash ?? this.calculateHash(this.hashType, bundling, bundledAsset.path);
@@ -404,7 +405,7 @@ export class AssetStaging extends Construct {
404405
fs.mkdirSync(targetPath);
405406
FileSystem.copyDirectory(sourcePath, targetPath, this.fingerprintOptions);
406407
} else {
407-
throw new Error(`Unknown file type: ${sourcePath}`);
408+
throw new ValidationError(`Unknown file type: ${sourcePath}`, this);
408409
}
409410
}
410411

@@ -472,12 +473,12 @@ export class AssetStaging extends Construct {
472473
// Success, rename the tempDir into place
473474
fs.renameSync(tempDir, bundleDir);
474475
} catch (err) {
475-
throw new Error(`Failed to bundle asset ${this.node.path}, bundle output is located at ${tempDir}: ${err}`);
476+
throw new ValidationError(`Failed to bundle asset ${this.node.path}, bundle output is located at ${tempDir}: ${err}`, this);
476477
}
477478

478479
if (FileSystem.isEmpty(bundleDir)) {
479480
const outputDir = localBundling ? bundleDir : AssetStaging.BUNDLING_OUTPUT_DIR;
480-
throw new Error(`Bundling did not produce any output. Check that content is written to ${outputDir}.`);
481+
throw new ValidationError(`Bundling did not produce any output. Check that content is written to ${outputDir}.`, this);
481482
}
482483
}
483484

@@ -505,11 +506,11 @@ export class AssetStaging extends Construct {
505506
case AssetHashType.BUNDLE:
506507
case AssetHashType.OUTPUT:
507508
if (!outputDir) {
508-
throw new Error(`Cannot use \`${hashType}\` hash type when \`bundling\` is not specified.`);
509+
throw new ValidationError(`Cannot use \`${hashType}\` hash type when \`bundling\` is not specified.`, this);
509510
}
510511
return FileSystem.fingerprint(outputDir, this.fingerprintOptions);
511512
default:
512-
throw new Error('Unknown asset hash type.');
513+
throw new ValidationError('Unknown asset hash type.', this);
513514
}
514515
}
515516

@@ -535,16 +536,16 @@ function renderAssetFilename(assetHash: string, extension = '') {
535536
* @param assetHashType Asset hash type construct prop
536537
* @param customSourceFingerprint Asset hash seed given in the construct props
537538
*/
538-
function determineHashType(assetHashType?: AssetHashType, customSourceFingerprint?: string) {
539+
function determineHashType(scope: Construct, assetHashType?: AssetHashType, customSourceFingerprint?: string) {
539540
const hashType = customSourceFingerprint
540541
? (assetHashType ?? AssetHashType.CUSTOM)
541542
: (assetHashType ?? AssetHashType.SOURCE);
542543

543544
if (customSourceFingerprint && hashType !== AssetHashType.CUSTOM) {
544-
throw new Error(`Cannot specify \`${assetHashType}\` for \`assetHashType\` when \`assetHash\` is specified. Use \`CUSTOM\` or leave \`undefined\`.`);
545+
throw new ValidationError(`Cannot specify \`${assetHashType}\` for \`assetHashType\` when \`assetHash\` is specified. Use \`CUSTOM\` or leave \`undefined\`.`, scope);
545546
}
546547
if (hashType === AssetHashType.CUSTOM && !customSourceFingerprint) {
547-
throw new Error('`assetHash` must be specified when `assetHashType` is set to `AssetHashType.CUSTOM`.');
548+
throw new ValidationError('`assetHash` must be specified when `assetHashType` is set to `AssetHashType.CUSTOM`.', scope);
548549
}
549550

550551
return hashType;
@@ -589,7 +590,7 @@ function sanitizeHashValue(key: string, value: any): any {
589590
}
590591
} catch (e: any) {
591592
if (e.name === 'TypeError') {
592-
throw new Error(`${key} must be a valid URL, got ${value}.`);
593+
throw new AssumptionError(`${key} must be a valid URL, got ${value}.`);
593594
}
594595
throw e;
595596
}
@@ -600,13 +601,13 @@ function sanitizeHashValue(key: string, value: any): any {
600601
/**
601602
* Returns the single archive file of a directory or undefined
602603
*/
603-
function findSingleFile(directory: string, archiveOnly: boolean): string | undefined {
604+
function findSingleFile(scope: Construct, directory: string, archiveOnly: boolean): string | undefined {
604605
if (!fs.existsSync(directory)) {
605-
throw new Error(`Directory ${directory} does not exist.`);
606+
throw new ValidationError(`Directory ${directory} does not exist.`, scope);
606607
}
607608

608609
if (!fs.statSync(directory).isDirectory()) {
609-
throw new Error(`${directory} is not a directory.`);
610+
throw new ValidationError(`${directory} is not a directory.`, scope);
610611
}
611612

612613
const content = fs.readdirSync(directory);
@@ -631,8 +632,8 @@ interface BundledAsset {
631632
* Returns the bundled asset to use based on the content of the bundle directory
632633
* and the type of output.
633634
*/
634-
function determineBundledAsset(bundleDir: string, outputType: BundlingOutput): BundledAsset {
635-
const archiveFile = findSingleFile(bundleDir, outputType !== BundlingOutput.SINGLE_FILE);
635+
function determineBundledAsset(scope: Construct, bundleDir: string, outputType: BundlingOutput): BundledAsset {
636+
const archiveFile = findSingleFile(scope, bundleDir, outputType !== BundlingOutput.SINGLE_FILE);
636637

637638
// auto-discover means that if there is an archive file, we take it as the
638639
// bundle, otherwise, we will archive here.
@@ -646,7 +647,7 @@ function determineBundledAsset(bundleDir: string, outputType: BundlingOutput): B
646647
case BundlingOutput.ARCHIVED:
647648
case BundlingOutput.SINGLE_FILE:
648649
if (!archiveFile) {
649-
throw new Error('Bundling output directory is expected to include only a single file when `output` is set to `ARCHIVED` or `SINGLE_FILE`');
650+
throw new ValidationError('Bundling output directory is expected to include only a single file when `output` is set to `ARCHIVED` or `SINGLE_FILE`', scope);
650651
}
651652
return { path: archiveFile, packaging: FileAssetPackaging.FILE, extension: getExtension(archiveFile) };
652653
}

packages/aws-cdk-lib/core/lib/bundling.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { spawnSync } from 'child_process';
22
import * as crypto from 'crypto';
33
import { isAbsolute, join } from 'path';
44
import { DockerCacheOption } from './assets';
5+
import { ExecutionError, UnscopedValidationError } from './errors';
56
import { FileSystem } from './fs';
67
import { dockerExec } from './private/asset-staging';
78
import { quiet, reset } from './private/jsii-deprecated';
@@ -312,7 +313,7 @@ export class BundlingDockerImage {
312313
const { stdout } = dockerExec(['create', this.image], {}); // Empty options to avoid stdout redirect here
313314
const match = stdout.toString().match(/([0-9a-f]{16,})/);
314315
if (!match) {
315-
throw new Error('Failed to extract container ID from Docker create output');
316+
throw new ExecutionError('Failed to extract container ID from Docker create output');
316317
}
317318

318319
const containerId = match[1];
@@ -322,7 +323,7 @@ export class BundlingDockerImage {
322323
dockerExec(['cp', containerPath, destPath]);
323324
return destPath;
324325
} catch (err) {
325-
throw new Error(`Failed to copy files from ${containerPath} to ${destPath}: ${err}`);
326+
throw new ExecutionError(`Failed to copy files from ${containerPath} to ${destPath}: ${err}`);
326327
} finally {
327328
dockerExec(['rm', '-v', containerId]);
328329
}
@@ -343,7 +344,7 @@ export class DockerImage extends BundlingDockerImage {
343344
const buildArgs = options.buildArgs || {};
344345

345346
if (options.file && isAbsolute(options.file)) {
346-
throw new Error(`"file" must be relative to the docker build directory. Got ${options.file}`);
347+
throw new UnscopedValidationError(`"file" must be relative to the docker build directory. Got ${options.file}`);
347348
}
348349

349350
// Image tag derived from path and build options

packages/aws-cdk-lib/core/lib/cfn-codedeploy-blue-green-hook.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Construct } from 'constructs';
22
import { CfnHook } from './cfn-hook';
33
import { CfnResource } from './cfn-resource';
4+
import { UnscopedValidationError } from './errors';
45
import { FromCloudFormationOptions } from './helpers-internal';
56
import { undefinedIfAllValuesAreEmpty } from './util';
67

@@ -366,7 +367,7 @@ export class CfnCodeDeployBlueGreenHook extends CfnHook {
366367
}
367368
const ret = options.parser.finder.findResource(logicalId);
368369
if (!ret) {
369-
throw new Error(`Hook '${id}' references resource '${logicalId}' that was not found in the template`);
370+
throw new UnscopedValidationError(`Hook '${id}' references resource '${logicalId}' that was not found in the template`);
370371
}
371372
return ret;
372373
}

packages/aws-cdk-lib/core/lib/cfn-element.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ export abstract class CfnElement extends Construct {
8484
*/
8585
public overrideLogicalId(newLogicalId: string) {
8686
if (this._logicalIdLocked) {
87-
throw new Error(`The logicalId for resource at path ${Node.of(this).path} has been locked and cannot be overridden\n` +
88-
'Make sure you are calling "overrideLogicalId" before Stack.exportValue');
87+
throw new ValidationError(`The logicalId for resource at path ${Node.of(this).path} has been locked and cannot be overridden\n` +
88+
'Make sure you are calling "overrideLogicalId" before Stack.exportValue', this);
8989
} else {
9090
this._logicalIdOverride = newLogicalId;
9191
}
@@ -206,4 +206,5 @@ function notTooLong(x: string) {
206206
// These imports have to be at the end to prevent circular imports
207207
import { CfnReference } from './private/cfn-reference';
208208
import { Stack } from './stack';
209-
import { Token } from './token';
209+
import { Token } from './token';import { ValidationError } from './errors';
210+

0 commit comments

Comments
 (0)