@@ -7,7 +7,8 @@ import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from ".
77import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js" ;
88import { IKeyValueAdapter } from "./IKeyValueAdapter.js" ;
99import { 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" ;
1112import { Disposable } from "./common/disposable.js" ;
1213import {
1314 FEATURE_FLAGS_KEY_NAME ,
@@ -33,6 +34,10 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp
3334import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js" ;
3435import { KeyFilter , LabelFilter , SettingSelector } from "./types.js" ;
3536import { 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
3742type 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- }
0 commit comments