Skip to content

Commit 435ff08

Browse files
clarify error type
1 parent a0e6543 commit 435ff08

11 files changed

+104
-143
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"dev": "rollup --config --watch",
2626
"lint": "eslint src/ test/",
2727
"fix-lint": "eslint src/ test/ --fix",
28-
"test": "mocha out/test/*.test.{js,cjs,mjs} --parallel"
28+
"test": "mocha out/test/failover.test.{js,cjs,mjs} --parallel"
2929
},
3030
"repository": {
3131
"type": "git",

src/AzureAppConfigurationImpl.ts

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp
4141
import { KeyFilter, LabelFilter, SettingSelector } from "./types.js";
4242
import { ConfigurationClientManager } from "./ConfigurationClientManager.js";
4343
import { getFixedBackoffDuration, calculateDynamicBackoffDuration } from "./failover.js";
44+
import { FailoverError, OperationError, isFailoverableError } from "./error.js";
4445

4546
type PagedSettingSelector = SettingSelector & {
4647
/**
@@ -50,7 +51,6 @@ type PagedSettingSelector = SettingSelector & {
5051
};
5152

5253
const DEFAULT_STARTUP_TIMEOUT = 100 * 1000; // 100 seconds in milliseconds
53-
const MAX_STARTUP_TIMEOUT = 60 * 60 * 1000; // 60 minutes in milliseconds
5454

5555
export class AzureAppConfigurationImpl implements AzureAppConfiguration {
5656
/**
@@ -127,10 +127,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
127127
} else {
128128
for (const setting of watchedSettings) {
129129
if (setting.key.includes("*") || setting.key.includes(",")) {
130-
throw new Error("The characters '*' and ',' are not supported in key of watched settings.");
130+
throw new RangeError("The characters '*' and ',' are not supported in key of watched settings.");
131131
}
132132
if (setting.label?.includes("*") || setting.label?.includes(",")) {
133-
throw new Error("The characters '*' and ',' are not supported in label of watched settings.");
133+
throw new RangeError("The characters '*' and ',' are not supported in label of watched settings.");
134134
}
135135
this.#sentinels.push(setting);
136136
}
@@ -139,7 +139,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
139139
// custom refresh interval
140140
if (refreshIntervalInMs !== undefined) {
141141
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
142-
throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
142+
throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
143143
} else {
144144
this.#kvRefreshInterval = refreshIntervalInMs;
145145
}
@@ -157,7 +157,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
157157
// custom refresh interval
158158
if (refreshIntervalInMs !== undefined) {
159159
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
160-
throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
160+
throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
161161
} else {
162162
this.#ffRefreshInterval = refreshIntervalInMs;
163163
}
@@ -233,17 +233,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
233233
* Loads the configuration store for the first time.
234234
*/
235235
async load() {
236-
const startupTimeout = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT;
237-
let timer;
236+
const startupTimeout: number = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT;
237+
const abortController = new AbortController();
238+
const abortSignal = abortController.signal;
239+
let timeoutId;
238240
try {
241+
// Promise.race will be settled when the first promise in the list is settled
242+
// It will not cancel the remaining promises in the list.
243+
// To avoid memory leaks, we need to cancel other promises when one promise is settled.
239244
await Promise.race([
240-
new Promise((_, reject) => timer = setTimeout(() => reject(new Error("Load operation timed out.")), startupTimeout)),
241-
this.#initialize()
245+
this.#initializeWithRetryPolicy(abortSignal),
246+
// this promise will be rejected after timeout
247+
new Promise((_, reject) => {
248+
timeoutId = setTimeout(() => {
249+
abortController.abort(); // abort the initialization promise
250+
reject(new Error("Load operation timed out."));
251+
},
252+
startupTimeout);
253+
})
242254
]);
243255
} catch (error) {
244256
throw new Error(`Failed to load: ${error.message}`);
245257
} finally {
246-
clearTimeout(timer);
258+
clearTimeout(timeoutId); // cancel the timeout promise
247259
}
248260
}
249261

@@ -254,7 +266,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
254266
const separator = options?.separator ?? ".";
255267
const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"];
256268
if (!validSeparators.includes(separator)) {
257-
throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
269+
throw new RangeError(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
258270
}
259271

260272
// construct hierarchical data object from map
@@ -267,22 +279,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
267279
const segment = segments[i];
268280
// undefined or empty string
269281
if (!segment) {
270-
throw new Error(`invalid key: ${key}`);
282+
throw new OperationError(`Failed to construct configuration object: Invalid key: ${key}`);
271283
}
272284
// create path if not exist
273285
if (current[segment] === undefined) {
274286
current[segment] = {};
275287
}
276288
// The path has been occupied by a non-object value, causing ambiguity.
277289
if (typeof current[segment] !== "object") {
278-
throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The path '${segments.slice(0, i + 1).join(separator)}' has been occupied.`);
290+
throw new OperationError(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The path '${segments.slice(0, i + 1).join(separator)}' has been occupied.`);
279291
}
280292
current = current[segment];
281293
}
282294

283295
const lastSegment = segments[segments.length - 1];
284296
if (current[lastSegment] !== undefined) {
285-
throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
297+
throw new OperationError(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
286298
}
287299
// set value to the last segment
288300
current[lastSegment] = value;
@@ -295,7 +307,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
295307
*/
296308
async refresh(): Promise<void> {
297309
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
298-
throw new Error("Refresh is not enabled for key-values or feature flags.");
310+
throw new OperationError("Refresh is not enabled for key-values or feature flags.");
299311
}
300312

301313
if (this.#refreshInProgress) {
@@ -314,7 +326,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
314326
*/
315327
onRefresh(listener: () => any, thisArg?: any): Disposable {
316328
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
317-
throw new Error("Refresh is not enabled for key-values or feature flags.");
329+
throw new OperationError("Refresh is not enabled for key-values or feature flags.");
318330
}
319331

320332
const boundedListener = listener.bind(thisArg);
@@ -332,12 +344,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
332344
/**
333345
* Initializes the configuration provider.
334346
*/
335-
async #initialize() {
347+
async #initializeWithRetryPolicy(abortSignal: AbortSignal) {
336348
if (!this.#isInitialLoadCompleted) {
337349
await this.#inspectFmPackage();
338-
const retryEnabled = this.#options?.startupOptions?.retryEnabled ?? true; // enable startup retry by default
339350
const startTimestamp = Date.now();
340-
while (startTimestamp + MAX_STARTUP_TIMEOUT > Date.now()) {
351+
while (!abortSignal.aborted) {
341352
try {
342353
await this.#loadSelectedAndWatchedKeyValues();
343354
if (this.#featureFlagEnabled) {
@@ -347,24 +358,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
347358
this.#isInitialLoadCompleted = true;
348359
break;
349360
} catch (error) {
350-
if (retryEnabled) {
351-
const timeElapsed = Date.now() - startTimestamp;
352-
let postAttempts = 0;
353-
let backoffDuration = getFixedBackoffDuration(timeElapsed);
354-
if (backoffDuration === undefined) {
355-
postAttempts += 1;
356-
backoffDuration = calculateDynamicBackoffDuration(postAttempts);
357-
}
358-
await new Promise(resolve => setTimeout(resolve, backoffDuration));
359-
console.warn("Failed to load configuration settings at startup. Retrying...");
360-
} else {
361-
throw error;
361+
const timeElapsed = Date.now() - startTimestamp;
362+
let postAttempts = 0;
363+
let backoffDuration = getFixedBackoffDuration(timeElapsed);
364+
if (backoffDuration === undefined) {
365+
postAttempts += 1;
366+
backoffDuration = calculateDynamicBackoffDuration(postAttempts);
362367
}
368+
await new Promise(resolve => setTimeout(resolve, backoffDuration));
369+
console.warn("Failed to load configuration settings at startup. Retrying...");
363370
}
364371
}
365-
if (!this.#isInitialLoadCompleted) {
366-
throw new Error("Load operation exceeded the maximum startup timeout limitation.");
367-
}
368372
}
369373
}
370374

@@ -687,7 +691,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
687691
}
688692

689693
this.#clientManager.refreshClients();
690-
throw new Error("All clients failed to get configuration settings.");
694+
throw new FailoverError("All fallback clients failed to get configuration settings.");
691695
}
692696

693697
async #processKeyValues(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
@@ -719,7 +723,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
719723
async #parseFeatureFlag(setting: ConfigurationSetting<string>): Promise<any> {
720724
const rawFlag = setting.value;
721725
if (rawFlag === undefined) {
722-
throw new Error("The value of configuration setting cannot be undefined.");
726+
throw new RangeError("The value of configuration setting cannot be undefined.");
723727
}
724728
const featureFlag = JSON.parse(rawFlag);
725729

@@ -943,13 +947,13 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
943947
return uniqueSelectors.map(selectorCandidate => {
944948
const selector = { ...selectorCandidate };
945949
if (!selector.keyFilter) {
946-
throw new Error("Key filter cannot be null or empty.");
950+
throw new RangeError("Key filter cannot be null or empty.");
947951
}
948952
if (!selector.labelFilter) {
949953
selector.labelFilter = LabelFilter.Null;
950954
}
951955
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
952-
throw new Error("The characters '*' and ',' are not supported in label filters.");
956+
throw new RangeError("The characters '*' and ',' are not supported in label filters.");
953957
}
954958
return selector;
955959
});
@@ -973,9 +977,3 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
973977
});
974978
return getValidSelectors(selectors);
975979
}
976-
977-
function isFailoverableError(error: any): boolean {
978-
// ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory
979-
return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" ||
980-
(error.statusCode !== undefined && (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500)));
981-
}

