Skip to content

Commit 5c1f4a3

Browse files
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
1 parent 64caf1f commit 5c1f4a3

21 files changed

+600
-201
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: 91 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from ".
77
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js";
88
import { IKeyValueAdapter } from "./IKeyValueAdapter.js";
99
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js";
10-
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js";
10+
import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js";
11+
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js";
1112
import { Disposable } from "./common/disposable.js";
1213
import {
1314
FEATURE_FLAGS_KEY_NAME,
@@ -33,6 +34,10 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp
3334
import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js";
3435
import { KeyFilter, LabelFilter, SettingSelector } from "./types.js";
3536
import { ConfigurationClientManager } from "./ConfigurationClientManager.js";
37+
import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js";
38+
import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js";
39+
40+
const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds
3641

3742
type PagedSettingSelector = SettingSelector & {
3843
/**
@@ -118,10 +123,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
118123
} else {
119124
for (const setting of watchedSettings) {
120125
if (setting.key.includes("*") || setting.key.includes(",")) {
121-
throw new Error("The characters '*' and ',' are not supported in key of watched settings.");
126+
throw new ArgumentError("The characters '*' and ',' are not supported in key of watched settings.");
122127
}
123128
if (setting.label?.includes("*") || setting.label?.includes(",")) {
124-
throw new Error("The characters '*' and ',' are not supported in label of watched settings.");
129+
throw new ArgumentError("The characters '*' and ',' are not supported in label of watched settings.");
125130
}
126131
this.#sentinels.push(setting);
127132
}
@@ -130,7 +135,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
130135
// custom refresh interval
131136
if (refreshIntervalInMs !== undefined) {
132137
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
133-
throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
138+
throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
134139
} else {
135140
this.#kvRefreshInterval = refreshIntervalInMs;
136141
}
@@ -148,7 +153,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
148153
// custom refresh interval
149154
if (refreshIntervalInMs !== undefined) {
150155
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
151-
throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
156+
throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
152157
} else {
153158
this.#ffRefreshInterval = refreshIntervalInMs;
154159
}
@@ -225,13 +230,40 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
225230
* Loads the configuration store for the first time.
226231
*/
227232
async load() {
228-
await this.#inspectFmPackage();
229-
await this.#loadSelectedAndWatchedKeyValues();
230-
if (this.#featureFlagEnabled) {
231-
await this.#loadFeatureFlags();
233+
const startTimestamp = Date.now();
234+
const startupTimeout: number = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT_IN_MS;
235+
const abortController = new AbortController();
236+
const abortSignal = abortController.signal;
237+
let timeoutId;
238+
try {
239+
// Promise.race will be settled when the first promise in the list is settled.
240+
// It will not cancel the remaining promises in the list.
241+
// To avoid memory leaks, we must ensure other promises will be eventually terminated.
242+
await Promise.race([
243+
this.#initializeWithRetryPolicy(abortSignal),
244+
// this promise will be rejected after timeout
245+
new Promise((_, reject) => {
246+
timeoutId = setTimeout(() => {
247+
abortController.abort(); // abort the initialization promise
248+
reject(new Error("Load operation timed out."));
249+
},
250+
startupTimeout);
251+
})
252+
]);
253+
} catch (error) {
254+
if (!isInputError(error)) {
255+
const timeElapsed = Date.now() - startTimestamp;
256+
if (timeElapsed < MIN_DELAY_FOR_UNHANDLED_FAILURE) {
257+
// load() method is called in the application's startup code path.
258+
// Unhandled exceptions cause application crash which can result in crash loops as orchestrators attempt to restart the application.
259+
// 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.
260+
await new Promise(resolve => setTimeout(resolve, MIN_DELAY_FOR_UNHANDLED_FAILURE - timeElapsed));
261+
}
262+
}
263+
throw new Error("Failed to load.", { cause: error });
264+
} finally {
265+
clearTimeout(timeoutId); // cancel the timeout promise
232266
}
233-
// Mark all settings have loaded at startup.
234-
this.#isInitialLoadCompleted = true;
235267
}
236268

237269
/**
@@ -241,7 +273,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
241273
const separator = options?.separator ?? ".";
242274
const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"];
243275
if (!validSeparators.includes(separator)) {
244-
throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
276+
throw new ArgumentError(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
245277
}
246278

247279
// construct hierarchical data object from map
@@ -254,22 +286,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
254286
const segment = segments[i];
255287
// undefined or empty string
256288
if (!segment) {
257-
throw new Error(`invalid key: ${key}`);
289+
throw new InvalidOperationError(`Failed to construct configuration object: Invalid key: ${key}`);
258290
}
259291
// create path if not exist
260292
if (current[segment] === undefined) {
261293
current[segment] = {};
262294
}
263295
// The path has been occupied by a non-object value, causing ambiguity.
264296
if (typeof current[segment] !== "object") {
265-
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.`);
297+
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.`);
266298
}
267299
current = current[segment];
268300
}
269301

270302
const lastSegment = segments[segments.length - 1];
271303
if (current[lastSegment] !== undefined) {
272-
throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
304+
throw new InvalidOperationError(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
273305
}
274306
// set value to the last segment
275307
current[lastSegment] = value;
@@ -282,7 +314,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
282314
*/
283315
async refresh(): Promise<void> {
284316
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
285-
throw new Error("Refresh is not enabled for key-values or feature flags.");
317+
throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags.");
286318
}
287319

288320
if (this.#refreshInProgress) {
@@ -301,7 +333,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
301333
*/
302334
onRefresh(listener: () => any, thisArg?: any): Disposable {
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
const boundedListener = listener.bind(thisArg);
@@ -316,6 +348,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
316348
return new Disposable(remove);
317349
}
318350

351+
/**
352+
* Initializes the configuration provider.
353+
*/
354+
async #initializeWithRetryPolicy(abortSignal: AbortSignal): Promise<void> {
355+
if (!this.#isInitialLoadCompleted) {
356+
await this.#inspectFmPackage();
357+
const startTimestamp = Date.now();
358+
let postAttempts = 0;
359+
do { // at least try to load once
360+
try {
361+
await this.#loadSelectedAndWatchedKeyValues();
362+
if (this.#featureFlagEnabled) {
363+
await this.#loadFeatureFlags();
364+
}
365+
this.#isInitialLoadCompleted = true;
366+
break;
367+
} catch (error) {
368+
if (isInputError(error)) {
369+
throw error;
370+
}
371+
if (abortSignal.aborted) {
372+
return;
373+
}
374+
const timeElapsed = Date.now() - startTimestamp;
375+
let backoffDuration = getFixedBackoffDuration(timeElapsed);
376+
if (backoffDuration === undefined) {
377+
postAttempts += 1;
378+
backoffDuration = getExponentialBackoffDuration(postAttempts);
379+
}
380+
console.warn(`Failed to load. Error message: ${error.message}. Retrying in ${backoffDuration} ms.`);
381+
await new Promise(resolve => setTimeout(resolve, backoffDuration));
382+
}
383+
} while (!abortSignal.aborted);
384+
}
385+
}
386+
319387
/**
320388
* Inspects the feature management package version.
321389
*/
@@ -426,7 +494,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
426494
this.#aiConfigurationTracing.reset();
427495
}
428496

429-
// process key-values, watched settings have higher priority
497+
// adapt configuration settings to key-values
430498
for (const setting of loadedSettings) {
431499
const [key, value] = await this.#processKeyValue(setting);
432500
keyValues.push([key, value]);
@@ -606,6 +674,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
606674
return response;
607675
}
608676

677+
// Only operations related to Azure App Configuration should be executed with failover policy.
609678
async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
610679
let clientWrappers = await this.#clientManager.getClients();
611680
if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) {
@@ -645,7 +714,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
645714
}
646715

647716
this.#clientManager.refreshClients();
648-
throw new Error("All clients failed to get configuration settings.");
717+
throw new Error("All fallback clients failed to get configuration settings.");
649718
}
650719

