diff --git a/rollup.config.mjs b/rollup.config.mjs index 1fa9626f..6f78ca44 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -4,7 +4,15 @@ import dts from "rollup-plugin-dts"; export default [ { - external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto", "dns/promises", "@microsoft/feature-management"], + external: [ + "@azure/app-configuration", + "@azure/keyvault-secrets", + "@azure/core-rest-pipeline", + "@azure/identity", + "crypto", + "dns/promises", + "@microsoft/feature-management" + ], input: "src/index.ts", output: [ { diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index 3f2918be..dbe2ce48 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -3,6 +3,9 @@ import { Disposable } from "./common/disposable.js"; +/** + * Azure App Configuration provider. + */ export type AzureAppConfiguration = { /** * API to trigger refresh operation. diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 35fc26ee..fb523ab0 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -7,7 +7,8 @@ import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from ". import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; -import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js"; +import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js"; +import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js"; import { Disposable } from "./common/disposable.js"; import { FEATURE_FLAGS_KEY_NAME, @@ -33,6 +34,10 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; +import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; +import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js"; + +const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds type PagedSettingSelector = SettingSelector & { /** @@ -118,10 +123,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } else { for (const setting of watchedSettings) { if (setting.key.includes("*") || setting.key.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in key of watched settings."); + throw new ArgumentError("The characters '*' and ',' are not supported in key of watched settings."); } if (setting.label?.includes("*") || setting.label?.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in label of watched settings."); + throw new ArgumentError("The characters '*' and ',' are not supported in label of watched settings."); } this.#sentinels.push(setting); } @@ -130,7 +135,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { - throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); + throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); } else { this.#kvRefreshInterval = refreshIntervalInMs; } @@ -148,7 +153,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { - throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); + throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); } else { this.#ffRefreshInterval = refreshIntervalInMs; } @@ -225,13 +230,40 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Loads the configuration store for the first time. */ async load() { - await this.#inspectFmPackage(); - await this.#loadSelectedAndWatchedKeyValues(); - if (this.#featureFlagEnabled) { - await this.#loadFeatureFlags(); + const startTimestamp = Date.now(); + const startupTimeout: number = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT_IN_MS; + const abortController = new AbortController(); + const abortSignal = abortController.signal; + let timeoutId; + try { + // Promise.race will be settled when the first promise in the list is settled. + // It will not cancel the remaining promises in the list. + // To avoid memory leaks, we must ensure other promises will be eventually terminated. + await Promise.race([ + this.#initializeWithRetryPolicy(abortSignal), + // this promise will be rejected after timeout + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortController.abort(); // abort the initialization promise + reject(new Error("Load operation timed out.")); + }, + startupTimeout); + }) + ]); + } catch (error) { + if (!isInputError(error)) { + const timeElapsed = Date.now() - startTimestamp; + if (timeElapsed < MIN_DELAY_FOR_UNHANDLED_FAILURE) { + // load() method is called in the application's startup code path. + // Unhandled exceptions cause application crash which can result in crash loops as orchestrators attempt to restart the application. + // Knowing the intended usage of the provider in startup code path, we mitigate back-to-back crash loops from overloading the server with requests by waiting a minimum time to propagate fatal errors. + await new Promise(resolve => setTimeout(resolve, MIN_DELAY_FOR_UNHANDLED_FAILURE - timeElapsed)); + } + } + throw new Error("Failed to load.", { cause: error }); + } finally { + clearTimeout(timeoutId); // cancel the timeout promise } - // Mark all settings have loaded at startup. - this.#isInitialLoadCompleted = true; } /** @@ -241,7 +273,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const separator = options?.separator ?? "."; const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"]; if (!validSeparators.includes(separator)) { - throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`); + throw new ArgumentError(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`); } // construct hierarchical data object from map @@ -254,7 +286,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const segment = segments[i]; // undefined or empty string if (!segment) { - throw new Error(`invalid key: ${key}`); + throw new InvalidOperationError(`Failed to construct configuration object: Invalid key: ${key}`); } // create path if not exist if (current[segment] === undefined) { @@ -262,14 +294,14 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } // The path has been occupied by a non-object value, causing ambiguity. if (typeof current[segment] !== "object") { - 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.`); + throw new InvalidOperationError(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The path '${segments.slice(0, i + 1).join(separator)}' has been occupied.`); } current = current[segment]; } const lastSegment = segments[segments.length - 1]; if (current[lastSegment] !== undefined) { - throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`); + throw new InvalidOperationError(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`); } // set value to the last segment current[lastSegment] = value; @@ -282,7 +314,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async refresh(): Promise { if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); + throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); } if (this.#refreshInProgress) { @@ -301,7 +333,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ onRefresh(listener: () => any, thisArg?: any): Disposable { if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); + throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); } const boundedListener = listener.bind(thisArg); @@ -316,6 +348,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return new Disposable(remove); } + /** + * Initializes the configuration provider. + */ + async #initializeWithRetryPolicy(abortSignal: AbortSignal): Promise { + if (!this.#isInitialLoadCompleted) { + await this.#inspectFmPackage(); + const startTimestamp = Date.now(); + let postAttempts = 0; + do { // at least try to load once + try { + await this.#loadSelectedAndWatchedKeyValues(); + if (this.#featureFlagEnabled) { + await this.#loadFeatureFlags(); + } + this.#isInitialLoadCompleted = true; + break; + } catch (error) { + if (isInputError(error)) { + throw error; + } + if (abortSignal.aborted) { + return; + } + const timeElapsed = Date.now() - startTimestamp; + let backoffDuration = getFixedBackoffDuration(timeElapsed); + if (backoffDuration === undefined) { + postAttempts += 1; + backoffDuration = getExponentialBackoffDuration(postAttempts); + } + console.warn(`Failed to load. Error message: ${error.message}. Retrying in ${backoffDuration} ms.`); + await new Promise(resolve => setTimeout(resolve, backoffDuration)); + } + } while (!abortSignal.aborted); + } + } + /** * Inspects the feature management package version. */ @@ -426,7 +494,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#aiConfigurationTracing.reset(); } - // process key-values, watched settings have higher priority + // adapt configuration settings to key-values for (const setting of loadedSettings) { const [key, value] = await this.#processKeyValue(setting); keyValues.push([key, value]); @@ -606,6 +674,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return response; } + // Only operations related to Azure App Configuration should be executed with failover policy. async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { let clientWrappers = await this.#clientManager.getClients(); if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { @@ -645,7 +714,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } this.#clientManager.refreshClients(); - throw new Error("All clients failed to get configuration settings."); + throw new Error("All fallback clients failed to get configuration settings."); } async #processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { @@ -700,7 +769,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #parseFeatureFlag(setting: ConfigurationSetting): Promise { const rawFlag = setting.value; if (rawFlag === undefined) { - throw new Error("The value of configuration setting cannot be undefined."); + throw new ArgumentError("The value of configuration setting cannot be undefined."); } const featureFlag = JSON.parse(rawFlag); @@ -762,13 +831,13 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { return uniqueSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; if (!selector.keyFilter) { - throw new Error("Key filter cannot be null or empty."); + throw new ArgumentError("Key filter cannot be null or empty."); } if (!selector.labelFilter) { selector.labelFilter = LabelFilter.Null; } if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in label filters."); + throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); } return selector; }); @@ -792,9 +861,3 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel }); return getValidSelectors(selectors); } - -function isFailoverableError(error: any): boolean { - // ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory - return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" || - (error.statusCode !== undefined && (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500))); -} diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 56b47b50..dcf27765 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -3,12 +3,10 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration"; import { KeyVaultOptions } from "./keyvault/KeyVaultOptions.js"; -import { RefreshOptions } from "./RefreshOptions.js"; +import { RefreshOptions } from "./refresh/refreshOptions.js"; import { SettingSelector } from "./types.js"; import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions.js"; - -export const MaxRetries = 2; -export const MaxRetryDelayInMs = 60000; +import { StartupOptions } from "./StartupOptions.js"; export interface AzureAppConfigurationOptions { /** @@ -48,6 +46,11 @@ export interface AzureAppConfigurationOptions { */ featureFlagOptions?: FeatureFlagOptions; + /** + * Specifies options used to configure provider startup. + */ + startupOptions?: StartupOptions; + /** * Specifies whether to enable replica discovery or not. * diff --git a/src/ConfigurationClientManager.ts b/src/ConfigurationClientManager.ts index 7e5151a4..72a3bfeb 100644 --- a/src/ConfigurationClientManager.ts +++ b/src/ConfigurationClientManager.ts @@ -4,10 +4,15 @@ import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration"; import { ConfigurationClientWrapper } from "./ConfigurationClientWrapper.js"; import { TokenCredential } from "@azure/identity"; -import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions.js"; +import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; import { isBrowser, isWebWorker } from "./requestTracing/utils.js"; import * as RequestTracing from "./requestTracing/constants.js"; -import { shuffleList } from "./common/utils.js"; +import { shuffleList, instanceOfTokenCredential } from "./common/utils.js"; +import { ArgumentError } from "./common/error.js"; + +// Configuration client retry options +const CLIENT_MAX_RETRIES = 2; +const CLIENT_MAX_RETRY_DELAY = 60_000; // 1 minute in milliseconds const TCP_ORIGIN_KEY_NAME = "_origin._tcp"; const ALT_KEY_NAME = "_alt"; @@ -54,18 +59,18 @@ export class ConfigurationClientManager { const regexMatch = connectionString.match(ConnectionStringRegex); if (regexMatch) { const endpointFromConnectionStr = regexMatch[1]; - this.endpoint = getValidUrl(endpointFromConnectionStr); + this.endpoint = new URL(endpointFromConnectionStr); this.#id = regexMatch[2]; this.#secret = regexMatch[3]; } else { - throw new Error(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`); + throw new ArgumentError(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`); } staticClient = new AppConfigurationClient(connectionString, this.#clientOptions); } else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && credentialPassed) { let endpoint = connectionStringOrEndpoint; // ensure string is a valid URL. if (typeof endpoint === "string") { - endpoint = getValidUrl(endpoint); + endpoint = new URL(endpoint); } const credential = credentialOrOptions as TokenCredential; @@ -75,7 +80,7 @@ export class ConfigurationClientManager { this.#credential = credential; staticClient = new AppConfigurationClient(this.endpoint.origin, this.#credential, this.#clientOptions); } else { - throw new Error("A connection string or an endpoint with credential must be specified to create a client."); + throw new ArgumentError("A connection string or an endpoint with credential must be specified to create a client."); } this.#staticClients = [new ConfigurationClientWrapper(this.endpoint.origin, staticClient)]; @@ -200,12 +205,12 @@ export class ConfigurationClientManager { }); index++; } - } catch (err) { - if (err.code === "ENOTFOUND") { + } catch (error) { + if (error.code === "ENOTFOUND") { // No more SRV records found, return results. return results; } else { - throw new Error(`Failed to lookup SRV records: ${err.message}`); + throw new Error(`Failed to lookup SRV records: ${error.message}`); } } @@ -260,8 +265,8 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat // retry options const defaultRetryOptions = { - maxRetries: MaxRetries, - maxRetryDelayInMs: MaxRetryDelayInMs, + maxRetries: CLIENT_MAX_RETRIES, + maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY, }; const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions); @@ -272,20 +277,3 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat } }); } - -function getValidUrl(endpoint: string): URL { - try { - return new URL(endpoint); - } catch (error) { - if (error.code === "ERR_INVALID_URL") { - throw new Error("Invalid endpoint URL.", { cause: error }); - } else { - throw error; - } - } -} - -export function instanceOfTokenCredential(obj: unknown) { - return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function"; -} - diff --git a/src/ConfigurationClientWrapper.ts b/src/ConfigurationClientWrapper.ts index 967158ae..137d1c38 100644 --- a/src/ConfigurationClientWrapper.ts +++ b/src/ConfigurationClientWrapper.ts @@ -2,10 +2,7 @@ // Licensed under the MIT license. import { AppConfigurationClient } from "@azure/app-configuration"; - -const MaxBackoffDuration = 10 * 60 * 1000; // 10 minutes in milliseconds -const MinBackoffDuration = 30 * 1000; // 30 seconds in milliseconds -const JITTER_RATIO = 0.25; +import { getExponentialBackoffDuration } from "./common/backoffUtils.js"; export class ConfigurationClientWrapper { endpoint: string; @@ -24,25 +21,7 @@ export class ConfigurationClientWrapper { this.backoffEndTime = Date.now(); } else { this.#failedAttempts += 1; - this.backoffEndTime = Date.now() + calculateBackoffDuration(this.#failedAttempts); + this.backoffEndTime = Date.now() + getExponentialBackoffDuration(this.#failedAttempts); } } } - -export function calculateBackoffDuration(failedAttempts: number) { - if (failedAttempts <= 1) { - return MinBackoffDuration; - } - - // exponential: minBackoff * 2 ^ (failedAttempts - 1) - // The right shift operator is not used in order to avoid potential overflow. Bitwise operations in JavaScript are limited to 32 bits. - let calculatedBackoffDuration = MinBackoffDuration * Math.pow(2, failedAttempts - 1); - if (calculatedBackoffDuration > MaxBackoffDuration) { - calculatedBackoffDuration = MaxBackoffDuration; - } - - // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs - const jitter = JITTER_RATIO * (Math.random() * 2 - 1); - - return calculatedBackoffDuration * (1 + jitter); -} diff --git a/src/StartupOptions.ts b/src/StartupOptions.ts new file mode 100644 index 00000000..f80644bb --- /dev/null +++ b/src/StartupOptions.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100 * 1000; // 100 seconds in milliseconds + +export interface StartupOptions { + /** + * The amount of time allowed to load data from Azure App Configuration on startup. + * + * @remarks + * If not specified, the default value is 100 seconds. + */ + timeoutInMs?: number; +} diff --git a/src/common/backoffUtils.ts b/src/common/backoffUtils.ts new file mode 100644 index 00000000..2bebf5c4 --- /dev/null +++ b/src/common/backoffUtils.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const MIN_BACKOFF_DURATION = 30_000; // 30 seconds in milliseconds +const MAX_BACKOFF_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds +const JITTER_RATIO = 0.25; + +export function getFixedBackoffDuration(timeElapsedInMs: number): number | undefined { + if (timeElapsedInMs < 100_000) { + return 5_000; + } + if (timeElapsedInMs < 200_000) { + return 10_000; + } + if (timeElapsedInMs < 10 * 60 * 1000) { + return MIN_BACKOFF_DURATION; + } + return undefined; +} + +export function getExponentialBackoffDuration(failedAttempts: number): number { + if (failedAttempts <= 1) { + return MIN_BACKOFF_DURATION; + } + + // exponential: minBackoff * 2 ^ (failedAttempts - 1) + // The right shift operator is not used in order to avoid potential overflow. Bitwise operations in JavaScript are limited to 32 bits. + let calculatedBackoffDuration = MIN_BACKOFF_DURATION * Math.pow(2, failedAttempts - 1); + if (calculatedBackoffDuration > MAX_BACKOFF_DURATION) { + calculatedBackoffDuration = MAX_BACKOFF_DURATION; + } + + // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs + const jitter = JITTER_RATIO * (Math.random() * 2 - 1); + + return calculatedBackoffDuration * (1 + jitter); +} diff --git a/src/common/error.ts b/src/common/error.ts new file mode 100644 index 00000000..bd4f5adf --- /dev/null +++ b/src/common/error.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { isRestError } from "@azure/core-rest-pipeline"; + +/** + * Error thrown when an operation cannot be performed by the Azure App Configuration provider. + */ +export class InvalidOperationError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidOperationError"; + } +} + +/** + * Error thrown when an input argument is invalid. + */ +export class ArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = "ArgumentError"; + } +} + +/** + * Error thrown when a Key Vault reference cannot be resolved. + */ +export class KeyVaultReferenceError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "KeyVaultReferenceError"; + } +} + +export function isFailoverableError(error: any): boolean { + if (!isRestError(error)) { + return false; + } + // https://nodejs.org/api/errors.html#common-system-errors + // ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory, ECONNREFUSED: connection refused, ECONNRESET: connection reset by peer, ETIMEDOUT: connection timed out + if (error.code !== undefined && + (error.code === "ENOTFOUND" || error.code === "ENOENT" || error.code === "ECONNREFUSED" || error.code === "ECONNRESET" || error.code === "ETIMEDOUT")) { + return true; + } + // 401 Unauthorized, 403 Forbidden, 408 Request Timeout, 429 Too Many Requests, 5xx Server Errors + if (error.statusCode !== undefined && + (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500)) { + return true; + } + + return false; +} + +/** + * Check if the error is an instance of ArgumentError, TypeError, or RangeError. + */ +export function isInputError(error: any): boolean { + return error instanceof ArgumentError || + error instanceof TypeError || + error instanceof RangeError; +} diff --git a/src/common/utils.ts b/src/common/utils.ts index 2db9e65a..18667874 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -8,3 +8,7 @@ export function shuffleList(array: T[]): T[] { } return array; } + +export function instanceOfTokenCredential(obj: unknown) { + return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function"; +} diff --git a/src/featureManagement/FeatureFlagOptions.ts b/src/featureManagement/FeatureFlagOptions.ts index 55ceda4d..6814dbf3 100644 --- a/src/featureManagement/FeatureFlagOptions.ts +++ b/src/featureManagement/FeatureFlagOptions.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { FeatureFlagRefreshOptions } from "../RefreshOptions.js"; +import { FeatureFlagRefreshOptions } from "../refresh/refreshOptions.js"; import { SettingSelector } from "../types.js"; /** diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index 1b6fdcc4..1b8c5977 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -4,12 +4,15 @@ import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; import { IKeyValueAdapter } from "../IKeyValueAdapter.js"; import { KeyVaultOptions } from "./KeyVaultOptions.js"; +import { ArgumentError, KeyVaultReferenceError } from "../common/error.js"; import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; +import { isRestError } from "@azure/core-rest-pipeline"; +import { AuthenticationError } from "@azure/identity"; export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { /** * Map vault hostname to corresponding secret client. - */ + */ #secretClients: Map; #keyVaultOptions: KeyVaultOptions | undefined; @@ -24,33 +27,53 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { // TODO: cache results to save requests. if (!this.#keyVaultOptions) { - throw new Error("Configure keyVaultOptions to resolve Key Vault Reference(s)."); + throw new ArgumentError("Failed to process the Key Vault reference because Key Vault options are not configured."); } - - // precedence: secret clients > credential > secret resolver - const { name: secretName, vaultUrl, sourceId, version } = parseKeyVaultSecretIdentifier( - parseSecretReference(setting).value.secretId - ); - - const client = this.#getSecretClient(new URL(vaultUrl)); - if (client) { - // TODO: what if error occurs when reading a key vault value? Now it breaks the whole load. - const secret = await client.getSecret(secretName, { version }); - return [setting.key, secret.value]; + let secretName, vaultUrl, sourceId, version; + try { + const { name: parsedName, vaultUrl: parsedVaultUrl, sourceId: parsedSourceId, version: parsedVersion } = parseKeyVaultSecretIdentifier( + parseSecretReference(setting).value.secretId + ); + secretName = parsedName; + vaultUrl = parsedVaultUrl; + sourceId = parsedSourceId; + version = parsedVersion; + } catch (error) { + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", setting), { cause: error }); } - if (this.#keyVaultOptions.secretResolver) { - return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(sourceId))]; + try { + // precedence: secret clients > credential > secret resolver + const client = this.#getSecretClient(new URL(vaultUrl)); + if (client) { + const secret = await client.getSecret(secretName, { version }); + return [setting.key, secret.value]; + } + if (this.#keyVaultOptions.secretResolver) { + return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(sourceId))]; + } + } catch (error) { + if (isRestError(error) || error instanceof AuthenticationError) { + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, sourceId), { cause: error }); + } + throw error; } - throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); + // When code reaches here, it means that the key vault reference cannot be resolved in all possible ways. + throw new ArgumentError("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); } + /** + * + * @param vaultUrl - The url of the key vault. + * @returns + */ #getSecretClient(vaultUrl: URL): SecretClient | undefined { if (this.#secretClients === undefined) { this.#secretClients = new Map(); - for (const c of this.#keyVaultOptions?.secretClients ?? []) { - this.#secretClients.set(getHost(c.vaultUrl), c); + for (const client of this.#keyVaultOptions?.secretClients ?? []) { + const clientUrl = new URL(client.vaultUrl); + this.#secretClients.set(clientUrl.host, client); } } @@ -70,6 +93,6 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { } } -function getHost(url: string) { - return new URL(url).host; +function buildKeyVaultReferenceErrorMessage(message: string, setting: ConfigurationSetting, secretIdentifier?: string ): string { + return `${message} Key: '${setting.key}' Label: '${setting.label ?? ""}' ETag: '${setting.etag ?? ""}' ${secretIdentifier ? ` SecretIdentifier: '${secretIdentifier}'` : ""}`; } diff --git a/src/load.ts b/src/load.ts index 4d24174e..2046b064 100644 --- a/src/load.ts +++ b/src/load.ts @@ -5,9 +5,10 @@ import { TokenCredential } from "@azure/identity"; import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; -import { ConfigurationClientManager, instanceOfTokenCredential } from "./ConfigurationClientManager.js"; +import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; +import { instanceOfTokenCredential } from "./common/utils.js"; -const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5000; // 5 seconds +const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5_000; // 5 seconds /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts index 45fdf0b3..5dff67fd 100644 --- a/src/refresh/RefreshTimer.ts +++ b/src/refresh/RefreshTimer.ts @@ -5,11 +5,9 @@ export class RefreshTimer { #backoffEnd: number; // Timestamp #interval: number; - constructor( - interval: number - ) { + constructor(interval: number) { if (interval <= 0) { - throw new Error(`Refresh interval must be greater than 0. Given: ${this.#interval}`); + throw new RangeError(`Refresh interval must be greater than 0. Given: ${interval}`); } this.#interval = interval; diff --git a/src/RefreshOptions.ts b/src/refresh/refreshOptions.ts similarity index 94% rename from src/RefreshOptions.ts rename to src/refresh/refreshOptions.ts index d5e4da5f..202c7340 100644 --- a/src/RefreshOptions.ts +++ b/src/refresh/refreshOptions.ts @@ -1,44 +1,44 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WatchedSetting } from "./WatchedSetting.js"; - -export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30 * 1000; -export const MIN_REFRESH_INTERVAL_IN_MS = 1 * 1000; - -export interface RefreshOptions { - /** - * Specifies whether the provider should automatically refresh when the configuration is changed. - */ - enabled: boolean; - - /** - * Specifies the minimum time that must elapse before checking the server for any new changes. - * Default value is 30 seconds. Must be greater than 1 second. - * Any refresh operation triggered will not update the value for a key until after the interval. - */ - refreshIntervalInMs?: number; - - /** - * One or more configuration settings to be watched for changes on the server. - * Any modifications to watched settings will refresh all settings loaded by the configuration provider when refresh() is called. - * - * @remarks - * If no watched setting is specified, all configuration settings will be watched. - */ - watchedSettings?: WatchedSetting[]; -} - -export interface FeatureFlagRefreshOptions { - /** - * Specifies whether the provider should automatically refresh all feature flags if any feature flag changes. - */ - enabled: boolean; - - /** - * Specifies the minimum time that must elapse before checking the server for any new changes. - * Default value is 30 seconds. Must be greater than 1 second. - * Any refresh operation triggered will not update the value for a key until after the interval. - */ - refreshIntervalInMs?: number; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WatchedSetting } from "../WatchedSetting.js"; + +export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30 * 1000; +export const MIN_REFRESH_INTERVAL_IN_MS = 1 * 1000; + +export interface RefreshOptions { + /** + * Specifies whether the provider should automatically refresh when the configuration is changed. + */ + enabled: boolean; + + /** + * Specifies the minimum time that must elapse before checking the server for any new changes. + * Default value is 30 seconds. Must be greater than 1 second. + * Any refresh operation triggered will not update the value for a key until after the interval. + */ + refreshIntervalInMs?: number; + + /** + * One or more configuration settings to be watched for changes on the server. + * Any modifications to watched settings will refresh all settings loaded by the configuration provider when refresh() is called. + * + * @remarks + * If no watched setting is specified, all configuration settings will be watched. + */ + watchedSettings?: WatchedSetting[]; +} + +export interface FeatureFlagRefreshOptions { + /** + * Specifies whether the provider should automatically refresh all feature flags if any feature flag changes. + */ + enabled: boolean; + + /** + * Specifies the minimum time that must elapse before checking the server for any new changes. + * Default value is 30 seconds. Must be greater than 1 second. + * Any refresh operation triggered will not update the value for a key until after the interval. + */ + refreshIntervalInMs?: number; +} diff --git a/test/clientOptions.test.ts b/test/clientOptions.test.ts index 2e9417e9..3401c19a 100644 --- a/test/clientOptions.test.ts +++ b/test/clientOptions.test.ts @@ -48,6 +48,9 @@ describe("custom client options", function () { policy: countPolicy, position: "perRetry" }] + }, + startupOptions: { + timeoutInMs: 5_000 } }); }; @@ -73,6 +76,9 @@ describe("custom client options", function () { retryOptions: { maxRetries } + }, + startupOptions: { + timeoutInMs: 5_000 } }); }; @@ -108,6 +114,9 @@ describe("custom client options", function () { policy: countPolicy, position: "perRetry" }] + }, + startupOptions: { + timeoutInMs: 5_000 } }); }; diff --git a/test/failover.test.ts b/test/failover.test.ts index e1f2f043..e7b491d7 100644 --- a/test/failover.test.ts +++ b/test/failover.test.ts @@ -64,14 +64,6 @@ describe("failover", function () { expect(settings.get("feature_management").feature_flags).not.undefined; }); - it("should throw error when all clients failed", async () => { - const isFailoverable = false; - mockConfigurationManagerGetClients([], isFailoverable); - - const connectionString = createMockedConnectionString(); - return expect(load(connectionString)).eventually.rejectedWith("All clients failed to get configuration settings."); - }); - it("should validate endpoint", () => { const fakeHost = "fake.azconfig.io"; const validDomain = getValidDomain(fakeHost); diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index e88044ea..219a0bda 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -26,6 +26,7 @@ function mockNewlyCreatedKeyVaultSecretClients() { // eslint-disable-next-line @typescript-eslint/no-unused-vars mockSecretClientGetSecret(mockedData.map(([_key, secretUri, value]) => [secretUri, value])); } + describe("key vault reference", function () { this.timeout(MAX_TIME_OUT); @@ -39,7 +40,15 @@ describe("key vault reference", function () { }); it("require key vault options to resolve reference", async () => { - return expect(load(createMockedConnectionString())).eventually.rejectedWith("Configure keyVaultOptions to resolve Key Vault Reference(s)."); + try { + await load(createMockedConnectionString()); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Failed to process the Key Vault reference because Key Vault options are not configured."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); }); it("should resolve key vault reference with credential", async () => { @@ -88,14 +97,21 @@ describe("key vault reference", function () { }); it("should throw error when secret clients not provided for all key vault references", async () => { - const loadKeyVaultPromise = load(createMockedConnectionString(), { - keyVaultOptions: { - secretClients: [ - new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), - ] - } - }); - return expect(loadKeyVaultPromise).eventually.rejectedWith("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); + try { + await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), + ] + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); }); it("should fallback to use default credential when corresponding secret client not provided", async () => { diff --git a/test/load.test.ts b/test/load.test.ts index d36a3311..599392a4 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -114,12 +114,12 @@ describe("load", function () { }); it("should throw error given invalid connection string", async () => { - return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string."); + return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string"); }); it("should throw error given invalid endpoint URL", async () => { const credential = createMockedTokenCredential(); - return expect(load("invalid-endpoint-url", credential)).eventually.rejectedWith("Invalid endpoint URL."); + return expect(load("invalid-endpoint-url", credential)).eventually.rejectedWith("Invalid URL"); }); it("should not include feature flags directly in the settings", async () => { @@ -359,7 +359,7 @@ describe("load", function () { * When constructConfigurationObject() is called, it first constructs from key "app5.settings.fontColor" and then from key "app5.settings". * An error will be thrown when constructing from key "app5.settings" because there is ambiguity between the two keys. */ - it("Edge case 1: Hierarchical key-value pairs with overlapped key prefix.", async () => { + it("Edge case 2: Hierarchical key-value pairs with overlapped key prefix.", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { selectors: [{ diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 3179602a..0b18f4b5 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -35,7 +35,12 @@ describe("request tracing", function () { it("should have correct user agent prefix", async () => { try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); @@ -43,7 +48,12 @@ describe("request tracing", function () { it("should have request type in correlation-context header", async () => { try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; expect(headerPolicy.headers.get("Correlation-Context")).eq("RequestType=Startup"); @@ -55,6 +65,9 @@ describe("request tracing", function () { clientOptions, keyVaultOptions: { credential: createMockedTokenCredential() + }, + startupOptions: { + timeoutInMs: 1 } }); } catch (e) { /* empty */ } @@ -69,6 +82,9 @@ describe("request tracing", function () { await load(createMockedConnectionString(fakeEndpoint), { clientOptions, loadBalancingEnabled: true, + startupOptions: { + timeoutInMs: 1 + } }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; @@ -81,7 +97,12 @@ describe("request tracing", function () { const replicaCount = 2; sinon.stub(ConfigurationClientManager.prototype, "getReplicaCount").returns(replicaCount); try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -93,7 +114,12 @@ describe("request tracing", function () { it("should detect env in correlation-context header", async () => { process.env.NODE_ENV = "development"; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -105,7 +131,12 @@ describe("request tracing", function () { it("should detect host type in correlation-context header", async () => { process.env.WEBSITE_SITE_NAME = "website-name"; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -118,7 +149,12 @@ describe("request tracing", function () { for (const indicator of ["TRUE", "true"]) { process.env.AZURE_APP_CONFIGURATION_TRACING_DISABLED = indicator; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -139,13 +175,13 @@ describe("request tracing", function () { clientOptions, refreshOptions: { enabled: true, - refreshIntervalInMs: 1000, + refreshIntervalInMs: 1_000, watchedSettings: [{ key: "app.settings.fontColor" }] } }); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -175,7 +211,7 @@ describe("request tracing", function () { selectors: [ {keyFilter: "*"} ], refresh: { enabled: true, - refreshIntervalInMs: 1000 + refreshIntervalInMs: 1_000 } } }); @@ -183,7 +219,7 @@ describe("request tracing", function () { expect(correlationContext).not.undefined; expect(correlationContext?.includes("RequestType=Startup")).eq(true); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -213,7 +249,7 @@ describe("request tracing", function () { selectors: [ {keyFilter: "*"} ], refresh: { enabled: true, - refreshIntervalInMs: 1000 + refreshIntervalInMs: 1_000 } } }); @@ -221,7 +257,7 @@ describe("request tracing", function () { expect(correlationContext).not.undefined; expect(correlationContext?.includes("RequestType=Startup")).eq(true); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -249,7 +285,7 @@ describe("request tracing", function () { selectors: [ {keyFilter: "*"} ], refresh: { enabled: true, - refreshIntervalInMs: 1000 + refreshIntervalInMs: 1_000 } } }); @@ -257,7 +293,7 @@ describe("request tracing", function () { expect(correlationContext).not.undefined; expect(correlationContext?.includes("RequestType=Startup")).eq(true); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -286,7 +322,7 @@ describe("request tracing", function () { selectors: [ {keyFilter: "*"} ], refresh: { enabled: true, - refreshIntervalInMs: 1000 + refreshIntervalInMs: 1_000 } } }); @@ -294,7 +330,7 @@ describe("request tracing", function () { expect(correlationContext).not.undefined; expect(correlationContext?.includes("RequestType=Startup")).eq(true); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -374,7 +410,12 @@ describe("request tracing", function () { (global as any).importScripts = function importScripts() { }; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -392,7 +433,12 @@ describe("request tracing", function () { (global as any).importScripts = function importScripts() { }; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -410,7 +456,12 @@ describe("request tracing", function () { (global as any).importScripts = function importScripts() { }; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -428,7 +479,12 @@ describe("request tracing", function () { (global as any).importScripts = function importScripts() { }; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -446,7 +502,12 @@ describe("request tracing", function () { (global as any).importScripts = undefined; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -484,7 +545,12 @@ describe("request tracing", function () { (global as any).document = new (global as any).Document(); try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -499,7 +565,12 @@ describe("request tracing", function () { (global as any).document = undefined; // not an instance of Document try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -514,7 +585,12 @@ describe("request tracing", function () { (global as any).document = {}; // Not an instance of Document try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -529,7 +605,12 @@ describe("request tracing", function () { (global as any).document = new (global as any).Document(); try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -544,7 +625,12 @@ describe("request tracing", function () { (global as any).document = new (global as any).Document(); try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); diff --git a/test/startup.test.ts b/test/startup.test.ts new file mode 100644 index 00000000..51b46a3a --- /dev/null +++ b/test/startup.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; +import { load } from "./exportedApi"; +import { MAX_TIME_OUT, createMockedConnectionString, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js"; + +describe("startup", function () { + this.timeout(MAX_TIME_OUT); + + afterEach(() => { + restoreMocks(); + }); + + it("should retry for load operation before timeout", async () => { + let attempt = 0; + const failForInitialAttempt = () => { + attempt += 1; + if (attempt <= 1) { + throw new Error("Test Error"); + } + }; + mockAppConfigurationClientListConfigurationSettings( + [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)], + failForInitialAttempt); + + const settings = await load(createMockedConnectionString()); + expect(attempt).eq(2); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("TestValue"); + }); + + it("should not retry for load operation after timeout", async () => { + let attempt = 0; + const failForAllAttempts = () => { + attempt += 1; + throw new Error("Test Error"); + }; + mockAppConfigurationClientListConfigurationSettings( + [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)], + failForAllAttempts); + + try { + await load(createMockedConnectionString(), { + startupOptions: { + timeoutInMs: 5_000 + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Load operation timed out."); + expect(attempt).eq(1); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + + it("should not retry on non-retriable TypeError", async () => { + let attempt = 0; + const failForAllAttempts = () => { + attempt += 1; + throw new TypeError("Non-retriable Test Error"); + }; + mockAppConfigurationClientListConfigurationSettings( + [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)], + failForAllAttempts); + + try { + await load(createMockedConnectionString(), { + startupOptions: { + timeoutInMs: 10_000 + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Non-retriable Test Error"); + expect(attempt).eq(1); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + + it("should not retry on non-retriable RangeError", async () => { + let attempt = 0; + const failForAllAttempts = () => { + attempt += 1; + throw new RangeError("Non-retriable Test Error"); + }; + mockAppConfigurationClientListConfigurationSettings( + [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)], + failForAllAttempts); + + try { + await load(createMockedConnectionString(), { + startupOptions: { + timeoutInMs: 10_000 + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Non-retriable Test Error"); + expect(attempt).eq(1); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); +});