Skip to content

Commit 00421f9

Browse files
Merge branch 'zhiyuanliang/select-snapshot' of https://github.com/Azure/AppConfiguration-JavaScriptProvider into zhiyuanliang/tag-filter
2 parents 5b89cb6 + cad828a commit 00421f9

21 files changed

+601
-215
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
const MAX_TAG_FILTERS = 5;
5762

@@ -139,10 +144,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
139144
} else {
140145
for (const setting of watchedSettings) {
141146
if (setting.key.includes("*") || setting.key.includes(",")) {
142-
throw new Error("The characters '*' and ',' are not supported in key of watched settings.");
147+
throw new ArgumentError("The characters '*' and ',' are not supported in key of watched settings.");
143148
}
144149
if (setting.label?.includes("*") || setting.label?.includes(",")) {
145-
throw new Error("The characters '*' and ',' are not supported in label of watched settings.");
150+
throw new ArgumentError("The characters '*' and ',' are not supported in label of watched settings.");
146151
}
147152
this.#sentinels.push(setting);
148153
}
@@ -151,7 +156,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
151156
// custom refresh interval
152157
if (refreshIntervalInMs !== undefined) {
153158
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
154-
throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
159+
throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
155160
} else {
156161
this.#kvRefreshInterval = refreshIntervalInMs;
157162
}
@@ -169,7 +174,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
169174
// custom refresh interval
170175
if (refreshIntervalInMs !== undefined) {
171176
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
172-
throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
177+
throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
173178
} else {
174179
this.#ffRefreshInterval = refreshIntervalInMs;
175180
}
@@ -246,13 +251,40 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
246251
* Loads the configuration store for the first time.
247252
*/
248253
async load() {
249-
await this.#inspectFmPackage();
250-
await this.#loadSelectedAndWatchedKeyValues();
251-
if (this.#featureFlagEnabled) {
252-
await this.#loadFeatureFlags();
254+
const startTimestamp = Date.now();
255+
const startupTimeout: number = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT_IN_MS;
256+
const abortController = new AbortController();
257+
const abortSignal = abortController.signal;
258+
let timeoutId;
259+
try {
260+
// Promise.race will be settled when the first promise in the list is settled.
261+
// It will not cancel the remaining promises in the list.
262+
// To avoid memory leaks, we must ensure other promises will be eventually terminated.
263+
await Promise.race([
264+
this.#initializeWithRetryPolicy(abortSignal),
265+
// this promise will be rejected after timeout
266+
new Promise((_, reject) => {
267+
timeoutId = setTimeout(() => {
268+
abortController.abort(); // abort the initialization promise
269+
reject(new Error("Load operation timed out."));
270+
},
271+
startupTimeout);
272+
})
273+
]);
274+
} catch (error) {
275+
if (!isInputError(error)) {
276+
const timeElapsed = Date.now() - startTimestamp;
277+
if (timeElapsed < MIN_DELAY_FOR_UNHANDLED_FAILURE) {
278+
// load() method is called in the application's startup code path.
279+
// Unhandled exceptions cause application crash which can result in crash loops as orchestrators attempt to restart the application.
280+
// 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.
281+
await new Promise(resolve => setTimeout(resolve, MIN_DELAY_FOR_UNHANDLED_FAILURE - timeElapsed));
282+
}
283+
}
284+
throw new Error("Failed to load.", { cause: error });
285+
} finally {
286+
clearTimeout(timeoutId); // cancel the timeout promise
253287
}
254-
// Mark all settings have loaded at startup.
255-
this.#isInitialLoadCompleted = true;
256288
}
257289

258290
/**
@@ -262,7 +294,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
262294
const separator = options?.separator ?? ".";
263295
const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"];
264296
if (!validSeparators.includes(separator)) {
265-
throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
297+
throw new ArgumentError(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
266298
}
267299

268300
// construct hierarchical data object from map
@@ -275,22 +307,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
275307
const segment = segments[i];
276308
// undefined or empty string
277309
if (!segment) {
278-
throw new Error(`invalid key: ${key}`);
310+
throw new InvalidOperationError(`Failed to construct configuration object: Invalid key: ${key}`);
279311
}
280312
// create path if not exist
281313
if (current[segment] === undefined) {
282314
current[segment] = {};
283315
}
284316
// The path has been occupied by a non-object value, causing ambiguity.
285317
if (typeof current[segment] !== "object") {
286-
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.`);
318+
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.`);
287319
}
288320
current = current[segment];
289321
}
290322

291323
const lastSegment = segments[segments.length - 1];
292324
if (current[lastSegment] !== undefined) {
293-
throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
325+
throw new InvalidOperationError(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
294326
}
295327
// set value to the last segment
296328
current[lastSegment] = value;
@@ -303,7 +335,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
303335
*/
304336
async refresh(): Promise<void> {
305337
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
306-
throw new Error("Refresh is not enabled for key-values or feature flags.");
338+
throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags.");
307339
}
308340

309341
if (this.#refreshInProgress) {
@@ -322,7 +354,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
322354
*/
323355
onRefresh(listener: () => any, thisArg?: any): Disposable {
324356
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
325-
throw new Error("Refresh is not enabled for key-values or feature flags.");
357+
throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags.");
326358
}
327359

328360
const boundedListener = listener.bind(thisArg);
@@ -337,6 +369,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
337369
return new Disposable(remove);
338370
}
339371

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

474-
// process key-values, watched settings have higher priority
542+
// adapt configuration settings to key-values
475543
for (const setting of loadedSettings) {
476544
const [key, value] = await this.#processKeyValue(setting);
477545
keyValues.push([key, value]);
@@ -678,6 +746,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
678746
return response;
679747
}
680748

749+
// Only operations related to Azure App Configuration should be executed with failover policy.
681750
async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
682751
let clientWrappers = await this.#clientManager.getClients();
683752
if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) {
@@ -717,7 +786,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
717786
}
718787

719788
this.#clientManager.refreshClients();
720-
throw new Error("All clients failed to get configuration settings.");
789+
throw new Error("All fallback clients failed to get configuration settings.");
721790
}
722791

