Skip to content

Commit 56e544c

Browse files
amerjusupovicjimmyca15avanigupta
authored
Add StartupOptions for time-based retries on startup (#488)
* first draft time-based retries in startup * set startupdelay from configureclientoptions, fix syntax and spacing * allow configuring of startupclientoptions from options * rough draft startupoptions * fix draft * clarify summary for startupoptions * testing out retryoptions settings * update with new structure, need logic to calculate retry options for specified timeout * fix usage of startupconfigclientmanager, logic for retry time to use cancellationtoken * in progress fixing connect tests * fix connecttest, clientoptions in progress, need to fix maxretry logic * fix tests * extend startup timeout on test * remove unused variable * remove unused usings * remove max retries change * in progress working on fix for multiple clients with timeout * in progress fix initializeasync * in progress fixing failover logic * progress * fix with updates to logic for replicas * fix tests and logic in initializeasync * remove unused using * in progress fixing timing of cts * stuck on logic for srv dns changes, iasyncenumerable * progress * progress changing timeout to per store/replica, change options properties to internal * fix logic to be per store/replica * remove cancellationtoken from func * progress on looping over clients * fix iterating over clients, unit tests in progress * add backoff to if statement * working draft of design to retry replicas until timeout * updates to fix unit tests, bugs * fix variable naming hasNextClient * add comment to catch in initializeasync * prevent unnecessary delay when load finished * remove custom exception, fix logic in executewithfailover * move comment * fix small mistakes, rename variables * PR revisions * restructure startup retry logic, fix tests * Update src/Microsoft.Extensions.Configuration.AzureAppConfiguration/StartupOptions.cs Co-authored-by: Jimmy Campbell <[email protected]> * remove unnecessary param isstartup, fix text * PR revisions, fix configurestartupoptions summary * Apply suggestions from code review Co-authored-by: Jimmy Campbell <[email protected]> * combine if statemenets * simplify logic for catching operationcanceledexception Co-authored-by: Avani Gupta <[email protected]> * move call to getclients * PR revisions, make tests more specific * add new methods for jitter and startup backoff calculation, fix smaller issues with namespace/names * use new fixed + exponential backoff function * remove debugging statemenets * PR revisions * PR revisions, remove new exponential backoff method and update old one to use jitter * update jitter range logic * fix isFailoverable, change jitter ratio to 0.25 * update IsFailoverable --------- Co-authored-by: Jimmy Campbell <[email protected]> Co-authored-by: Avani Gupta <[email protected]>
1 parent 94e6871 commit 56e544c

File tree

13 files changed

+320
-83
lines changed

13 files changed

+320
-83
lines changed

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ internal IEnumerable<IKeyValueAdapter> Adapters
116116
/// </summary>
117117
internal FeatureFilterTelemetry FeatureFilterTelemetry { get; set; } = new FeatureFilterTelemetry();
118118

119+
/// <summary>
120+
/// Options used to configure provider startup.
121+
/// </summary>
122+
internal StartupOptions Startup { get; set; } = new StartupOptions();
123+
119124
/// <summary>
120125
/// Specify what key-values to include in the configuration provider.
121126
/// <see cref="Select"/> can be called multiple times to include multiple sets of key-values.
@@ -351,7 +356,7 @@ public AzureAppConfigurationOptions TrimKeyPrefix(string prefix)
351356
}
352357

353358
/// <summary>
354-
/// Configure the client used to communicate with Azure App Configuration.
359+
/// Configure the client(s) used to communicate with Azure App Configuration.
355360
/// </summary>
356361
/// <param name="configure">A callback used to configure Azure App Configuration client options.</param>
357362
public AzureAppConfigurationOptions ConfigureClientOptions(Action<ConfigurationClientOptions> configure)
@@ -429,6 +434,16 @@ public AzureAppConfigurationOptions Map(Func<ConfigurationSetting, ValueTask<Con
429434
return this;
430435
}
431436

437+
/// <summary>
438+
/// Configure the provider behavior when loading data from Azure App Configuration on startup.
439+
/// </summary>
440+
/// <param name="configure">A callback used to configure Azure App Configuration startup options.</param>
441+
public AzureAppConfigurationOptions ConfigureStartupOptions(Action<StartupOptions> configure)
442+
{
443+
configure?.Invoke(Startup);
444+
return this;
445+
}
446+
432447
private static ConfigurationClientOptions GetDefaultClientOptions()
433448
{
434449
var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2022_11_01_Preview);

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs

