Skip to content

Startup Retry & Configurable Startup Time-out & Error handling #166

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 43 commits into from
Apr 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2720628
support startup retry and timeout
zhiyuanliang-ms Feb 17, 2025
c15cf1b
update
zhiyuanliang-ms Feb 17, 2025
15c7e54
update
zhiyuanliang-ms Feb 17, 2025
90c4159
update
zhiyuanliang-ms Feb 17, 2025
a0e6543
add testcase
zhiyuanliang-ms Feb 17, 2025
435ff08
clarify error type
zhiyuanliang-ms Feb 19, 2025
326bf46
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms Feb 19, 2025
6a8ceb7
update
zhiyuanliang-ms Feb 19, 2025
fc1aa5b
update
zhiyuanliang-ms Feb 19, 2025
7de8a0d
update
zhiyuanliang-ms Feb 20, 2025
5d23399
fix lint
zhiyuanliang-ms Feb 20, 2025
233af51
handle keyvault error
zhiyuanliang-ms Feb 20, 2025
a0e0792
update
zhiyuanliang-ms Feb 20, 2025
9e32db4
update
zhiyuanliang-ms Feb 21, 2025
3a33738
update
zhiyuanliang-ms Feb 23, 2025
c637682
update
zhiyuanliang-ms Feb 23, 2025
a9bcea4
update
zhiyuanliang-ms Feb 23, 2025
00e2e6b
update
zhiyuanliang-ms Feb 23, 2025
1478e94
handle keyvault reference error
zhiyuanliang-ms Feb 23, 2025
9b50135
update
zhiyuanliang-ms Feb 23, 2025
39d9a3d
fix lint
zhiyuanliang-ms Feb 23, 2025
f8b76ed
update
zhiyuanliang-ms Feb 26, 2025
7e63ad5
update
zhiyuanliang-ms Feb 26, 2025
fe9ad2f
add boot loop protection
zhiyuanliang-ms Feb 27, 2025
1a10c89
Merge branch 'zhiyuanliang/startup-timeout' of https://github.com/Azu…
zhiyuanliang-ms Feb 27, 2025
48e1147
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms Feb 27, 2025
b19732d
update
zhiyuanliang-ms Mar 4, 2025
80108c9
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms Mar 13, 2025
009ccd5
update
zhiyuanliang-ms Mar 13, 2025
d61aba9
update testcase
zhiyuanliang-ms Mar 13, 2025
3d88c7a
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms Apr 23, 2025
73a24d4
update
zhiyuanliang-ms Apr 23, 2025
2c362ce
update testcase
zhiyuanliang-ms Apr 23, 2025
56f6265
update
zhiyuanliang-ms Apr 24, 2025
aa037e5
update
zhiyuanliang-ms Apr 25, 2025
3afb300
update
zhiyuanliang-ms Apr 25, 2025
f69be0d
move error.ts to common folder
zhiyuanliang-ms Apr 25, 2025
2f6585c
handle transient network error
zhiyuanliang-ms Apr 25, 2025
894a00e
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms Apr 25, 2025
4b22c86
update
zhiyuanliang-ms Apr 25, 2025
a0f5f1e
update
zhiyuanliang-ms Apr 27, 2025
f1e683e
keep error stack when fail to load
zhiyuanliang-ms Apr 28, 2025
4a31659
update testcase
zhiyuanliang-ms Apr 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
3 changes: 3 additions & 0 deletions src/AzureAppConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

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

/**
* Azure App Configuration provider.
*/
export type AzureAppConfiguration = {
/**
* API to trigger refresh operation.
Expand Down
119 changes: 91 additions & 28 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 & {
/**
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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
Expand All @@ -254,22 +286,22 @@ 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) {
current[segment] = {};
}
// 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;
Expand All @@ -282,7 +314,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
*/
async refresh(): Promise<void> {
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) {
Expand All @@ -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);
Expand All @@ -316,6 +348,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
return new Disposable(remove);
}

/**
* Initializes the configuration provider.
*/
async #initializeWithRetryPolicy(abortSignal: AbortSignal): Promise<void> {
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.
*/
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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<any>): Promise<any> {
let clientWrappers = await this.#clientManager.getClients();
if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) {
Expand Down Expand Up @@ -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<string>): Promise<[string, unknown]> {
Expand Down Expand Up @@ -700,7 +769,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
async #parseFeatureFlag(setting: ConfigurationSetting<string>): Promise<any> {
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);

Expand Down Expand Up @@ -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;
});
Expand All @@ -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)));
}
11 changes: 7 additions & 4 deletions src/AzureAppConfigurationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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.
*
Expand Down
Loading