651720
async #processKeyValue(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
@@ -700,7 +769,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
700769
async #parseFeatureFlag(setting: ConfigurationSetting<string>): Promise<any> {
701770
const rawFlag = setting.value;
702771
if (rawFlag === undefined) {
703-
throw new Error("The value of configuration setting cannot be undefined.");
772+
throw new ArgumentError("The value of configuration setting cannot be undefined.");
704773
}
705774
const featureFlag = JSON.parse(rawFlag);
706775

@@ -762,13 +831,13 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
762831
return uniqueSelectors.map(selectorCandidate => {
763832
const selector = { ...selectorCandidate };
764833
if (!selector.keyFilter) {
765-
throw new Error("Key filter cannot be null or empty.");
834+
throw new ArgumentError("Key filter cannot be null or empty.");
766835
}
767836
if (!selector.labelFilter) {
768837
selector.labelFilter = LabelFilter.Null;
769838
}
770839
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
771-
throw new Error("The characters '*' and ',' are not supported in label filters.");
840+
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
772841
}
773842
return selector;
774843
});
@@ -792,9 +861,3 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
792861
});
793862
return getValidSelectors(selectors);
794863
}
795-
796-
function isFailoverableError(error: any): boolean {
797-
// ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory
798-
return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" ||
799-
(error.statusCode !== undefined && (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500)));
800-
}

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)