diff --git a/package-lock.json b/package-lock.json index 498f59e1..7cc0a774 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2524,6 +2524,7 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -2869,6 +2870,7 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "dev": true, + "license": "MIT", "dependencies": { "isarray": "0.0.1" } @@ -3085,6 +3087,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 977872a8..08f3bb3e 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -35,6 +35,7 @@ import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAd import { RefreshTimer } from "./refresh/RefreshTimer.js"; import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; +import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; type PagedSettingSelector = SettingSelector & { /** @@ -56,10 +57,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ #sortedTrimKeyPrefixes: string[] | undefined; readonly #requestTracingEnabled: boolean; - #client: AppConfigurationClient; - #clientEndpoint: string | undefined; + #clientManager: ConfigurationClientManager; #options: AzureAppConfigurationOptions | undefined; #isInitialLoadCompleted: boolean = false; + #isFailoverRequest: boolean = false; // Refresh #refreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; @@ -78,13 +79,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #featureFlagSelectors: PagedSettingSelector[] = []; constructor( - client: AppConfigurationClient, - clientEndpoint: string | undefined, - options: AzureAppConfigurationOptions | undefined + clientManager: ConfigurationClientManager, + options: AzureAppConfigurationOptions | undefined, ) { - this.#client = client; - this.#clientEndpoint = clientEndpoint; this.#options = options; + this.#clientManager = clientManager; // Enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); @@ -197,35 +196,66 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return { requestTracingEnabled: this.#requestTracingEnabled, initialLoadCompleted: this.#isInitialLoadCompleted, - appConfigOptions: this.#options + appConfigOptions: this.#options, + isFailoverRequest: this.#isFailoverRequest }; } - async #loadSelectedKeyValues(): Promise { - const loadedSettings: ConfigurationSetting[] = []; + async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { + const clientWrappers = await this.#clientManager.getClients(); - // validate selectors - const selectors = getValidKeyValueSelectors(this.#options?.selectors); + let successful: boolean; + for (const clientWrapper of clientWrappers) { + successful = false; + try { + const result = await funcToExecute(clientWrapper.client); + this.#isFailoverRequest = false; + successful = true; + clientWrapper.updateBackoffStatus(successful); + return result; + } catch (error) { + if (isFailoverableError(error)) { + clientWrapper.updateBackoffStatus(successful); + this.#isFailoverRequest = true; + continue; + } - for (const selector of selectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter - }; + throw error; + } + } - const settings = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - this.#client, - listOptions - ); + this.#clientManager.refreshClients(); + throw new Error("All clients failed to get configuration settings."); + } - for await (const setting of settings) { - if (!isFeatureFlag(setting)) { // exclude feature flags - loadedSettings.push(setting); + async #loadSelectedKeyValues(): Promise { + // validate selectors + const selectors = getValidKeyValueSelectors(this.#options?.selectors); + + const funcToExecute = async (client) => { + const loadedSettings: ConfigurationSetting[] = []; + for (const selector of selectors) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: selector.keyFilter, + labelFilter: selector.labelFilter + }; + + const settings = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ); + + for await (const setting of settings) { + if (!isFeatureFlag(setting)) { // exclude feature flags + loadedSettings.push(setting); + } } } - } - return loadedSettings; + return loadedSettings; + }; + + return await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; } /** @@ -279,29 +309,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } async #loadFeatureFlags() { - const featureFlagSettings: ConfigurationSetting[] = []; - for (const selector of this.#featureFlagSelectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, - labelFilter: selector.labelFilter - }; + // Temporary map to store feature flags, key is the key of the setting, value is the raw value of the setting + const funcToExecute = async (client) => { + const featureFlagSettings: ConfigurationSetting[] = []; + // deep copy selectors to avoid modification if current client fails + const selectors = JSON.parse( + JSON.stringify(this.#featureFlagSelectors) + ); - const pageEtags: string[] = []; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - this.#client, - listOptions - ).byPage(); - for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); - for (const setting of page.items) { - if (isFeatureFlag(setting)) { - featureFlagSettings.push(setting); + for (const selector of selectors) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, + labelFilter: selector.labelFilter + }; + + const pageEtags: string[] = []; + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + for await (const page of pageIterator) { + pageEtags.push(page.etag ?? ""); + for (const setting of page.items) { + if (isFeatureFlag(setting)) { + featureFlagSettings.push(setting); + } } } + selector.pageEtags = pageEtags; } - selector.pageEtags = pageEtags; - } + + this.#featureFlagSelectors = selectors; + return featureFlagSettings; + }; + + const featureFlagSettings = await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; // parse feature flags const featureFlags = await Promise.all( @@ -389,7 +432,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // check if any refresh task failed for (const result of results) { if (result.status === "rejected") { - throw result.reason; + console.warn("Refresh failed:", result.reason); } } @@ -430,13 +473,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (needRefresh) { - try { - await this.#loadSelectedAndWatchedKeyValues(); - } catch (error) { - // if refresh failed, backoff - this.#refreshTimer.backoff(); - throw error; - } + await this.#loadSelectedAndWatchedKeyValues(); } this.#refreshTimer.reset(); @@ -454,39 +491,32 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } // check if any feature flag is changed - let needRefresh = false; - for (const selector of this.#featureFlagSelectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, - labelFilter: selector.labelFilter, - pageEtags: selector.pageEtags - }; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - this.#client, - listOptions - ).byPage(); - - for await (const page of pageIterator) { - if (page._response.status === 200) { // created or changed - needRefresh = true; - break; + const funcToExecute = async (client) => { + for (const selector of this.#featureFlagSelectors) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, + labelFilter: selector.labelFilter, + pageEtags: selector.pageEtags + }; + + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + + for await (const page of pageIterator) { + if (page._response.status === 200) { // created or changed + return true; + } } } + return false; + }; - if (needRefresh) { - break; // short-circuit if result from any of the selectors is changed - } - } - + const needRefresh: boolean = await this.#executeWithFailoverPolicy(funcToExecute); if (needRefresh) { - try { - await this.#loadFeatureFlags(); - } catch (error) { - // if refresh failed, backoff - this.#featureFlagRefreshTimer.backoff(); - throw error; - } + await this.#loadFeatureFlags(); } this.#featureFlagRefreshTimer.reset(); @@ -540,14 +570,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Get a configuration setting by key and label. If the setting is not found, return undefine instead of throwing an error. */ async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { - let response: GetConfigurationSettingResponse | undefined; - try { - response = await getConfigurationSettingWithTrace( + const funcToExecute = async (client) => { + return getConfigurationSettingWithTrace( this.#requestTraceOptions, - this.#client, + client, configurationSettingId, customOptions ); + }; + + let response: GetConfigurationSettingResponse | undefined; + try { + response = await this.#executeWithFailoverPolicy(funcToExecute); } catch (error) { if (isRestError(error) && error.statusCode === 404) { response = undefined; @@ -634,7 +668,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } #createFeatureFlagReference(setting: ConfigurationSetting): string { - let featureFlagReference = `${this.#clientEndpoint}kv/${setting.key}`; + let featureFlagReference = `${this.#clientManager.endpoint.origin}/kv/${setting.key}`; if (setting.label && setting.label.trim().length !== 0) { featureFlagReference += `?label=${setting.label}`; } @@ -794,3 +828,9 @@ 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 f88ad67c..4aa3f99d 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -12,7 +12,7 @@ export const MaxRetryDelayInMs = 60000; export interface AzureAppConfigurationOptions { /** - * Specify what key-values to include in the configuration provider. + * Specifies what key-values to include in the configuration provider. * * @remarks * If no selectors are specified then all key-values with no label will be included. @@ -47,4 +47,12 @@ export interface AzureAppConfigurationOptions { * Specifies options used to configure feature flags. */ featureFlagOptions?: FeatureFlagOptions; + + /** + * Specifies whether to enable replica discovery or not. + * + * @remarks + * If not specified, the default value is true. + */ + replicaDiscoveryEnabled?: boolean; } diff --git a/src/ConfigurationClientManager.ts b/src/ConfigurationClientManager.ts new file mode 100644 index 00000000..59e03aa5 --- /dev/null +++ b/src/ConfigurationClientManager.ts @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +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 { isBrowser, isWebWorker } from "./requestTracing/utils.js"; +import * as RequestTracing from "./requestTracing/constants.js"; +import { shuffleList } from "./common/utils.js"; + +const TCP_ORIGIN_KEY_NAME = "_origin._tcp"; +const ALT_KEY_NAME = "_alt"; +const TCP_KEY_NAME = "_tcp"; +const ENDPOINT_KEY_NAME = "Endpoint"; +const ID_KEY_NAME = "Id"; +const SECRET_KEY_NAME = "Secret"; +const TRUSTED_DOMAIN_LABELS = [".azconfig.", ".appconfig."]; +const FALLBACK_CLIENT_REFRESH_EXPIRE_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds +const MINIMAL_CLIENT_REFRESH_INTERVAL = 30 * 1000; // 30 seconds in milliseconds +const SRV_QUERY_TIMEOUT = 30 * 1000; // 30 seconds in milliseconds + +export class ConfigurationClientManager { + #isFailoverable: boolean; + #dns: any; + endpoint: URL; + #secret : string; + #id : string; + #credential: TokenCredential; + #clientOptions: AppConfigurationClientOptions | undefined; + #appConfigOptions: AzureAppConfigurationOptions | undefined; + #validDomain: string; + #staticClients: ConfigurationClientWrapper[]; + #dynamicClients: ConfigurationClientWrapper[]; + #lastFallbackClientRefreshTime: number = 0; + #lastFallbackClientRefreshAttempt: number = 0; + + constructor ( + connectionStringOrEndpoint?: string | URL, + credentialOrOptions?: TokenCredential | AzureAppConfigurationOptions, + appConfigOptions?: AzureAppConfigurationOptions + ) { + let staticClient: AppConfigurationClient; + const credentialPassed = instanceOfTokenCredential(credentialOrOptions); + + if (typeof connectionStringOrEndpoint === "string" && !credentialPassed) { + const connectionString = connectionStringOrEndpoint; + this.#appConfigOptions = credentialOrOptions as AzureAppConfigurationOptions; + this.#clientOptions = getClientOptions(this.#appConfigOptions); + const ConnectionStringRegex = /Endpoint=(.*);Id=(.*);Secret=(.*)/; + const regexMatch = connectionString.match(ConnectionStringRegex); + if (regexMatch) { + const endpointFromConnectionStr = regexMatch[1]; + this.endpoint = getValidUrl(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}'.`); + } + 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); + } + + const credential = credentialOrOptions as TokenCredential; + this.#appConfigOptions = appConfigOptions as AzureAppConfigurationOptions; + this.#clientOptions = getClientOptions(this.#appConfigOptions); + this.endpoint = endpoint; + 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."); + } + + this.#staticClients = [new ConfigurationClientWrapper(this.endpoint.origin, staticClient)]; + this.#validDomain = getValidDomain(this.endpoint.hostname.toLowerCase()); + } + + async init() { + if (this.#appConfigOptions?.replicaDiscoveryEnabled === false || isBrowser() || isWebWorker()) { + this.#isFailoverable = false; + return; + } + + try { + this.#dns = await import("dns/promises"); + }catch (error) { + this.#isFailoverable = false; + console.warn("Failed to load the dns module:", error.message); + return; + } + + this.#isFailoverable = true; + } + + async getClients() : Promise { + if (!this.#isFailoverable) { + return this.#staticClients; + } + + const currentTime = Date.now(); + // Filter static clients whose backoff time has ended + let availableClients = this.#staticClients.filter(client => client.backoffEndTime <= currentTime); + if (currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL && + (!this.#dynamicClients || + // All dynamic clients are in backoff means no client is available + this.#dynamicClients.every(client => currentTime < client.backoffEndTime) || + currentTime >= this.#lastFallbackClientRefreshTime + FALLBACK_CLIENT_REFRESH_EXPIRE_INTERVAL)) { + this.#lastFallbackClientRefreshAttempt = currentTime; + await this.#discoverFallbackClients(this.endpoint.hostname); + return availableClients.concat(this.#dynamicClients); + } + + // If there are dynamic clients, filter and concatenate them + if (this.#dynamicClients && this.#dynamicClients.length > 0) { + availableClients = availableClients.concat( + this.#dynamicClients + .filter(client => client.backoffEndTime <= currentTime)); + } + + return availableClients; + } + + async refreshClients() { + const currentTime = Date.now(); + if (this.#isFailoverable && + currentTime >= new Date(this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL).getTime()) { + this.#lastFallbackClientRefreshAttempt = currentTime; + await this.#discoverFallbackClients(this.endpoint.hostname); + } + } + + async #discoverFallbackClients(host: string) { + let result; + try { + result = await Promise.race([ + new Promise((_, reject) => setTimeout(() => reject(new Error("SRV record query timed out.")), SRV_QUERY_TIMEOUT)), + this.#querySrvTargetHost(host) + ]); + } catch (error) { + throw new Error(`Failed to build fallback clients, ${error.message}`); + } + + const srvTargetHosts = shuffleList(result) as string[]; + const newDynamicClients: ConfigurationClientWrapper[] = []; + for (const host of srvTargetHosts) { + if (isValidEndpoint(host, this.#validDomain)) { + const targetEndpoint = `https://${host}`; + if (host.toLowerCase() === this.endpoint.hostname.toLowerCase()) { + continue; + } + const client = this.#credential ? + new AppConfigurationClient(targetEndpoint, this.#credential, this.#clientOptions) : + new AppConfigurationClient(buildConnectionString(targetEndpoint, this.#secret, this.#id), this.#clientOptions); + newDynamicClients.push(new ConfigurationClientWrapper(targetEndpoint, client)); + } + } + + this.#dynamicClients = newDynamicClients; + this.#lastFallbackClientRefreshTime = Date.now(); + } + + /** + * Query SRV records and return target hosts. + */ + async #querySrvTargetHost(host: string): Promise { + const results: string[] = []; + + try { + // Look up SRV records for the origin host + const originRecords = await this.#dns.resolveSrv(`${TCP_ORIGIN_KEY_NAME}.${host}`); + if (originRecords.length === 0) { + return results; + } + + // Add the first origin record to results + const originHost = originRecords[0].name; + results.push(originHost); + + // Look up SRV records for alternate hosts + let index = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + const currentAlt = `${ALT_KEY_NAME}${index}`; + const altRecords = await this.#dns.resolveSrv(`${currentAlt}.${TCP_KEY_NAME}.${originHost}`); + if (altRecords.length === 0) { + break; // No more alternate records, exit loop + } + + altRecords.forEach(record => { + const altHost = record.name; + if (altHost) { + results.push(altHost); + } + }); + index++; + } + } catch (err) { + if (err.code === "ENOTFOUND") { + return results; // No more SRV records found, return results + } else { + throw new Error(`Failed to lookup SRV records: ${err.message}`); + } + } + + return results; + } +} + +/** + * Builds a connection string from the given endpoint, secret, and id. + * Returns an empty string if either secret or id is empty. + */ +function buildConnectionString(endpoint, secret, id: string): string { + if (!secret || !id) { + return ""; + } + + return `${ENDPOINT_KEY_NAME}=${endpoint};${ID_KEY_NAME}=${id};${SECRET_KEY_NAME}=${secret}`; +} + +/** + * Extracts a valid domain from the given endpoint URL based on trusted domain labels. + */ +export function getValidDomain(host: string): string { + for (const label of TRUSTED_DOMAIN_LABELS) { + const index = host.lastIndexOf(label); + if (index !== -1) { + return host.substring(index); + } + } + + return ""; +} + +/** + * Checks if the given host ends with the valid domain. + */ +export function isValidEndpoint(host: string, validDomain: string): boolean { + if (!validDomain) { + return false; + } + + return host.toLowerCase().endsWith(validDomain.toLowerCase()); +} + +function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurationClientOptions | undefined { + // user-agent + let userAgentPrefix = RequestTracing.USER_AGENT_PREFIX; // Default UA for JavaScript Provider + const userAgentOptions = options?.clientOptions?.userAgentOptions; + if (userAgentOptions?.userAgentPrefix) { + userAgentPrefix = `${userAgentOptions.userAgentPrefix} ${userAgentPrefix}`; // Prepend if UA prefix specified by user + } + + // retry options + const defaultRetryOptions = { + maxRetries: MaxRetries, + maxRetryDelayInMs: MaxRetryDelayInMs, + }; + const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions); + + return Object.assign({}, options?.clientOptions, { + retryOptions, + userAgentOptions: { + userAgentPrefix + } + }); +} + +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 new file mode 100644 index 00000000..7dd6f418 --- /dev/null +++ b/src/ConfigurationClientWrapper.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// 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 MAX_SAFE_EXPONENTIAL = 30; // Used to avoid overflow. bitwise operations in JavaScript are limited to 32 bits. It overflows at 2^31 - 1. +const JITTER_RATIO = 0.25; + +export class ConfigurationClientWrapper { + endpoint: string; + client: AppConfigurationClient; + backoffEndTime: number = 0; // Timestamp + #failedAttempts: number = 0; + + constructor(endpoint: string, client: AppConfigurationClient) { + this.endpoint = endpoint; + this.client = client; + } + + updateBackoffStatus(successfull: boolean) { + if (successfull) { + this.#failedAttempts = 0; + this.backoffEndTime = Date.now(); + } else { + this.#failedAttempts += 1; + this.backoffEndTime = Date.now() + calculateBackoffDuration(this.#failedAttempts); + } + } +} + +export function calculateBackoffDuration(failedAttempts: number) { + if (failedAttempts <= 1) { + return MinBackoffDuration; + } + + // exponential: minBackoff * 2 ^ (failedAttempts - 1) + const exponential = Math.min(failedAttempts - 1, MAX_SAFE_EXPONENTIAL); + let calculatedBackoffDuration = MinBackoffDuration * (1 << exponential); + 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/common/utils.ts b/src/common/utils.ts index ad827bbb..8682484b 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -22,3 +22,11 @@ export function jsonSorter(key, value) { } return value; } + +export function shuffleList(array: T[]): T[] { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} diff --git a/src/load.ts b/src/load.ts index ce3d39c2..4d24174e 100644 --- a/src/load.ts +++ b/src/load.ts @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration"; import { TokenCredential } from "@azure/identity"; import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; -import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions.js"; -import * as RequestTracing from "./requestTracing/constants.js"; +import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; +import { ConfigurationClientManager, instanceOfTokenCredential } from "./ConfigurationClientManager.js"; const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5000; // 5 seconds @@ -31,43 +30,18 @@ export async function load( appConfigOptions?: AzureAppConfigurationOptions ): Promise { const startTimestamp = Date.now(); - let client: AppConfigurationClient; - let clientEndpoint: string | undefined; let options: AzureAppConfigurationOptions | undefined; + const clientManager = new ConfigurationClientManager(connectionStringOrEndpoint, credentialOrOptions, appConfigOptions); + await clientManager.init(); - // input validation - if (typeof connectionStringOrEndpoint === "string" && !instanceOfTokenCredential(credentialOrOptions)) { - const connectionString = connectionStringOrEndpoint; + if (!instanceOfTokenCredential(credentialOrOptions)) { options = credentialOrOptions as AzureAppConfigurationOptions; - const clientOptions = getClientOptions(options); - client = new AppConfigurationClient(connectionString, clientOptions); - clientEndpoint = getEndpoint(connectionStringOrEndpoint); - } else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && instanceOfTokenCredential(credentialOrOptions)) { - // ensure string is a valid URL. - if (typeof connectionStringOrEndpoint === "string") { - try { - const endpointUrl = new URL(connectionStringOrEndpoint); - clientEndpoint = endpointUrl.toString(); - } catch (error) { - if (error.code === "ERR_INVALID_URL") { - throw new Error("Invalid endpoint URL.", { cause: error }); - } else { - throw error; - } - } - } else { - clientEndpoint = connectionStringOrEndpoint.toString(); - } - const credential = credentialOrOptions as TokenCredential; - options = appConfigOptions; - const clientOptions = getClientOptions(options); - client = new AppConfigurationClient(clientEndpoint, credential, clientOptions); } else { - throw new Error("A connection string or an endpoint with credential must be specified to create a client."); + options = appConfigOptions; } try { - const appConfiguration = new AzureAppConfigurationImpl(client, clientEndpoint, options); + const appConfiguration = new AzureAppConfigurationImpl(clientManager, options); await appConfiguration.load(); return appConfiguration; } catch (error) { @@ -81,45 +55,3 @@ export async function load( throw error; } } - -function instanceOfTokenCredential(obj: unknown) { - return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function"; -} - -function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurationClientOptions | undefined { - // user-agent - let userAgentPrefix = RequestTracing.USER_AGENT_PREFIX; // Default UA for JavaScript Provider - const userAgentOptions = options?.clientOptions?.userAgentOptions; - if (userAgentOptions?.userAgentPrefix) { - userAgentPrefix = `${userAgentOptions.userAgentPrefix} ${userAgentPrefix}`; // Prepend if UA prefix specified by user - } - - // retry options - const defaultRetryOptions = { - maxRetries: MaxRetries, - maxRetryDelayInMs: MaxRetryDelayInMs, - }; - const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions); - - return Object.assign({}, options?.clientOptions, { - retryOptions, - userAgentOptions: { - userAgentPrefix - } - }); -} - -function getEndpoint(connectionString: string): string | undefined { - const parts = connectionString.split(";"); - const endpointPart = parts.find(part => part.startsWith("Endpoint=")); - - if (endpointPart) { - let endpoint = endpointPart.split("=")[1]; - if (!endpoint.endsWith("/")) { - endpoint += "/"; - } - return endpoint; - } - - return undefined; -} diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts index ce485947..45fdf0b3 100644 --- a/src/refresh/RefreshTimer.ts +++ b/src/refresh/RefreshTimer.ts @@ -1,30 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -/** - * The backoff time is between the minimum and maximum backoff time, based on the number of attempts. - * An exponential backoff strategy is used, with a jitter factor to prevent clients from retrying at the same time. - * - * The backoff time is calculated as follows: - * - `basic backoff time` = `MinimumBackoffInMs` * 2 ^ `attempts`, and it is no larger than the `MaximumBackoffInMs`. - * - based on jitter ratio, the jittered time is between [-1, 1) * `JitterRatio` * basic backoff time. - * - the final backoff time is the basic backoff time plus the jittered time. - * - * Note: the backoff time usually is no larger than the refresh interval, which is specified by the user. - * - If the interval is less than the minimum backoff, the interval is used. - * - If the interval is between the minimum and maximum backoff, the interval is used as the maximum backoff. - * - Because of the jitter, the maximum backoff time is actually `MaximumBackoffInMs` * (1 + `JitterRatio`). - */ - -const MIN_BACKOFF_IN_MS = 30 * 1000; // 30s -const MAX_BACKOFF_IN_MS = 10 * 60 * 1000; // 10min -const MAX_SAFE_EXPONENTIAL = 30; // Used to avoid overflow. bitwise operations in JavaScript are limited to 32 bits. It overflows at 2^31 - 1. -const JITTER_RATIO = 0.25; - export class RefreshTimer { - #minBackoff: number = MIN_BACKOFF_IN_MS; - #maxBackoff: number = MAX_BACKOFF_IN_MS; - #failedAttempts: number = 0; #backoffEnd: number; // Timestamp #interval: number; @@ -43,43 +20,7 @@ export class RefreshTimer { return Date.now() >= this.#backoffEnd; } - backoff(): void { - this.#failedAttempts += 1; - this.#backoffEnd = Date.now() + this.#calculateBackoffTime(); - } - reset(): void { - this.#failedAttempts = 0; this.#backoffEnd = Date.now() + this.#interval; } - - #calculateBackoffTime(): number { - let minBackoffMs: number; - let maxBackoffMs: number; - if (this.#interval <= this.#minBackoff) { - return this.#interval; - } - - // _minBackoff <= _interval - if (this.#interval <= this.#maxBackoff) { - minBackoffMs = this.#minBackoff; - maxBackoffMs = this.#interval; - } else { - minBackoffMs = this.#minBackoff; - maxBackoffMs = this.#maxBackoff; - } - - // exponential: minBackoffMs * 2^(failedAttempts-1) - const exponential = Math.min(this.#failedAttempts - 1, MAX_SAFE_EXPONENTIAL); - let calculatedBackoffMs = minBackoffMs * (1 << exponential); - if (calculatedBackoffMs > maxBackoffMs) { - calculatedBackoffMs = maxBackoffMs; - } - - // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs - const jitter = JITTER_RATIO * (Math.random() * 2 - 1); - - return calculatedBackoffMs * (1 + jitter); - } - } diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index d46cdfda..60dbb81a 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -45,4 +45,5 @@ export enum RequestType { } // Tag names +export const FAILOVER_REQUEST_TAG = "Failover"; export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index de335737..8a2fdbc4 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -19,7 +19,8 @@ import { REQUEST_TYPE_KEY, RequestType, SERVICE_FABRIC_ENV_VAR, - CORRELATION_CONTEXT_HEADER_NAME + CORRELATION_CONTEXT_HEADER_NAME, + FAILOVER_REQUEST_TAG } from "./constants"; // Utils @@ -28,17 +29,18 @@ export function listConfigurationSettingsWithTrace( requestTracingEnabled: boolean; initialLoadCompleted: boolean; appConfigOptions: AzureAppConfigurationOptions | undefined; + isFailoverRequest: boolean; }, client: AppConfigurationClient, listOptions: ListConfigurationSettingsOptions ) { - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions } = requestTracingOptions; + const { requestTracingEnabled, initialLoadCompleted, appConfigOptions, isFailoverRequest } = requestTracingOptions; const actualListOptions = { ...listOptions }; if (requestTracingEnabled) { actualListOptions.requestOptions = { customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted) + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isFailoverRequest) } }; } @@ -51,18 +53,19 @@ export function getConfigurationSettingWithTrace( requestTracingEnabled: boolean; initialLoadCompleted: boolean; appConfigOptions: AzureAppConfigurationOptions | undefined; + isFailoverRequest: boolean; }, client: AppConfigurationClient, configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions, ) { - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions } = requestTracingOptions; + const { requestTracingEnabled, initialLoadCompleted, appConfigOptions, isFailoverRequest } = requestTracingOptions; const actualGetOptions = { ...getOptions }; if (requestTracingEnabled) { actualGetOptions.requestOptions = { customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted) + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isFailoverRequest) } }; } @@ -70,7 +73,7 @@ export function getConfigurationSettingWithTrace( return client.getConfigurationSetting(configurationSettingId, actualGetOptions); } -export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean): string { +export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean, isFailoverRequest: boolean): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs @@ -100,6 +103,10 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt contextParts.push(tag); } + if (isFailoverRequest) { + contextParts.push(FAILOVER_REQUEST_TAG); + } + return contextParts.join(","); } @@ -146,7 +153,7 @@ function isDevEnvironment(): boolean { return false; } -function isBrowser() { +export function isBrowser() { // https://developer.mozilla.org/en-US/docs/Web/API/Window const isWindowDefinedAsExpected = typeof window === "object" && typeof Window === "function" && window instanceof Window; // https://developer.mozilla.org/en-US/docs/Web/API/Document @@ -155,7 +162,7 @@ function isBrowser() { return isWindowDefinedAsExpected && isDocumentDefinedAsExpected; } -function isWebWorker() { +export function isWebWorker() { // https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope const workerGlobalScopeDefined = typeof WorkerGlobalScope !== "undefined"; // https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator @@ -165,3 +172,4 @@ function isWebWorker() { return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected; } + diff --git a/test/failover.test.ts b/test/failover.test.ts new file mode 100644 index 00000000..c97a127a --- /dev/null +++ b/test/failover.test.ts @@ -0,0 +1,112 @@ +// 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 { createMockedConnectionString, createMockedFeatureFlag, createMockedKeyValue, mockConfigurationManagerGetClients, restoreMocks } from "./utils/testHelper"; +import { getValidDomain, isValidEndpoint } from "../src/ConfigurationClientManager"; + +const mockedKVs = [{ + key: "app.settings.fontColor", + value: "red", +}, { + key: "app.settings.fontSize", + value: "40", +}].map(createMockedKeyValue); + +const mockedFeatureFlags = [{ + key: "app.settings.fontColor", + value: "red", +}].map(createMockedKeyValue).concat([ + createMockedFeatureFlag("Beta", { enabled: true }), + createMockedFeatureFlag("Alpha_1", { enabled: true }), + createMockedFeatureFlag("Alpha_2", { enabled: false }), +]); + +describe("failover", function () { + this.timeout(15000); + + afterEach(() => { + restoreMocks(); + }); + + it("should failover to replica and load key values from config store", async () => { + const isFailoverable = true; + mockConfigurationManagerGetClients(isFailoverable, mockedKVs); + + const connectionString = createMockedConnectionString(); + // replicaDiscoveryEnabled is default to true + const settings = await load(connectionString); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + + it("should failover to replica and load feature flags from config store", async () => { + const isFailoverable = true; + mockConfigurationManagerGetClients(isFailoverable, mockedFeatureFlags); + + const connectionString = createMockedConnectionString(); + // replicaDiscoveryEnabled is default to true + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*" + }] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + 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); + + expect(isValidEndpoint("azure.azconfig.io", validDomain)).to.be.true; + expect(isValidEndpoint("azure.privatelink.azconfig.io", validDomain)).to.be.true; + expect(isValidEndpoint("azure-replica.azconfig.io", validDomain)).to.be.true; + expect(isValidEndpoint("azure.badazconfig.io", validDomain)).to.be.false; + expect(isValidEndpoint("azure.azconfigbad.io", validDomain)).to.be.false; + expect(isValidEndpoint("azure.appconfig.azure.com", validDomain)).to.be.false; + expect(isValidEndpoint("azure.azconfig.bad.io", validDomain)).to.be.false; + + const fakeHost2 = "foobar.appconfig.azure.com"; + const validDomain2 = getValidDomain(fakeHost2); + + expect(isValidEndpoint("azure.appconfig.azure.com", validDomain2)).to.be.true; + expect(isValidEndpoint("azure.z1.appconfig.azure.com", validDomain2)).to.be.true; + expect(isValidEndpoint("azure-replia.z1.appconfig.azure.com", validDomain2)).to.be.true; // Note: Typo "azure-replia" + expect(isValidEndpoint("azure.privatelink.appconfig.azure.com", validDomain2)).to.be.true; + expect(isValidEndpoint("azconfig.appconfig.azure.com", validDomain2)).to.be.true; + expect(isValidEndpoint("azure.azconfig.io", validDomain2)).to.be.false; + expect(isValidEndpoint("azure.badappconfig.azure.com", validDomain2)).to.be.false; + expect(isValidEndpoint("azure.appconfigbad.azure.com", validDomain2)).to.be.false; + + const fakeHost3 = "foobar.azconfig-test.io"; + const validDomain3 = getValidDomain(fakeHost3); + + expect(isValidEndpoint("azure.azconfig-test.io", validDomain3)).to.be.false; + expect(isValidEndpoint("azure.azconfig.io", validDomain3)).to.be.false; + + const fakeHost4 = "foobar.z1.appconfig-test.azure.com"; + const validDomain4 = getValidDomain(fakeHost4); + + expect(isValidEndpoint("foobar.z2.appconfig-test.azure.com", validDomain4)).to.be.false; + expect(isValidEndpoint("foobar.appconfig-test.azure.com", validDomain4)).to.be.false; + expect(isValidEndpoint("foobar.appconfig.azure.com", validDomain4)).to.be.false; + }); +}); diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index d4e7edcf..62d0c5b5 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -54,9 +54,7 @@ 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 }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; expect(headerPolicy.headers.get("Correlation-Context")).eq("RequestType=Startup"); @@ -80,9 +78,7 @@ 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 }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -94,9 +90,7 @@ 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 }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -109,9 +103,7 @@ 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 }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 6e787dd7..261b9b57 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -10,6 +10,8 @@ import { RestError } from "@azure/core-rest-pipeline"; import { promisify } from "util"; const sleepInMs = promisify(setTimeout); import * as crypto from "crypto"; +import { ConfigurationClientManager } from "../../src/ConfigurationClientManager"; +import { ConfigurationClientWrapper } from "../../src/ConfigurationClientWrapper"; const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; @@ -38,6 +40,50 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { }); } +function getMockedIterator(pages: ConfigurationSetting[][], kvs: ConfigurationSetting[], listOptions: any) { + const mockIterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { + [Symbol.asyncIterator](): AsyncIterableIterator { + kvs = _filterKVs(pages.flat(), listOptions); + return this; + }, + next() { + const value = kvs.shift(); + return Promise.resolve({ done: !value, value }); + }, + byPage(): AsyncIterableIterator { + let remainingPages; + const pageEtags = listOptions?.pageEtags ? [...listOptions.pageEtags] : undefined; // a copy of the original list + return { + [Symbol.asyncIterator](): AsyncIterableIterator { + remainingPages = [...pages]; + return this; + }, + next() { + const pageItems = remainingPages.shift(); + const pageEtag = pageEtags?.shift(); + if (pageItems === undefined) { + return Promise.resolve({ done: true, value: undefined }); + } else { + const items = _filterKVs(pageItems ?? [], listOptions); + const etag = _sha256(JSON.stringify(items)); + const statusCode = pageEtag === etag ? 304 : 200; + return Promise.resolve({ + done: false, + value: { + items, + etag, + _response: { status: statusCode } + } + }); + } + } + }; + } + }; + + return mockIterator as any; +} + /** * Mocks the listConfigurationSettings method of AppConfigurationClient to return the provided pages of ConfigurationSetting. * E.g. @@ -49,48 +95,34 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { function mockAppConfigurationClientListConfigurationSettings(...pages: ConfigurationSetting[][]) { sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake((listOptions) => { - let kvs = _filterKVs(pages.flat(), listOptions); - const mockIterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { - [Symbol.asyncIterator](): AsyncIterableIterator { - kvs = _filterKVs(pages.flat(), listOptions); - return this; - }, - next() { - const value = kvs.shift(); - return Promise.resolve({ done: !value, value }); - }, - byPage(): AsyncIterableIterator { - let remainingPages; - const pageEtags = listOptions?.pageEtags ? [...listOptions.pageEtags] : undefined; // a copy of the original list - return { - [Symbol.asyncIterator](): AsyncIterableIterator { - remainingPages = [...pages]; - return this; - }, - next() { - const pageItems = remainingPages.shift(); - const pageEtag = pageEtags?.shift(); - if (pageItems === undefined) { - return Promise.resolve({ done: true, value: undefined }); - } else { - const items = _filterKVs(pageItems ?? [], listOptions); - const etag = _sha256(JSON.stringify(items)); - const statusCode = pageEtag === etag ? 304 : 200; - return Promise.resolve({ - done: false, - value: { - items, - etag, - _response: { status: statusCode } - } - }); - } - } - }; - } - }; + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); + }); +} - return mockIterator as any; +function mockConfigurationManagerGetClients(isFailoverable: boolean, ...pages: ConfigurationSetting[][]) { + // Stub the getClients method on the class prototype + sinon.stub(ConfigurationClientManager.prototype, "getClients").callsFake(async () => { + const clients: ConfigurationClientWrapper[] = []; + const fakeEndpoint = createMockedEndpoint("fake"); + const fakeStaticClientWrapper = new ConfigurationClientWrapper(fakeEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint))); + sinon.stub(fakeStaticClientWrapper.client, "listConfigurationSettings").callsFake(() => { + throw new RestError("Internal Server Error", { statusCode: 500 }); + }); + clients.push(fakeStaticClientWrapper); + + if (!isFailoverable) { + return clients; + } + + const fakeReplicaEndpoint = createMockedEndpoint("fake-replica"); + const fakeDynamicClientWrapper = new ConfigurationClientWrapper(fakeReplicaEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeReplicaEndpoint))); + clients.push(fakeDynamicClientWrapper); + sinon.stub(fakeDynamicClientWrapper.client, "listConfigurationSettings").callsFake((listOptions) => { + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); + }); + return clients; }); } @@ -198,6 +230,7 @@ export { sinon, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, + mockConfigurationManagerGetClients, mockSecretClientGetSecret, restoreMocks, @@ -210,4 +243,4 @@ export { createMockedFeatureFlag, sleepInMs -}; +};