Skip to content

Commit bda172e

Browse files
Merge branch 'main' of https://github.com/Azure/AppConfiguration-JavaScriptProvider into zhiyuanliang/select-snapshot
2 parents 994c10e + 5c1f4a3 commit bda172e

21 files changed

+601
-203
lines changed

rollup.config.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@ import dts from "rollup-plugin-dts";
44

55
export default [
66
{
7-
external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto", "dns/promises", "@microsoft/feature-management"],
7+
external: [
8+
"@azure/app-configuration",
9+
"@azure/keyvault-secrets",
10+
"@azure/core-rest-pipeline",
11+
"@azure/identity",
12+
"crypto",
13+
"dns/promises",
14+
"@microsoft/feature-management"
15+
],
816
input: "src/index.ts",
917
output: [
1018
{

src/AzureAppConfiguration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
import { Disposable } from "./common/disposable.js";
55

6+
/**
7+
* Azure App Configuration provider.
8+
*/
69
export type AzureAppConfiguration = {
710
/**
811
* API to trigger refresh operation.

src/AzureAppConfigurationImpl.ts

Lines changed: 92 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from ".
1919
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js";
2020
import { IKeyValueAdapter } from "./IKeyValueAdapter.js";
2121
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js";
22-
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js";
22+
import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js";
23+
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js";
2324
import { Disposable } from "./common/disposable.js";
2425
import {
2526
FEATURE_FLAGS_KEY_NAME,
@@ -52,6 +53,10 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp
5253
import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js";
5354
import { KeyFilter, LabelFilter, SettingSelector } from "./types.js";
5455
import { ConfigurationClientManager } from "./ConfigurationClientManager.js";
56+
import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js";
57+
import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js";
58+
59+
const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds
5560

5661
type PagedSettingSelector = SettingSelector & {
5762
/**
@@ -137,10 +142,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
137142
} else {
138143
for (const setting of watchedSettings) {
139144
if (setting.key.includes("*") || setting.key.includes(",")) {
140-
throw new Error("The characters '*' and ',' are not supported in key of watched settings.");
145+
throw new ArgumentError("The characters '*' and ',' are not supported in key of watched settings.");
141146
}
142147
if (setting.label?.includes("*") || setting.label?.includes(",")) {
143-
throw new Error("The characters '*' and ',' are not supported in label of watched settings.");
148+
throw new ArgumentError("The characters '*' and ',' are not supported in label of watched settings.");
144149
}
145150
this.#sentinels.push(setting);
146151
}
@@ -149,7 +154,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
149154
// custom refresh interval
150155
if (refreshIntervalInMs !== undefined) {
151156
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
152-
throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
157+
throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
153158
} else {
154159
this.#kvRefreshInterval = refreshIntervalInMs;
155160
}
@@ -167,7 +172,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
167172
// custom refresh interval
168173
if (refreshIntervalInMs !== undefined) {
169174
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
170-
throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
175+
throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
171176
} else {
172177
this.#ffRefreshInterval = refreshIntervalInMs;
173178
}
@@ -244,13 +249,40 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
244249
* Loads the configuration store for the first time.
245250
*/
246251
async load() {
247-
await this.#inspectFmPackage();
248-
await this.#loadSelectedAndWatchedKeyValues();
249-
if (this.#featureFlagEnabled) {
250-
await this.#loadFeatureFlags();
252+
const startTimestamp = Date.now();
253+
const startupTimeout: number = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT_IN_MS;
254+
const abortController = new AbortController();
255+
const abortSignal = abortController.signal;
256+
let timeoutId;
257+
try {
258+
// Promise.race will be settled when the first promise in the list is settled.
259+
// It will not cancel the remaining promises in the list.
260+
// To avoid memory leaks, we must ensure other promises will be eventually terminated.
261+
await Promise.race([
262+
this.#initializeWithRetryPolicy(abortSignal),
263+
// this promise will be rejected after timeout
264+
new Promise((_, reject) => {
265+
timeoutId = setTimeout(() => {
266+
abortController.abort(); // abort the initialization promise
267+
reject(new Error("Load operation timed out."));
268+
},
269+
startupTimeout);
270+
})
271+
]);
272+
} catch (error) {
273+
if (!isInputError(error)) {
274+
const timeElapsed = Date.now() - startTimestamp;
275+
if (timeElapsed < MIN_DELAY_FOR_UNHANDLED_FAILURE) {
276+
// load() method is called in the application's startup code path.
277+
// Unhandled exceptions cause application crash which can result in crash loops as orchestrators attempt to restart the application.
278+
// 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.
279+
await new Promise(resolve => setTimeout(resolve, MIN_DELAY_FOR_UNHANDLED_FAILURE - timeElapsed));
280+
}
281+
}
282+
throw new Error("Failed to load.", { cause: error });
283+
} finally {
284+
clearTimeout(timeoutId); // cancel the timeout promise
251285
}
252-
// Mark all settings have loaded at startup.
253-
this.#isInitialLoadCompleted = true;
254286
}
255287

256288
/**
@@ -260,7 +292,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
260292
const separator = options?.separator ?? ".";
261293
const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"];
262294
if (!validSeparators.includes(separator)) {
263-
throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
295+
throw new ArgumentError(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
264296
}
265297

266298
// construct hierarchical data object from map
@@ -273,22 +305,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
273305
const segment = segments[i];
274306
// undefined or empty string
275307
if (!segment) {
276-
throw new Error(`invalid key: ${key}`);
308+
throw new InvalidOperationError(`Failed to construct configuration object: Invalid key: ${key}`);
277309
}
278310
// create path if not exist
279311
if (current[segment] === undefined) {
280312
current[segment] = {};
281313
}
282314
// The path has been occupied by a non-object value, causing ambiguity.
283315
if (typeof current[segment] !== "object") {
284-
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.`);
316+
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.`);
285317
}
286318
current = current[segment];
287319
}
288320

289321
const lastSegment = segments[segments.length - 1];
290322
if (current[lastSegment] !== undefined) {
291-
throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
323+
throw new InvalidOperationError(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
292324
}
293325
// set value to the last segment
294326
current[lastSegment] = value;
@@ -301,7 +333,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
301333
*/
302334
async refresh(): Promise<void> {
303335
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
304-
throw new Error("Refresh is not enabled for key-values or feature flags.");
336+
throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags.");
305337
}
306338

307339
if (this.#refreshInProgress) {
@@ -320,7 +352,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
320352
*/
321353
onRefresh(listener: () => any, thisArg?: any): Disposable {
322354
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
323-
throw new Error("Refresh is not enabled for key-values or feature flags.");
355+
throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags.");
324356
}
325357

326358
const boundedListener = listener.bind(thisArg);
@@ -335,6 +367,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
335367
return new Disposable(remove);
336368
}
337369

370+
/**
371+
* Initializes the configuration provider.
372+
*/
373+
async #initializeWithRetryPolicy(abortSignal: AbortSignal): Promise<void> {
374+
if (!this.#isInitialLoadCompleted) {
375+
await this.#inspectFmPackage();
376+
const startTimestamp = Date.now();
377+
let postAttempts = 0;
378+
do { // at least try to load once
379+
try {
380+
await this.#loadSelectedAndWatchedKeyValues();
381+
if (this.#featureFlagEnabled) {
382+
await this.#loadFeatureFlags();
383+
}
384+
this.#isInitialLoadCompleted = true;
385+
break;
386+
} catch (error) {
387+
if (isInputError(error)) {
388+
throw error;
389+
}
390+
if (abortSignal.aborted) {
391+
return;
392+
}
393+
const timeElapsed = Date.now() - startTimestamp;
394+
let backoffDuration = getFixedBackoffDuration(timeElapsed);
395+
if (backoffDuration === undefined) {
396+
postAttempts += 1;
397+
backoffDuration = getExponentialBackoffDuration(postAttempts);
398+
}
399+
console.warn(`Failed to load. Error message: ${error.message}. Retrying in ${backoffDuration} ms.`);
400+
await new Promise(resolve => setTimeout(resolve, backoffDuration));
401+
}
402+
} while (!abortSignal.aborted);
403+
}
404+
}
405+
338406
/**
339407
* Inspects the feature management package version.
340408
*/
@@ -468,7 +536,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
468536
this.#aiConfigurationTracing.reset();
469537
}
470538

471-
// process key-values, watched settings have higher priority
539+
// adapt configuration settings to key-values
472540
for (const setting of loadedSettings) {
473541
const [key, value] = await this.#processKeyValue(setting);
474542
keyValues.push([key, value]);
@@ -674,6 +742,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
674742
return response;
675743
}
676744

745+
// Only operations related to Azure App Configuration should be executed with failover policy.
677746
async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
678747
let clientWrappers = await this.#clientManager.getClients();
679748
if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) {
@@ -713,7 +782,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
713782
}
714783

715784
this.#clientManager.refreshClients();
716-
throw new Error("All clients failed to get configuration settings.");
785+
throw new Error("All fallback clients failed to get configuration settings.");
717786
}
718787

719788
async #processKeyValue(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
@@ -768,7 +837,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
768837
async #parseFeatureFlag(setting: ConfigurationSetting<string>): Promise<any> {
769838
const rawFlag = setting.value;
770839
if (rawFlag === undefined) {
771-
throw new Error("The value of configuration setting cannot be undefined.");
840+
throw new ArgumentError("The value of configuration setting cannot be undefined.");
772841
}
773842
const featureFlag = JSON.parse(rawFlag);
774843

@@ -831,17 +900,17 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector
831900
const selector = { ...selectorCandidate };
832901
if (selector.snapshotName) {
833902
if (selector.keyFilter || selector.labelFilter) {
834-
throw new Error("Key or label filter should not be used for a snapshot.");
903+
throw new ArgumentError("Key or label filter should not be used for a snapshot.");
835904
}
836905
} else {
837906
if (!selector.keyFilter) {
838-
throw new Error("Key filter cannot be null or empty.");
907+
throw new ArgumentError("Key filter cannot be null or empty.");
839908
}
840909
if (!selector.labelFilter) {
841910
selector.labelFilter = LabelFilter.Null;
842911
}
843912
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
844-
throw new Error("The characters '*' and ',' are not supported in label filters.");
913+
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
845914
}
846915
}
847916
return selector;
@@ -866,9 +935,3 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
866935
});
867936
return getValidSettingSelectors(selectors);
868937
}
869-
870-
function isFailoverableError(error: any): boolean {
871-
// ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory
872-
return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" ||
873-
(error.statusCode !== undefined && (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500)));
874-
}

src/AzureAppConfigurationOptions.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33

44
import { AppConfigurationClientOptions } from "@azure/app-configuration";
55
import { KeyVaultOptions } from "./keyvault/KeyVaultOptions.js";
6-
import { RefreshOptions } from "./RefreshOptions.js";
6+
import { RefreshOptions } from "./refresh/refreshOptions.js";
77
import { SettingSelector } from "./types.js";
88
import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions.js";
9-
10-
export const MaxRetries = 2;
11-
export const MaxRetryDelayInMs = 60000;
9+
import { StartupOptions } from "./StartupOptions.js";
1210

1311
export interface AzureAppConfigurationOptions {
1412
/**
@@ -48,6 +46,11 @@ export interface AzureAppConfigurationOptions {
4846
*/
4947
featureFlagOptions?: FeatureFlagOptions;
5048

49+
/**
50+
* Specifies options used to configure provider startup.
51+
*/
52+
startupOptions?: StartupOptions;
53+
5154
/**
5255
* Specifies whether to enable replica discovery or not.
5356
*

0 commit comments

Comments
 (0)