src/ConfigurationClientManager.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"
88
import { isBrowser, isWebWorker } from "./requestTracing/utils.js";
99
import * as RequestTracing from "./requestTracing/constants.js";
1010
import { shuffleList } from "./common/utils.js";
11+
import { OperationError } from "./error.js";
1112

1213
// Configuration client retry options
1314
const CLIENT_MAX_RETRIES = 2;
@@ -60,7 +61,7 @@ export class ConfigurationClientManager {
6061
this.#id = regexMatch[2];
6162
this.#secret = regexMatch[3];
6263
} else {
63-
throw new Error(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`);
64+
throw new RangeError(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`);
6465
}
6566
staticClient = new AppConfigurationClient(connectionString, this.#clientOptions);
6667
} else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && credentialPassed) {
@@ -77,7 +78,7 @@ export class ConfigurationClientManager {
7778
this.#credential = credential;
7879
staticClient = new AppConfigurationClient(this.endpoint.origin, this.#credential, this.#clientOptions);
7980
} else {
80-
throw new Error("A connection string or an endpoint with credential must be specified to create a client.");
81+
throw new OperationError("A connection string or an endpoint with credential must be specified to create a client.");
8182
}
8283

8384
this.#staticClients = [new ConfigurationClientWrapper(this.endpoint.origin, staticClient)];
@@ -150,7 +151,8 @@ export class ConfigurationClientManager {
150151
let timer;
151152
try {
152153
result = await Promise.race([
153-
new Promise((_, reject) => timer = setTimeout(() => reject(new Error("SRV record query timed out.")), SRV_QUERY_TIMEOUT)),
154+
new Promise((_, reject) =>
155+
timer = setTimeout(() => reject(new Error("SRV record query timed out.")), SRV_QUERY_TIMEOUT)),
154156
this.#querySrvTargetHost(host)
155157
]);
156158
} catch (error) {

src/StartupOptions.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,11 @@
22
// Licensed under the MIT license.
33

44
export interface StartupOptions {
5-
/**
6-
* Specifies whether to enable retry on startup or not.
7-
*
8-
* @remarks
9-
* If not specified, the default value is true.
10-
*/
11-
retryEnabled?: boolean;
12-
135
/**
146
* The amount of time allowed to load data from Azure App Configuration on startup.
157
*
168
* @remarks
17-
* If not specified, the default value is 100 seconds.
9+
* If not specified, the default value is 100 seconds. The maximum allowed value is 1 hour.
1810
*/
1911
timeoutInMs?: number;
2012
}

src/error.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { isRestError } from "@azure/core-rest-pipeline";
5+
6+
export class OperationError extends Error {
7+
constructor(message: string) {
8+
super(message);
9+
this.name = "OperationError";
10+
}
11+
}
12+
13+
export class FailoverError extends Error {
14+
constructor(message: string) {
15+
super(message);
16+
this.name = "FailoverError";
17+
}
18+
}
19+
20+
export function isFailoverableError(error: any): boolean {
21+
// ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory
22+
return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" ||
23+
(error.statusCode !== undefined && (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500)));
24+
}
25+
26+
export function isRetriableError(error: any): boolean {
27+
if (error instanceof OperationError ||
28+
error instanceof RangeError) {
29+
return false;
30+
}
31+
return true;
32+
}

src/keyvault/AzureKeyVaultKeyValueAdapter.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@
55
import { IKeyValueAdapter } from "../IKeyValueAdapter.js";
66
import { KeyVaultOptions } from "./KeyVaultOptions.js";
77
import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets";
8+
import { OperationError } from "../error.js";
89

910
export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
1011
/**
@@ -24,7 +25,7 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
2425
async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> {
2526
// TODO: cache results to save requests.
2627
if (!this.#keyVaultOptions) {
27-
throw new Error("Configure keyVaultOptions to resolve Key Vault Reference(s).");
28+
throw new OperationError("Failed to process the key vault reference. The keyVaultOptions is not configured.");
2829
}
2930

3031
// precedence: secret clients > credential > secret resolver
@@ -43,7 +44,7 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
4344
return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(sourceId))];
4445
}
4546

46-
throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found.");
47+
throw new OperationError("Failed to process the key vault reference. No key vault credential or secret resolver callback configured, and no matching secret client could be found.");
4748
}
4849

4950
#getSecretClient(vaultUrl: URL): SecretClient | undefined {

test/clientOptions.test.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,6 @@ describe("custom client options", function () {
4848
policy: countPolicy,
4949
position: "perRetry"
5050
}]
51-
},
52-
startupOptions: {
53-
retryEnabled: false
5451
}
5552
});
5653
};
@@ -76,9 +73,6 @@ describe("custom client options", function () {
7673
retryOptions: {
7774
maxRetries
7875
}
79-
},
80-
startupOptions: {
81-
retryEnabled: false
8276
}
8377
});
8478
};
@@ -114,9 +108,6 @@ describe("custom client options", function () {
114108
policy: countPolicy,
115109
position: "perRetry"
116110
}]
117-
},
118-
startupOptions: {
119-
retryEnabled: false
120111
}
121112
});
122113
};

0 commit comments

Comments
 (0)