Lines changed: 152 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ public ILoggerFactory LoggerFactory
8888
}
8989
}
9090

91-
public AzureAppConfigurationProvider(IConfigurationClientManager clientManager, AzureAppConfigurationOptions options, bool optional)
91+
public AzureAppConfigurationProvider(IConfigurationClientManager configClientManager, AzureAppConfigurationOptions options, bool optional)
9292
{
93-
_configClientManager = clientManager ?? throw new ArgumentNullException(nameof(clientManager));
93+
_configClientManager = configClientManager ?? throw new ArgumentNullException(nameof(configClientManager));
9494
_options = options ?? throw new ArgumentNullException(nameof(options));
9595
_optional = optional;
9696

@@ -127,16 +127,13 @@ public override void Load()
127127
{
128128
var watch = Stopwatch.StartNew();
129129

130-
var loadStartTime = DateTimeOffset.UtcNow;
131-
132-
// Guaranteed to have atleast one available client since it is a application startup path.
133-
IEnumerable<ConfigurationClient> availableClients = _configClientManager.GetAvailableClients(loadStartTime);
134-
135130
try
136131
{
132+
using var startupCancellationTokenSource = new CancellationTokenSource(_options.Startup.Timeout);
133+
137134
// Load() is invoked only once during application startup. We don't need to check for concurrent network
138135
// operations here because there can't be any other startup or refresh operation in progress at this time.
139-
InitializeAsync(_optional, availableClients, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
136+
LoadAsync(_optional, startupCancellationTokenSource.Token).ConfigureAwait(false).GetAwaiter().GetResult();
140137
}
141138
catch (ArgumentException)
142139
{
@@ -205,7 +202,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken)
205202
if (InitializationCacheExpires < utcNow)
206203
{
207204
InitializationCacheExpires = utcNow.Add(MinCacheExpirationInterval);
208-
await InitializeAsync(ignoreFailures: false, availableClients, cancellationToken).ConfigureAwait(false);
205+
206+
await InitializeAsync(availableClients, cancellationToken).ConfigureAwait(false);
209207
}
210208

211209
return;
@@ -548,42 +546,135 @@ private async Task<Dictionary<string, string>> PrepareData(Dictionary<string, Co
548546
return applicationData;
549547
}
550548

551-
private async Task InitializeAsync(bool ignoreFailures, IEnumerable<ConfigurationClient> availableClients, CancellationToken cancellationToken = default)
549+
private async Task LoadAsync(bool ignoreFailures, CancellationToken cancellationToken)
552550
{
553-
Dictionary<string, ConfigurationSetting> data = null;
554-
Dictionary<KeyValueIdentifier, ConfigurationSetting> watchedSettings = null;
555-
551+
var startupStopwatch = Stopwatch.StartNew();
552+
553+
int postFixedWindowAttempts = 0;
554+
555+
var startupExceptions = new List<Exception>();
556+
556557
try
557558
{
558-
await ExecuteWithFailOverPolicyAsync(
559-
availableClients,
560-
async (client) =>
559+
while (true)
560+
{
561+
IEnumerable<ConfigurationClient> clients = _configClientManager.GetAllClients();
562+
563+
if (await TryInitializeAsync(clients, startupExceptions, cancellationToken).ConfigureAwait(false))
561564
{
562-
data = await LoadSelectedKeyValues(
563-
client,
564-
cancellationToken)
565-
.ConfigureAwait(false);
566-
567-
watchedSettings = await LoadKeyValuesRegisteredForRefresh(
568-
client,
569-
data,
570-
cancellationToken)
571-
.ConfigureAwait(false);
572-
573-
watchedSettings = UpdateWatchedKeyValueCollections(watchedSettings, data);
574-
},
575-
cancellationToken)
576-
.ConfigureAwait(false);
565+
break;
566+
}
567+
568+
TimeSpan delay;
569+
570+
if (startupStopwatch.Elapsed.TryGetFixedBackoff(out TimeSpan backoff))
571+
{
572+
delay = backoff;
573+
}
574+
else
575+
{
576+
postFixedWindowAttempts++;
577+
578+
delay = FailOverConstants.MinStartupBackoffDuration.CalculateBackoffDuration(
579+
FailOverConstants.MaxBackoffDuration,
580+
postFixedWindowAttempts);
581+
}
582+
583+
try
584+
{
585+
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
586+
}
587+
catch (OperationCanceledException)
588+
{
589+
throw new TimeoutException(
590+
$"The provider timed out while attempting to load.",
591+
new AggregateException(startupExceptions));
592+
}
593+
}
577594
}
578595
catch (Exception exception) when (
579596
ignoreFailures &&
580597
(exception is RequestFailedException ||
598+
exception is KeyVaultReferenceException ||
599+
exception is TimeoutException ||
581600
exception is OperationCanceledException ||
582601
exception is InvalidOperationException ||
583602
((exception as AggregateException)?.InnerExceptions?.Any(e =>
584603
e is RequestFailedException ||
585604
e is OperationCanceledException) ?? false)))
586605
{ }
606+
}
607+
608+
private async Task<bool> TryInitializeAsync(IEnumerable<ConfigurationClient> clients, List<Exception> startupExceptions, CancellationToken cancellationToken = default)
609+
{
610+
try
611+
{
612+
await InitializeAsync(clients, cancellationToken).ConfigureAwait(false);
613+
}
614+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
615+
{
616+
return false;
617+
}
618+
catch (RequestFailedException exception)
619+
{
620+
if (IsFailOverable(exception))
621+
{
622+
startupExceptions.Add(exception);
623+
624+
return false;
625+
}
626+
627+
throw;
628+
}
629+
catch (AggregateException exception)
630+
{
631+
if (exception.InnerExceptions?.Any(e => e is OperationCanceledException) ?? false)
632+
{
633+
if (!cancellationToken.IsCancellationRequested)
634+
{
635+
startupExceptions.Add(exception);
636+
}
637+
638+
return false;
639+
}
640+
641+
if (IsFailOverable(exception))
642+
{
643+
startupExceptions.Add(exception);
644+
645+
return false;
646+
}
647+
648+
throw;
649+
}
650+
651+
return true;
652+
}
653+
654+
private async Task InitializeAsync(IEnumerable<ConfigurationClient> clients, CancellationToken cancellationToken = default)
655+
{
656+
Dictionary<string, ConfigurationSetting> data = null;
657+
Dictionary<KeyValueIdentifier, ConfigurationSetting> watchedSettings = null;
658+
659+
await ExecuteWithFailOverPolicyAsync(
660+
clients,
661+
async (client) =>
662+
{
663+
data = await LoadSelectedKeyValues(
664+
client,
665+
cancellationToken)
666+
.ConfigureAwait(false);
667+
668+
watchedSettings = await LoadKeyValuesRegisteredForRefresh(
669+
client,
670+
data,
671+
cancellationToken)
672+
.ConfigureAwait(false);
673+
674+
watchedSettings = UpdateWatchedKeyValueCollections(watchedSettings, data);
675+
},
676+
cancellationToken)
677+
.ConfigureAwait(false);
587678

588679
// Update the cache expiration time for all refresh registered settings and feature flags
589680
foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers.Concat(_options.MultiKeyWatchers))
@@ -599,17 +690,10 @@ e is RequestFailedException ||
599690
adapter.InvalidateCache();
600691
}
601692

602-
try
603-
{
604-
Dictionary<string, ConfigurationSetting> mappedData = await MapConfigurationSettings(data).ConfigureAwait(false);
605-
SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false));
606-
_watchedSettings = watchedSettings;
607-
_mappedData = mappedData;
608-
}
609-
catch (KeyVaultReferenceException) when (ignoreFailures)
610-
{
611-
// ignore failures
612-
}
693+
Dictionary<string, ConfigurationSetting> mappedData = await MapConfigurationSettings(data).ConfigureAwait(false);
694+
SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false));
695+
_watchedSettings = watchedSettings;
696+
_mappedData = mappedData;
613697
}
614698
}
615699

