@@ -19,7 +19,8 @@ import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from ".
1919import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js" ;
2020import { IKeyValueAdapter } from "./IKeyValueAdapter.js" ;
2121import { 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" ;
2324import { Disposable } from "./common/disposable.js" ;
2425import {
2526 FEATURE_FLAGS_KEY_NAME ,
@@ -52,6 +53,10 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp
5253import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js" ;
5354import { KeyFilter , LabelFilter , SettingSelector } from "./types.js" ;
5455import { 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
5661type 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- }
0 commit comments