723792
async #processKeyValue(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
@@ -772,7 +841,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
772841
async #parseFeatureFlag(setting: ConfigurationSetting<string>): Promise<any> {
773842
const rawFlag = setting.value;
774843
if (rawFlag === undefined) {
775-
throw new Error("The value of configuration setting cannot be undefined.");
844+
throw new ArgumentError("The value of configuration setting cannot be undefined.");
776845
}
777846
const featureFlag = JSON.parse(rawFlag);
778847

@@ -839,17 +908,17 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector
839908
const selector = { ...selectorCandidate };
840909
if (selector.snapshotName) {
841910
if (selector.keyFilter || selector.labelFilter || selector.tagFilters) {
842-
throw new Error("Key, label or tag filter should not be used for a snapshot.");
911+
throw new ArgumentError("Key, label or tag filter should not be used for a snapshot.");
843912
}
844913
} else {
845914
if (!selector.keyFilter && (!selector.tagFilters || selector.tagFilters.length === 0)) {
846-
throw new Error("Key filter cannot be null or empty.");
915+
throw new ArgumentError("Key filter cannot be null or empty.");
847916
}
848917
if (!selector.labelFilter) {
849918
selector.labelFilter = LabelFilter.Null;
850919
}
851920
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
852-
throw new Error("The characters '*' and ',' are not supported in label filters.");
921+
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
853922
}
854923
if (selector.tagFilters) {
855924
validateTagFilters(selector.tagFilters);
@@ -906,9 +975,3 @@ function validateTagFilters(tagFilters: string[]): void {
906975
}
907976
}
908977
}
909-
910-
function isFailoverableError(error: any): boolean {
911-
// ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory
912-
return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" ||
913-
(error.statusCode !== undefined && (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500)));
914-
}

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)