@@ -7,7 +7,8 @@ import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from ".
7
7
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js" ;
8
8
import { IKeyValueAdapter } from "./IKeyValueAdapter.js" ;
9
9
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" ;
11
12
import { Disposable } from "./common/disposable.js" ;
12
13
import {
13
14
FEATURE_FLAGS_KEY_NAME ,
@@ -33,6 +34,10 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp
33
34
import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js" ;
34
35
import { KeyFilter , LabelFilter , SettingSelector } from "./types.js" ;
35
36
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
36
41
37
42
type PagedSettingSelector = SettingSelector & {
38
43
/**
@@ -118,10 +123,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
118
123
} else {
119
124
for ( const setting of watchedSettings ) {
120
125
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." ) ;
122
127
}
123
128
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." ) ;
125
130
}
126
131
this . #sentinels. push ( setting ) ;
127
132
}
@@ -130,7 +135,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
130
135
// custom refresh interval
131
136
if ( refreshIntervalInMs !== undefined ) {
132
137
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.` ) ;
134
139
} else {
135
140
this . #kvRefreshInterval = refreshIntervalInMs ;
136
141
}
@@ -148,7 +153,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
148
153
// custom refresh interval
149
154
if ( refreshIntervalInMs !== undefined ) {
150
155
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.` ) ;
152
157
} else {
153
158
this . #ffRefreshInterval = refreshIntervalInMs ;
154
159
}
@@ -225,13 +230,40 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
225
230
* Loads the configuration store for the first time.
226
231
*/
227
232
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
232
266
}
233
- // Mark all settings have loaded at startup.
234
- this . #isInitialLoadCompleted = true ;
235
267
}
236
268
237
269
/**
@@ -241,7 +273,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
241
273
const separator = options ?. separator ?? "." ;
242
274
const validSeparators = [ "." , "," , ";" , "-" , "_" , "__" , "/" , ":" ] ;
243
275
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 ( ", " ) } .` ) ;
245
277
}
246
278
247
279
// construct hierarchical data object from map
@@ -254,22 +286,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
254
286
const segment = segments [ i ] ;
255
287
// undefined or empty string
256
288
if ( ! segment ) {
257
- throw new Error ( `invalid key: ${ key } `) ;
289
+ throw new InvalidOperationError ( `Failed to construct configuration object: Invalid key: ${ key } `) ;
258
290
}
259
291
// create path if not exist
260
292
if ( current [ segment ] === undefined ) {
261
293
current [ segment ] = { } ;
262
294
}
263
295
// The path has been occupied by a non-object value, causing ambiguity.
264
296
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.` ) ;
266
298
}
267
299
current = current [ segment ] ;
268
300
}
269
301
270
302
const lastSegment = segments [ segments . length - 1 ] ;
271
303
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.` ) ;
273
305
}
274
306
// set value to the last segment
275
307
current [ lastSegment ] = value ;
@@ -282,7 +314,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
282
314
*/
283
315
async refresh ( ) : Promise < void > {
284
316
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." ) ;
286
318
}
287
319
288
320
if ( this . #refreshInProgress) {
@@ -301,7 +333,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
301
333
*/
302
334
onRefresh ( listener : ( ) => any , thisArg ?: any ) : Disposable {
303
335
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." ) ;
305
337
}
306
338
307
339
const boundedListener = listener . bind ( thisArg ) ;
@@ -316,6 +348,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
316
348
return new Disposable ( remove ) ;
317
349
}
318
350
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
+
319
387
/**
320
388
* Inspects the feature management package version.
321
389
*/
@@ -426,7 +494,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
426
494
this . #aiConfigurationTracing. reset ( ) ;
427
495
}
428
496
429
- // process key-values, watched settings have higher priority
497
+ // adapt configuration settings to key-values
430
498
for ( const setting of loadedSettings ) {
431
499
const [ key , value ] = await this . #processKeyValue( setting ) ;
432
500
keyValues . push ( [ key , value ] ) ;
@@ -606,6 +674,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
606
674
return response ;
607
675
}
608
676
677
+ // Only operations related to Azure App Configuration should be executed with failover policy.
609
678
async #executeWithFailoverPolicy( funcToExecute : ( client : AppConfigurationClient ) => Promise < any > ) : Promise < any > {
610
679
let clientWrappers = await this . #clientManager. getClients ( ) ;
611
680
if ( this . #options?. loadBalancingEnabled && this . #lastSuccessfulEndpoint !== "" && clientWrappers . length > 1 ) {
@@ -645,7 +714,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
645
714
}
646
715
647
716
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." ) ;
649
718
}
650
719
651
720
async #processKeyValue( setting : ConfigurationSetting < string > ) : Promise < [ string , unknown ] > {
@@ -700,7 +769,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
700
769
async #parseFeatureFlag( setting : ConfigurationSetting < string > ) : Promise < any > {
701
770
const rawFlag = setting . value ;
702
771
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." ) ;
704
773
}
705
774
const featureFlag = JSON . parse ( rawFlag ) ;
706
775
@@ -762,13 +831,13 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
762
831
return uniqueSelectors . map ( selectorCandidate => {
763
832
const selector = { ...selectorCandidate } ;
764
833
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." ) ;
766
835
}
767
836
if ( ! selector . labelFilter ) {
768
837
selector . labelFilter = LabelFilter . Null ;
769
838
}
770
839
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." ) ;
772
841
}
773
842
return selector ;
774
843
} ) ;
@@ -792,9 +861,3 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
792
861
} ) ;
793
862
return getValidSelectors ( selectors ) ;
794
863
}
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