@@ -856,7 +940,10 @@ private void UpdateCacheExpirationTime(KeyValueWatcher changeWatcher)
856940
changeWatcher.CacheExpires = DateTimeOffset.UtcNow.Add(cacheExpirationTime);
857941
}
858942

859-
private async Task<T> ExecuteWithFailOverPolicyAsync<T>(IEnumerable<ConfigurationClient> clients, Func<ConfigurationClient, Task<T>> funcToExecute, CancellationToken cancellationToken = default)
943+
private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
944+
IEnumerable<ConfigurationClient> clients,
945+
Func<ConfigurationClient, Task<T>> funcToExecute,
946+
CancellationToken cancellationToken = default)
860947
{
861948
using IEnumerator<ConfigurationClient> clientEnumerator = clients.GetEnumerator();
862949

@@ -929,7 +1016,10 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(IEnumerable<Configuratio
9291016
}
9301017
}
9311018

932-
private async Task ExecuteWithFailOverPolicyAsync(IEnumerable<ConfigurationClient> clients, Func<ConfigurationClient, Task> funcToExecute, CancellationToken cancellationToken = default)
1019+
private async Task ExecuteWithFailOverPolicyAsync(
1020+
IEnumerable<ConfigurationClient> clients,
1021+
Func<ConfigurationClient, Task> funcToExecute,
1022+
CancellationToken cancellationToken = default)
9331023
{
9341024
await ExecuteWithFailOverPolicyAsync<object>(clients, async (client) =>
9351025
{
@@ -948,18 +1038,28 @@ private bool IsFailOverable(AggregateException ex)
9481038

9491039
private bool IsFailOverable(RequestFailedException rfe)
9501040
{
1041+
if (rfe.Status == HttpStatusCodes.TooManyRequests ||
1042+
rfe.Status == (int)HttpStatusCode.RequestTimeout ||
1043+
rfe.Status >= (int)HttpStatusCode.InternalServerError)
1044+
{
1045+
return true;
1046+
}
1047+
1048+
Exception innerException;
9511049

952-
// The InnerException could be SocketException or WebException when endpoint is invalid and IOException if it is network issue.
953-
if (rfe.InnerException != null && rfe.InnerException is HttpRequestException hre && hre.InnerException != null)
1050+
if (rfe.InnerException is HttpRequestException hre)
1051+
{
1052+
innerException = hre.InnerException;
1053+
}
1054+
else
9541055
{
955-
return hre.InnerException is WebException ||
956-
hre.InnerException is SocketException ||
957-
hre.InnerException is IOException;
1056+
innerException = rfe.InnerException;
9581057
}
9591058

960-
return rfe.Status == HttpStatusCodes.TooManyRequests ||
961-
rfe.Status == (int)HttpStatusCode.RequestTimeout ||
962-
rfe.Status >= (int)HttpStatusCode.InternalServerError;
1059+
// The InnerException could be SocketException or WebException when an endpoint is invalid and IOException if it's a network issue.
1060+
return innerException is WebException ||
1061+
innerException is SocketException ||
1062+
innerException is IOException;
9631063
}
9641064

9651065
private async Task<Dictionary<string, ConfigurationSetting>> MapConfigurationSettings(Dictionary<string, ConfigurationSetting> data)

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@
44

55
using Azure.Core;
66
using Azure.Data.AppConfiguration;
7-
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Constants;
87
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions;
98
using System;
109
using System.Collections.Generic;
1110
using System.Linq;
12-
using System.Net;
1311

1412
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
1513
{
@@ -62,6 +60,11 @@ public IEnumerable<ConfigurationClient> GetAvailableClients(DateTimeOffset time)
6260
return _clients.Where(client => client.BackoffEndTime <= time).Select(c => c.Client).ToList();
6361
}
6462

63+
public IEnumerable<ConfigurationClient> GetAllClients()
64+
{
65+
return _clients.Select(c => c.Client).ToList();
66+
}
67+
6568
public void UpdateClientStatus(ConfigurationClient client, bool successful)
6669
{
6770
if (client == null)
@@ -96,7 +99,7 @@ public bool UpdateSyncToken(Uri endpoint, string syncToken)
9699
throw new ArgumentNullException(nameof(syncToken));
97100
}
98101

99-
ConfigurationClientWrapper clientWrapper = this._clients.SingleOrDefault(c => new EndpointComparer().Equals(c.Endpoint, endpoint));
102+
ConfigurationClientWrapper clientWrapper = _clients.SingleOrDefault(c => new EndpointComparer().Equals(c.Endpoint, endpoint));
100103

101104
if (clientWrapper != null)
102105
{

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/FailOverConstants.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44

55
using System;
66

7-
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Constants
7+
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
88
{
99
internal class FailOverConstants
1010
{
1111
// Timeouts to retry requests to config stores and their replicas after failure.
12+
1213
public static readonly TimeSpan MinBackoffDuration = TimeSpan.FromSeconds(30);
1314
public static readonly TimeSpan MaxBackoffDuration = TimeSpan.FromMinutes(10);
15+
// Minimum backoff duration for retries that occur after the fixed backoff window during startup.
16+
public static readonly TimeSpan MinStartupBackoffDuration = TimeSpan.FromSeconds(30);
1417
}
1518
}

0 commit comments

Comments
 (0)