From 411d08349332912b99092fc12bad2f58d5983634 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 22 Apr 2025 10:57:07 +0800 Subject: [PATCH 1/4] Merge pull request #185 from Azure/zhiyuanliang/contenttype-tracing AI Configuration request tracing --- src/AzureAppConfigurationImpl.ts | 70 +++++++++++++++---- src/JsonKeyValueAdapter.ts | 25 +------ src/common/contentType.ts | 62 ++++++++++++++++ .../AIConfigurationTracingOptions.ts | 16 +++++ .../FeatureFlagTracingOptions.ts | 37 +++------- src/requestTracing/constants.ts | 7 ++ src/requestTracing/utils.ts | 38 +++++++--- test/requestTracing.test.ts | 44 ++++++++++++ 8 files changed, 228 insertions(+), 71 deletions(-) create mode 100644 src/common/contentType.ts create mode 100644 src/requestTracing/AIConfigurationTracingOptions.ts diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index d6565d84..35fc26ee 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -24,11 +24,13 @@ import { CONDITIONS_KEY_NAME, CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; -import { FM_PACKAGE_NAME } from "./requestTracing/constants.js"; +import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } from "./requestTracing/constants.js"; +import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/RefreshTimer.js"; import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; +import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; @@ -58,6 +60,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #isFailoverRequest: boolean = false; #featureFlagTracing: FeatureFlagTracingOptions | undefined; #fmVersion: string | undefined; + #aiConfigurationTracing: AIConfigurationTracingOptions | undefined; // Refresh #refreshInProgress: boolean = false; @@ -97,6 +100,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); if (this.#requestTracingEnabled) { + this.#aiConfigurationTracing = new AIConfigurationTracingOptions(); this.#featureFlagTracing = new FeatureFlagTracingOptions(); } @@ -178,7 +182,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { replicaCount: this.#clientManager.getReplicaCount(), isFailoverRequest: this.#isFailoverRequest, featureFlagTracing: this.#featureFlagTracing, - fmVersion: this.#fmVersion + fmVersion: this.#fmVersion, + aiConfigurationTracing: this.#aiConfigurationTracing }; } @@ -416,9 +421,14 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { await this.#updateWatchedKeyValuesEtag(loadedSettings); } + if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { + // Reset old AI configuration tracing in order to track the information present in the current response from server. + this.#aiConfigurationTracing.reset(); + } + // process key-values, watched settings have higher priority for (const setting of loadedSettings) { - const [key, value] = await this.#processKeyValues(setting); + const [key, value] = await this.#processKeyValue(setting); keyValues.push([key, value]); } @@ -467,6 +477,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const loadFeatureFlag = true; const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag); + if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { + // Reset old feature flag tracing in order to track the information present in the current response from server. + this.#featureFlagTracing.reset(); + } + // parse feature flags const featureFlags = await Promise.all( featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) @@ -633,12 +648,35 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { throw new Error("All clients failed to get configuration settings."); } - async #processKeyValues(setting: ConfigurationSetting): Promise<[string, unknown]> { + async #processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { + this.#setAIConfigurationTracing(setting); + const [key, value] = await this.#processAdapters(setting); const trimmedKey = this.#keyWithPrefixesTrimmed(key); return [trimmedKey, value]; } + #setAIConfigurationTracing(setting: ConfigurationSetting): void { + if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { + const contentType = parseContentType(setting.contentType); + // content type: "application/json; profile=\"https://azconfig.io/mime-profiles/ai\""" + if (isJsonContentType(contentType) && + !isFeatureFlagContentType(contentType) && + !isSecretReferenceContentType(contentType)) { + const profile = contentType?.parameters["profile"]; + if (profile === undefined) { + return; + } + if (profile.includes(AI_MIME_PROFILE)) { + this.#aiConfigurationTracing.usesAIConfiguration = true; + } + if (profile.includes(AI_CHAT_COMPLETION_MIME_PROFILE)) { + this.#aiConfigurationTracing.usesAIChatCompletionConfiguration = true; + } + } + } + } + async #processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { for (const adapter of this.#adapters) { if (adapter.canProcess(setting)) { @@ -675,6 +713,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; } + this.#setFeatureFlagTracing(featureFlag); + + return featureFlag; + } + + #createFeatureFlagReference(setting: ConfigurationSetting): string { + let featureFlagReference = `${this.#clientManager.endpoint.origin}/kv/${setting.key}`; + if (setting.label && setting.label.trim().length !== 0) { + featureFlagReference += `?label=${setting.label}`; + } + return featureFlagReference; + } + + #setFeatureFlagTracing(featureFlag: any): void { if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { if (featureFlag[CONDITIONS_KEY_NAME] && featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME] && @@ -693,16 +745,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#featureFlagTracing.usesSeed = true; } } - - return featureFlag; - } - - #createFeatureFlagReference(setting: ConfigurationSetting): string { - let featureFlagReference = `${this.#clientManager.endpoint.origin}/kv/${setting.key}`; - if (setting.label && setting.label.trim().length !== 0) { - featureFlagReference += `?label=${setting.label}`; - } - return featureFlagReference; } } diff --git a/src/JsonKeyValueAdapter.ts b/src/JsonKeyValueAdapter.ts index d9157a45..dcce1033 100644 --- a/src/JsonKeyValueAdapter.ts +++ b/src/JsonKeyValueAdapter.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { ConfigurationSetting, featureFlagContentType, secretReferenceContentType } from "@azure/app-configuration"; +import { parseContentType, isJsonContentType } from "./common/contentType.js"; import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; export class JsonKeyValueAdapter implements IKeyValueAdapter { @@ -17,7 +18,8 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter { if (JsonKeyValueAdapter.#ExcludedJsonContentTypes.includes(setting.contentType)) { return false; } - return isJsonContentType(setting.contentType); + const contentType = parseContentType(setting.contentType); + return isJsonContentType(contentType); } async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { @@ -34,24 +36,3 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter { return [setting.key, parsedValue]; } } - -// Determine whether a content type string is a valid JSON content type. -// https://docs.microsoft.com/en-us/azure/azure-app-configuration/howto-leverage-json-content-type -function isJsonContentType(contentTypeValue: string): boolean { - if (!contentTypeValue) { - return false; - } - - const contentTypeNormalized: string = contentTypeValue.trim().toLowerCase(); - const mimeType: string = contentTypeNormalized.split(";", 1)[0].trim(); - const typeParts: string[] = mimeType.split("/"); - if (typeParts.length !== 2) { - return false; - } - - if (typeParts[0] !== "application") { - return false; - } - - return typeParts[1].split("+").includes("json"); -} diff --git a/src/common/contentType.ts b/src/common/contentType.ts new file mode 100644 index 00000000..4891f425 --- /dev/null +++ b/src/common/contentType.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { secretReferenceContentType, featureFlagContentType } from "@azure/app-configuration"; + +export type ContentType = { + mediaType: string; + parameters: Record; +} + +export function parseContentType(contentTypeValue: string | undefined): ContentType | undefined { + if (!contentTypeValue) { + return undefined; + } + const [mediaType, ...args] = contentTypeValue.split(";").map((s) => s.trim().toLowerCase()); + const parameters: Record = {}; + + for (const param of args) { + const [key, value] = param.split("=").map((s) => s.trim().toLowerCase()); + if (key && value) { + parameters[key] = value; + } + } + + return { mediaType, parameters }; +} + +// Determine whether a content type string is a valid JSON content type. +// https://docs.microsoft.com/en-us/azure/azure-app-configuration/howto-leverage-json-content-type +export function isJsonContentType(contentType: ContentType | undefined): boolean { + const mediaType = contentType?.mediaType; + if (!mediaType) { + return false; + } + + const typeParts: string[] = mediaType.split("/"); + if (typeParts.length !== 2) { + return false; + } + + if (typeParts[0] !== "application") { + return false; + } + + return typeParts[1].split("+").includes("json"); +} + +export function isFeatureFlagContentType(contentType: ContentType | undefined): boolean { + const mediaType = contentType?.mediaType; + if (!mediaType) { + return false; + } + return mediaType === featureFlagContentType; +} + +export function isSecretReferenceContentType(contentType: ContentType | undefined): boolean { + const mediaType = contentType?.mediaType; + if (!mediaType) { + return false; + } + return mediaType === secretReferenceContentType; +} diff --git a/src/requestTracing/AIConfigurationTracingOptions.ts b/src/requestTracing/AIConfigurationTracingOptions.ts new file mode 100644 index 00000000..1c7671ae --- /dev/null +++ b/src/requestTracing/AIConfigurationTracingOptions.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class AIConfigurationTracingOptions { + usesAIConfiguration: boolean = false; + usesAIChatCompletionConfiguration: boolean = false; + + reset(): void { + this.usesAIConfiguration = false; + this.usesAIChatCompletionConfiguration = false; + } + + usesAnyTracingFeature() { + return this.usesAIConfiguration || this.usesAIChatCompletionConfiguration; + } +} diff --git a/src/requestTracing/FeatureFlagTracingOptions.ts b/src/requestTracing/FeatureFlagTracingOptions.ts index 006d969e..dd1781db 100644 --- a/src/requestTracing/FeatureFlagTracingOptions.ts +++ b/src/requestTracing/FeatureFlagTracingOptions.ts @@ -18,7 +18,7 @@ export class FeatureFlagTracingOptions { usesSeed: boolean = false; maxVariants: number = 0; - resetFeatureFlagTracing(): void { + reset(): void { this.usesCustomFilter = false; this.usesTimeWindowFilter = false; this.usesTargetingFilter = false; @@ -52,44 +52,27 @@ export class FeatureFlagTracingOptions { } createFeatureFiltersString(): string { - if (!this.usesAnyFeatureFilter()) { - return ""; - } - - let result: string = ""; + const tags: string[] = []; if (this.usesCustomFilter) { - result += CUSTOM_FILTER_KEY; + tags.push(CUSTOM_FILTER_KEY); } if (this.usesTimeWindowFilter) { - if (result !== "") { - result += DELIMITER; - } - result += TIME_WINDOW_FILTER_KEY; + tags.push(TIME_WINDOW_FILTER_KEY); } if (this.usesTargetingFilter) { - if (result !== "") { - result += DELIMITER; - } - result += TARGETING_FILTER_KEY; + tags.push(TARGETING_FILTER_KEY); } - return result; + return tags.join(DELIMITER); } createFeaturesString(): string { - if (!this.usesAnyTracingFeature()) { - return ""; - } - - let result: string = ""; + const tags: string[] = []; if (this.usesSeed) { - result += FF_SEED_USED_TAG; + tags.push(FF_SEED_USED_TAG); } if (this.usesTelemetry) { - if (result !== "") { - result += DELIMITER; - } - result += FF_TELEMETRY_USED_TAG; + tags.push(FF_TELEMETRY_USED_TAG); } - return result; + return tags.join(DELIMITER); } } diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index 74ca58bb..6d1f3f3f 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -70,4 +70,11 @@ export const FF_MAX_VARIANTS_KEY = "MaxVariants"; export const FF_SEED_USED_TAG = "Seed"; export const FF_FEATURES_KEY = "FFFeatures"; +// AI Configuration tracing +export const AI_CONFIGURATION_TAG = "AI"; +export const AI_CHAT_COMPLETION_CONFIGURATION_TAG = "AICC"; + +export const AI_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai"; +export const AI_CHAT_COMPLETION_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/chat-completion"; + export const DELIMITER = "+"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 2e8b1124..9332c656 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -4,6 +4,7 @@ import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration"; import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js"; import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js"; +import { AIConfigurationTracingOptions } from "./AIConfigurationTracingOptions.js"; import { AZURE_FUNCTION_ENV_VAR, AZURE_WEB_APP_ENV_VAR, @@ -28,7 +29,10 @@ import { FAILOVER_REQUEST_TAG, FEATURES_KEY, LOAD_BALANCE_CONFIGURED_TAG, - FM_VERSION_KEY + FM_VERSION_KEY, + DELIMITER, + AI_CONFIGURATION_TAG, + AI_CHAT_COMPLETION_CONFIGURATION_TAG } from "./constants"; export interface RequestTracingOptions { @@ -39,6 +43,7 @@ export interface RequestTracingOptions { isFailoverRequest: boolean; featureFlagTracing: FeatureFlagTracingOptions | undefined; fmVersion: string | undefined; + aiConfigurationTracing: AIConfigurationTracingOptions | undefined; } // Utils @@ -125,15 +130,13 @@ export function createCorrelationContextHeader(requestTracingOptions: RequestTra keyValues.set(FM_VERSION_KEY, requestTracingOptions.fmVersion); } - // Compact tags: Features=LB+... - if (appConfigOptions?.loadBalancingEnabled) { - keyValues.set(FEATURES_KEY, LOAD_BALANCE_CONFIGURED_TAG); - } + // Use compact tags for new tracing features: Features=LB+AI+AICC... + keyValues.set(FEATURES_KEY, usesAnyTracingFeature(requestTracingOptions) ? createFeaturesString(requestTracingOptions) : undefined); const contextParts: string[] = []; - for (const [k, v] of keyValues) { - if (v !== undefined) { - contextParts.push(`${k}=${v}`); + for (const [key, value] of keyValues) { + if (value !== undefined) { + contextParts.push(`${key}=${value}`); } } for (const tag of tags) { @@ -149,6 +152,25 @@ export function requestTracingEnabled(): boolean { return !disabled; } +function usesAnyTracingFeature(requestTracingOptions: RequestTracingOptions): boolean { + return (requestTracingOptions.appConfigOptions?.loadBalancingEnabled ?? false) || + (requestTracingOptions.aiConfigurationTracing?.usesAnyTracingFeature() ?? false); +} + +function createFeaturesString(requestTracingOptions: RequestTracingOptions): string { + const tags: string[] = []; + if (requestTracingOptions.appConfigOptions?.loadBalancingEnabled) { + tags.push(LOAD_BALANCE_CONFIGURED_TAG); + } + if (requestTracingOptions.aiConfigurationTracing?.usesAIConfiguration) { + tags.push(AI_CONFIGURATION_TAG); + } + if (requestTracingOptions.aiConfigurationTracing?.usesAIChatCompletionConfiguration) { + tags.push(AI_CHAT_COMPLETION_CONFIGURATION_TAG); + } + return tags.join(DELIMITER); +} + function getEnvironmentVariable(name: string) { // Make it compatible with non-Node.js runtime if (typeof process !== "undefined" && typeof process?.env === "object") { diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index ed3fdaee..3179602a 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -64,6 +64,19 @@ describe("request tracing", function () { expect(correlationContext.includes("UsesKeyVault")).eq(true); }); + it("should have loadbalancing tag in correlation-context header", async () => { + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + loadBalancingEnabled: true, + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Features=LB")).eq(true); + }); + it("should have replica count in correlation-context header", async () => { const replicaCount = 2; sinon.stub(ConfigurationClientManager.prototype, "getReplicaCount").returns(replicaCount); @@ -293,6 +306,37 @@ describe("request tracing", function () { restoreMocks(); }); + it("should have AI tag in correlation-context header if key values use AI configuration", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedKeyValue({ contentType: "application/json; profile=\"https://azconfig.io/mime-profiles/ai/chat-completion\"" }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000 + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("Features=AI+AICC")).eq(true); + + restoreMocks(); + }); + describe("request tracing in Web Worker environment", () => { let originalNavigator; let originalWorkerNavigator; From 0155bf6bc95a64d0537152d7bff71bd35b972fba Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 22 Apr 2025 11:44:39 +0800 Subject: [PATCH 2/4] version bump 2.0.2 (#186) --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index da3474a1..b4979eed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure/app-configuration-provider", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "2.0.1", + "version": "2.0.2", "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.6.1", diff --git a/package.json b/package.json index 7c757350..c528c521 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/app-configuration-provider", - "version": "2.0.1", + "version": "2.0.2", "description": "The JavaScript configuration provider for Azure App Configuration", "main": "dist/index.js", "module": "./dist-esm/index.js", diff --git a/src/version.ts b/src/version.ts index e731cf4c..b0d0e88d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.0.1"; +export const VERSION = "2.0.2"; From 64caf1f141ce54f90d18577843720ea2abf32230 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:15:39 +0800 Subject: [PATCH 3/4] replace rightshift with math.pow (#189) --- src/ConfigurationClientWrapper.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ConfigurationClientWrapper.ts b/src/ConfigurationClientWrapper.ts index 7dd6f418..967158ae 100644 --- a/src/ConfigurationClientWrapper.ts +++ b/src/ConfigurationClientWrapper.ts @@ -5,7 +5,6 @@ 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 { @@ -36,8 +35,8 @@ export function calculateBackoffDuration(failedAttempts: number) { } // exponential: minBackoff * 2 ^ (failedAttempts - 1) - const exponential = Math.min(failedAttempts - 1, MAX_SAFE_EXPONENTIAL); - let calculatedBackoffDuration = MinBackoffDuration * (1 << exponential); + // 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; } From 5c1f4a34475fe94326228f8bdffaa8088779a2d6 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 29 Apr 2025 10:35:27 +0800 Subject: [PATCH 4/4] Startup Retry & Configurable Startup Time-out & Error handling (#166) * support startup retry and timeout * update * update * update * add testcase * clarify error type * update * update * update * fix lint * handle keyvault error * update * update * update * update * update * update * handle keyvault reference error * update * fix lint * update * update * add boot loop protection * update * update * update testcase * update * update testcase * update * update * update * move error.ts to common folder * handle transient network error * update * update * keep error stack when fail to load * update testcase --- rollup.config.mjs | 10 +- src/AzureAppConfiguration.ts | 3 + src/AzureAppConfigurationImpl.ts | 119 +++++++++++---- src/AzureAppConfigurationOptions.ts | 11 +- src/ConfigurationClientManager.ts | 44 ++---- src/ConfigurationClientWrapper.ts | 25 +--- src/StartupOptions.ts | 14 ++ src/common/backoffUtils.ts | 37 +++++ src/common/error.ts | 62 ++++++++ src/common/utils.ts | 4 + src/featureManagement/FeatureFlagOptions.ts | 2 +- src/keyvault/AzureKeyVaultKeyValueAdapter.ts | 63 +++++--- src/load.ts | 5 +- src/refresh/RefreshTimer.ts | 6 +- .../refreshOptions.ts} | 88 +++++------ test/clientOptions.test.ts | 9 ++ test/failover.test.ts | 8 - test/keyvault.test.ts | 34 +++-- test/load.test.ts | 6 +- test/requestTracing.test.ts | 138 ++++++++++++++---- test/startup.test.ts | 113 ++++++++++++++ 21 files changed, 600 insertions(+), 201 deletions(-) create mode 100644 src/StartupOptions.ts create mode 100644 src/common/backoffUtils.ts create mode 100644 src/common/error.ts rename src/{RefreshOptions.ts => refresh/refreshOptions.ts} (94%) create mode 100644 test/startup.test.ts 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."); + }); +});