From c1cdea3cde95ac3844a71f7fe0c0798e3db7b487 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 12 Mar 2025 16:47:24 -0400 Subject: [PATCH 1/9] Ensure kv collection refresh settings are not considered unless the feature is enabled. (#633) * Ensure that kv collection refresh interval is not used unless collection based refresh of key-values is enabled. Add tests to ensure that minimum refresh interval is respected for key-values and feature flags. * Remove duplicated tests. * fix. * Fix formatting. --- .../AzureAppConfigurationProvider.cs | 11 +++- .../RefreshTests.cs | 65 ++++++++++--------- .../Tests.AzureAppConfiguration/TestHelper.cs | 13 ++-- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index d7d629f8..9d61faca 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -118,6 +118,13 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan if (options.RegisterAllEnabled) { + if (options.KvCollectionRefreshInterval <= TimeSpan.Zero) + { + throw new ArgumentException( + $"{nameof(options.KvCollectionRefreshInterval)} must be greater than zero seconds when using RegisterAll for refresh", + nameof(options)); + } + MinRefreshInterval = TimeSpan.FromTicks(Math.Min(minWatcherRefreshInterval.Ticks, options.KvCollectionRefreshInterval.Ticks)); } else if (hasWatchers) @@ -206,7 +213,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) var utcNow = DateTimeOffset.UtcNow; IEnumerable refreshableIndividualKvWatchers = _options.IndividualKvWatchers.Where(kvWatcher => utcNow >= kvWatcher.NextRefreshTime); IEnumerable refreshableFfWatchers = _options.FeatureFlagWatchers.Where(ffWatcher => utcNow >= ffWatcher.NextRefreshTime); - bool isRefreshDue = utcNow >= _nextCollectionRefreshTime; + bool isRefreshDue = _options.RegisterAllEnabled && utcNow >= _nextCollectionRefreshTime; // Skip refresh if mappedData is loaded, but none of the watchers or adapters are refreshable. if (_mappedData != null && @@ -412,7 +419,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } } - if (isRefreshDue) + if (_options.RegisterAllEnabled && isRefreshDue) { _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); } diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index af6dd11d..e64b5184 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -212,7 +212,7 @@ public async Task RefreshTests_RefreshIsNotSkippedIfCacheIsExpired() _kvCollection[0] = TestHelpers.ChangeValue(FirstKeyValue, "newValue"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -247,7 +247,7 @@ public async Task RefreshTests_RefreshAllFalseDoesNotUpdateEntireConfiguration() _kvCollection = _kvCollection.Select(kv => TestHelpers.ChangeValue(kv, "newValue")).ToList(); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -284,7 +284,7 @@ public async Task RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() _kvCollection = _kvCollection.Select(kv => TestHelpers.ChangeValue(kv, "newValue")).ToList(); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -356,7 +356,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o keyValueCollection.Remove(keyValueCollection.FirstOrDefault(s => s.Key == "TestKey3" && s.Label == "label")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -430,7 +430,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o keyValueCollection.Remove(keyValueCollection.FirstOrDefault(s => s.Key == "TestKey3" && s.Label == "label")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -443,32 +443,33 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public async void RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() + public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() { var keyValueCollection = new List(_kvCollection); var requestCount = 0; var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + // Define delay for async operations + var operationDelay = TimeSpan.FromSeconds(6); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(() => { requestCount++; - Thread.Sleep(6000); - var copy = new List(); foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); }; - return new MockAsyncPageable(copy); + return new MockAsyncPageable(copy, operationDelay); }); - Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) + async Task> GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) { requestCount++; - Thread.Sleep(6000); + await Task.Delay(operationDelay, cancellationToken); var newSetting = keyValueCollection.FirstOrDefault(s => s.Key == setting.Key && s.Label == setting.Label); var unchanged = (newSetting.Key == setting.Key && newSetting.Label == setting.Label && newSetting.Value == setting.Value); @@ -477,7 +478,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Func>)GetIfChanged); + .Returns((Func>>)GetIfChanged); IConfigurationRefresher refresher = null; @@ -512,7 +513,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public void RefreshTests_RefreshAsyncThrowsOnRequestFailedException() + public async Task RefreshTests_RefreshAsyncThrowsOnRequestFailedException() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -539,7 +540,7 @@ public void RefreshTests_RefreshAsyncThrowsOnRequestFailedException() .Throws(new RequestFailedException("Request failed.")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); Action action = () => refresher.RefreshAsync().Wait(); Assert.Throws(action); @@ -575,7 +576,7 @@ public async Task RefreshTests_TryRefreshAsyncReturnsFalseOnRequestFailedExcepti .Throws(new RequestFailedException("Request failed.")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); bool result = await refresher.TryRefreshAsync(); Assert.False(result); @@ -608,7 +609,7 @@ public async Task RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrue _kvCollection[0] = TestHelpers.ChangeValue(_kvCollection[0], "newValue"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); bool result = await refresher.TryRefreshAsync(); Assert.True(result); @@ -651,13 +652,13 @@ public async Task RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFaile FirstKeyValue.Value = "newValue"; // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); // First call to GetConfigurationSettingAsync does not throw Assert.True(await refresher.TryRefreshAsync()); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); // Second call to GetConfigurationSettingAsync throws KeyVaultReferenceException Assert.False(await refresher.TryRefreshAsync()); @@ -704,7 +705,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o _kvCollection[0] = TestHelpers.ChangeValue(_kvCollection[0], "newValue"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await Assert.ThrowsAsync(async () => await refresher.RefreshAsync() @@ -748,7 +749,7 @@ public async Task RefreshTests_UpdatesAllSettingsIfInitialLoadFails() Assert.Null(configuration["TestKey3"]); // Make sure MinBackoffDuration has ended - Thread.Sleep(100); + await Task.Delay(100); // Act await Assert.ThrowsAsync(async () => @@ -763,7 +764,7 @@ await Assert.ThrowsAsync(async () => Assert.Null(configuration["TestKey3"]); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -825,7 +826,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o keyValueCollection = keyValueCollection.Select(kv => TestHelpers.ChangeValue(kv, "newValue")).ToList(); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); bool firstRefreshResult = await refresher.TryRefreshAsync(); Assert.False(firstRefreshResult); @@ -835,7 +836,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o Assert.Equal("TestValue3", config["TestKey3"]); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); bool secondRefreshResult = await refresher.TryRefreshAsync(); Assert.True(secondRefreshResult); @@ -876,7 +877,7 @@ public async Task RefreshTests_RefreshAllTrueForOverwrittenSentinelUpdatesEntire _kvCollection = _kvCollection.Select(kv => TestHelpers.ChangeValue(kv, "newValue")).ToList(); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -917,7 +918,7 @@ public async Task RefreshTests_RefreshAllFalseForOverwrittenSentinelUpdatesConfi _kvCollection[_kvCollection.IndexOf(refreshRegisteredSetting)] = TestHelpers.ChangeValue(refreshRegisteredSetting, "UpdatedValueForLabel1"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -959,7 +960,7 @@ public async Task RefreshTests_RefreshRegisteredKvOverwritesSelectedKv() _kvCollection[_kvCollection.IndexOf(refreshAllRegisteredSetting)] = TestHelpers.ChangeValue(refreshAllRegisteredSetting, "UpdatedValueForLabel1"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1020,7 +1021,7 @@ public void RefreshTests_ConfigureRefreshThrowsOnNoRegistration() } [Fact] - public void RefreshTests_RefreshIsCancelled() + public async Task RefreshTests_RefreshIsCancelled() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -1043,7 +1044,7 @@ public void RefreshTests_RefreshIsCancelled() FirstKeyValue.Value = "newValue1"; // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); using var cancellationSource = new CancellationTokenSource(); cancellationSource.Cancel(); @@ -1087,7 +1088,7 @@ public async Task RefreshTests_SelectedKeysRefreshWithRegisterAll() _kvCollection[2].Value = "newValue3"; // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1097,7 +1098,7 @@ public async Task RefreshTests_SelectedKeysRefreshWithRegisterAll() _kvCollection.RemoveAt(2); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1198,7 +1199,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1209,7 +1210,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) featureFlags.RemoveAt(0); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/TestHelper.cs index ca929efc..9fd3f388 100644 --- a/tests/Tests.AzureAppConfiguration/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/TestHelper.cs @@ -164,8 +164,9 @@ class MockAsyncPageable : AsyncPageable { private readonly List _collection = new List(); private int _status; + private readonly TimeSpan? _delay; - public MockAsyncPageable(List collection) + public MockAsyncPageable(List collection, TimeSpan? delay = null) { foreach (ConfigurationSetting setting in collection) { @@ -177,6 +178,7 @@ public MockAsyncPageable(List collection) } _status = 200; + _delay = delay; } public void UpdateCollection(List newCollection) @@ -207,10 +209,13 @@ public void UpdateCollection(List newCollection) } } -#pragma warning disable 1998 - public async override IAsyncEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) -#pragma warning restore 1998 + public override async IAsyncEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) { + if (_delay.HasValue) + { + await Task.Delay(_delay.Value); + } + yield return Page.FromValues(_collection, null, new MockResponse(_status)); } } From 87f0f85ca2e4011f82d93a864e35c6c804cd6c39 Mon Sep 17 00:00:00 2001 From: Richard chen <99175581+RichardChen820@users.noreply.github.com> Date: Tue, 18 Mar 2025 12:49:46 -0700 Subject: [PATCH 2/9] Shorten the defeult timeout of individual call to backend (#620) Co-authored-by: AMER JUSUPOVIC --- .../AzureAppConfigurationOptions.cs | 1035 +++++++++-------- .../AzureAppConfigurationProvider.cs | 7 + 2 files changed, 528 insertions(+), 514 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index a3dbfa05..db3b6c3d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -1,517 +1,524 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Azure.Core; -using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - /// - /// Options used to configure the behavior of an Azure App Configuration provider. - /// If neither nor is ever called, all key-values with no label are included in the configuration provider. - /// - public class AzureAppConfigurationOptions - { - private const int MaxRetries = 2; - private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// Options used to configure the behavior of an Azure App Configuration provider. + /// If neither nor is ever called, all key-values with no label are included in the configuration provider. + /// + public class AzureAppConfigurationOptions + { + private const int MaxRetries = 2; + private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); + private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(10); private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; - - private List _individualKvWatchers = new List(); - private List _ffWatchers = new List(); - private List _adapters; - private List>> _mappers = new List>>(); - private List _selectors; - private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher(); - private bool _selectCalled = false; - - // The following set is sorted in descending order. - // Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. - private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); - - /// - /// Flag to indicate whether replica discovery is enabled. - /// - public bool ReplicaDiscoveryEnabled { get; set; } = true; - - /// - /// Flag to indicate whether load balancing is enabled. - /// - public bool LoadBalancingEnabled { get; set; } - - /// - /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. - /// - internal IEnumerable ConnectionStrings { get; private set; } - - /// - /// The list of endpoints of an Azure App Configuration store. - /// If this property is set, the property also needs to be set. - /// - internal IEnumerable Endpoints { get; private set; } - - /// - /// The credential used to connect to the Azure App Configuration. - /// If this property is set, the property also needs to be set. - /// - internal TokenCredential Credential { get; private set; } - - /// - /// A collection of specified by user. - /// - internal IEnumerable Selectors => _selectors; - - /// - /// Indicates if was called. - /// - internal bool RegisterAllEnabled { get; private set; } - - /// - /// Refresh interval for selected key-value collections when is called. - /// - internal TimeSpan KvCollectionRefreshInterval { get; private set; } - - /// - /// A collection of . - /// - internal IEnumerable IndividualKvWatchers => _individualKvWatchers; - - /// - /// A collection of . - /// - internal IEnumerable FeatureFlagWatchers => _ffWatchers; - - /// - /// A collection of . - /// - internal IEnumerable Adapters - { - get => _adapters; - set => _adapters = value?.ToList(); - } - - /// - /// A collection of user defined functions that transform each . - /// - internal IEnumerable>> Mappers => _mappers; - - /// - /// A collection of key prefixes to be trimmed. - /// - internal IEnumerable KeyPrefixes => _keyPrefixes; - - /// - /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. - /// - internal IConfigurationClientManager ClientManager { get; set; } - - /// - /// For use in tests only. An optional class used to process pageable results from Azure App Configuration. - /// - internal IConfigurationSettingPageIterator ConfigurationSettingPageIterator { get; set; } - - /// - /// An optional timespan value to set the minimum backoff duration to a value other than the default. - /// - internal TimeSpan MinBackoffDuration { get; set; } = FailOverConstants.MinBackoffDuration; - - /// - /// Options used to configure the client used to communicate with Azure App Configuration. - /// - internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); - - /// - /// Flag to indicate whether Key Vault options have been configured. - /// - internal bool IsKeyVaultConfigured { get; private set; } = false; - - /// - /// Flag to indicate whether Key Vault secret values will be refreshed automatically. - /// - internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; - - /// - /// Indicates all types of feature filters used by the application. - /// - internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); - - /// - /// Options used to configure provider startup. - /// - internal StartupOptions Startup { get; set; } = new StartupOptions(); - - /// - /// Initializes a new instance of the class. - /// - public AzureAppConfigurationOptions() - { - _adapters = new List() + + private List _individualKvWatchers = new List(); + private List _ffWatchers = new List(); + private List _adapters; + private List>> _mappers = new List>>(); + private List _selectors; + private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher(); + private bool _selectCalled = false; + + // The following set is sorted in descending order. + // Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. + private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); + + /// + /// Flag to indicate whether replica discovery is enabled. + /// + public bool ReplicaDiscoveryEnabled { get; set; } = true; + + /// + /// Flag to indicate whether load balancing is enabled. + /// + public bool LoadBalancingEnabled { get; set; } + + /// + /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. + /// + internal IEnumerable ConnectionStrings { get; private set; } + + /// + /// The list of endpoints of an Azure App Configuration store. + /// If this property is set, the property also needs to be set. + /// + internal IEnumerable Endpoints { get; private set; } + + /// + /// The credential used to connect to the Azure App Configuration. + /// If this property is set, the property also needs to be set. + /// + internal TokenCredential Credential { get; private set; } + + /// + /// A collection of specified by user. + /// + internal IEnumerable Selectors => _selectors; + + /// + /// Indicates if was called. + /// + internal bool RegisterAllEnabled { get; private set; } + + /// + /// Refresh interval for selected key-value collections when is called. + /// + internal TimeSpan KvCollectionRefreshInterval { get; private set; } + + /// + /// A collection of . + /// + internal IEnumerable IndividualKvWatchers => _individualKvWatchers; + + /// + /// A collection of . + /// + internal IEnumerable FeatureFlagWatchers => _ffWatchers; + + /// + /// A collection of . + /// + internal IEnumerable Adapters + { + get => _adapters; + set => _adapters = value?.ToList(); + } + + /// + /// A collection of user defined functions that transform each . + /// + internal IEnumerable>> Mappers => _mappers; + + /// + /// A collection of key prefixes to be trimmed. + /// + internal IEnumerable KeyPrefixes => _keyPrefixes; + + /// + /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. + /// + internal IConfigurationClientManager ClientManager { get; set; } + + /// + /// For use in tests only. An optional class used to process pageable results from Azure App Configuration. + /// + internal IConfigurationSettingPageIterator ConfigurationSettingPageIterator { get; set; } + + /// + /// An optional timespan value to set the minimum backoff duration to a value other than the default. + /// + internal TimeSpan MinBackoffDuration { get; set; } = FailOverConstants.MinBackoffDuration; + + /// + /// Options used to configure the client used to communicate with Azure App Configuration. + /// + internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); + + /// + /// Flag to indicate whether Key Vault options have been configured. + /// + internal bool IsKeyVaultConfigured { get; private set; } = false; + + /// + /// Flag to indicate whether Key Vault secret values will be refreshed automatically. + /// + internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; + + /// + /// Indicates all types of feature filters used by the application. + /// + internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); + + /// + /// Options used to configure provider startup. + /// + internal StartupOptions Startup { get; set; } = new StartupOptions(); + + /// + /// Initializes a new instance of the class. + /// + public AzureAppConfigurationOptions() + { + _adapters = new List() + { + new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), + new JsonKeyValueAdapter(), + new FeatureManagementKeyValueAdapter(FeatureFlagTracing) + }; + + // Adds the default query to App Configuration if and are never called. + _selectors = new List { DefaultQuery }; + } + + /// + /// Specify what key-values to include in the configuration provider. + /// can be called multiple times to include multiple sets of key-values. + /// + /// + /// The key filter to apply when querying Azure App Configuration for key-values. + /// An asterisk (*) can be added to the end to return all key-values whose key begins with the key filter. + /// e.g. key filter `abc*` returns all key-values whose key starts with `abc`. + /// A comma (,) can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. + /// Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. + /// E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. + /// For all other cases the characters: asterisk (*), comma (,), and backslash (\) are reserved. Reserved characters must be escaped using a backslash (\). + /// e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. + /// Built-in key filter options: . + /// + /// + /// The label filter to apply when querying Azure App Configuration for key-values. By default the null label will be used. Built-in label filter options: + /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). + /// + public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null) + { + if (string.IsNullOrEmpty(keyFilter)) + { + throw new ArgumentNullException(nameof(keyFilter)); + } + + // Do not support * and , for label filter for now. + if (labelFilter != null && (labelFilter.Contains('*') || labelFilter.Contains(','))) + { + throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); + } + + if (string.IsNullOrWhiteSpace(labelFilter)) + { + labelFilter = LabelFilter.Null; + } + + if (!_selectCalled) + { + _selectors.Remove(DefaultQuery); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector + { + KeyFilter = keyFilter, + LabelFilter = labelFilter + }); + + return this; + } + + /// + /// Specify a snapshot and include its contained key-values in the configuration provider. + /// can be called multiple times to include key-values from multiple snapshots. + /// + /// The name of the snapshot in Azure App Configuration. + public AzureAppConfigurationOptions SelectSnapshot(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (!_selectCalled) + { + _selectors.Remove(DefaultQuery); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector + { + SnapshotName = name + }); + + return this; + } + + /// + /// Configures options for Azure App Configuration feature flags that will be parsed and transformed into feature management configuration. + /// If no filtering is specified via the then all feature flags with no label are loaded. + /// All loaded feature flags will be automatically registered for refresh as a collection. + /// + /// A callback used to configure feature flag options. + public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) + { + FeatureFlagOptions options = new FeatureFlagOptions(); + configure?.Invoke(options); + + if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) + { + throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, + string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); + } + + if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) + { + throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both."); + } + + if (options.FeatureFlagSelectors.Count() == 0) + { + // Select clause is not present + options.FeatureFlagSelectors.Add(new KeyValueSelector + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", + LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, + IsFeatureFlagSelector = true + }); + } + + foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) + { + _selectors.AppendUnique(featureFlagSelector); + + _ffWatchers.AppendUnique(new KeyValueWatcher + { + Key = featureFlagSelector.KeyFilter, + Label = featureFlagSelector.LabelFilter, + // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins + RefreshInterval = options.RefreshInterval + }); + } + + return this; + } + + /// + /// Connect the provider to the Azure App Configuration service via a connection string. + /// + /// + /// Used to authenticate with Azure App Configuration. + /// + public AzureAppConfigurationOptions Connect(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return Connect(new List { connectionString }); + } + + /// + /// Connect the provider to an Azure App Configuration store and its replicas via a list of connection strings. + /// + /// + /// Used to authenticate with Azure App Configuration. + /// + public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) + { + if (connectionStrings == null || !connectionStrings.Any()) + { + throw new ArgumentNullException(nameof(connectionStrings)); + } + + if (connectionStrings.Distinct().Count() != connectionStrings.Count()) + { + throw new ArgumentException($"All values in '{nameof(connectionStrings)}' must be unique."); + } + + Endpoints = null; + Credential = null; + ConnectionStrings = connectionStrings; + return this; + } + + /// + /// Connect the provider to Azure App Configuration using endpoint and token credentials. + /// + /// The endpoint of the Azure App Configuration to connect to. + /// Token credentials to use to connect. + public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential credential) + { + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + + return Connect(new List() { endpoint }, credential); + } + + /// + /// Connect the provider to an Azure App Configuration store and its replicas using a list of endpoints and a token credential. + /// + /// The list of endpoints of an Azure App Configuration store and its replicas to connect to. + /// Token credential to use to connect. + public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) + { + if (endpoints == null || !endpoints.Any()) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (endpoints.Distinct(new EndpointComparer()).Count() != endpoints.Count()) + { + throw new ArgumentException($"All values in '{nameof(endpoints)}' must be unique."); + } + + Credential = credential ?? throw new ArgumentNullException(nameof(credential)); + + Endpoints = endpoints; + ConnectionStrings = null; + return this; + } + + /// + /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. + /// + /// The prefix to be trimmed. + public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + throw new ArgumentNullException(nameof(prefix)); + } + + _keyPrefixes.Add(prefix); + return this; + } + + /// + /// Configure the client(s) used to communicate with Azure App Configuration. + /// + /// A callback used to configure Azure App Configuration client options. + public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) + { + configure?.Invoke(ClientOptions); + return this; + } + + /// + /// Configure refresh for key-values in the configuration provider. + /// + /// A callback used to configure Azure App Configuration refresh options. + public AzureAppConfigurationOptions ConfigureRefresh(Action configure) + { + if (RegisterAllEnabled) + { + throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() cannot be invoked multiple times when {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} has been invoked."); + } + + var refreshOptions = new AzureAppConfigurationRefreshOptions(); + configure?.Invoke(refreshOptions); + + bool isRegisterCalled = refreshOptions.RefreshRegistrations.Any(); + RegisterAllEnabled = refreshOptions.RegisterAllEnabled; + + if (!isRegisterCalled && !RegisterAllEnabled) + { + throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() must call either {nameof(AzureAppConfigurationRefreshOptions.Register)}()" + + $" or {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)}()"); + } + + // Check if both register methods are called at any point + if (RegisterAllEnabled && (_individualKvWatchers.Any() || isRegisterCalled)) + { + throw new InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " + + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); + } + + if (RegisterAllEnabled) + { + KvCollectionRefreshInterval = refreshOptions.RefreshInterval; + } + else + { + foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) + { + item.RefreshInterval = refreshOptions.RefreshInterval; + _individualKvWatchers.Add(item); + } + } + + return this; + } + + /// + /// Get an instance of that can be used to trigger a refresh for the registered key-values. + /// + /// An instance of . + public IConfigurationRefresher GetRefresher() + { + return _refresher; + } + + /// + /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. + /// + /// A callback used to configure Azure App Configuration key vault options. + public AzureAppConfigurationOptions ConfigureKeyVault(Action configure) + { + var keyVaultOptions = new AzureAppConfigurationKeyVaultOptions(); + configure?.Invoke(keyVaultOptions); + + if (keyVaultOptions.Credential != null && keyVaultOptions.SecretResolver != null) + { + throw new InvalidOperationException($"Cannot configure both default credentials and secret resolver for Key Vault references. Please call either {nameof(keyVaultOptions.SetCredential)} or {nameof(keyVaultOptions.SetSecretResolver)} method, not both."); + } + + _adapters.RemoveAll(a => a is AzureKeyVaultKeyValueAdapter); + _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); + + IsKeyVaultRefreshConfigured = keyVaultOptions.IsKeyVaultRefreshConfigured; + IsKeyVaultConfigured = true; + return this; + } + + /// + /// Provides a way to transform settings retrieved from App Configuration before they are processed by the configuration provider. + /// + /// A callback registered by the user to transform each configuration setting. + public AzureAppConfigurationOptions Map(Func> mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + _mappers.Add(mapper); + return this; + } + + /// + /// Configure the provider behavior when loading data from Azure App Configuration on startup. + /// + /// A callback used to configure Azure App Configuration startup options. + public AzureAppConfigurationOptions ConfigureStartupOptions(Action configure) + { + configure?.Invoke(Startup); + return this; + } + + private static ConfigurationClientOptions GetDefaultClientOptions() + { + var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01); + clientOptions.Retry.MaxRetries = MaxRetries; + clientOptions.Retry.MaxDelay = MaxRetryDelay; + clientOptions.Retry.Mode = RetryMode.Exponential; + clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); + clientOptions.Transport = new HttpClientTransport(new HttpClient() { - new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), - new JsonKeyValueAdapter(), - new FeatureManagementKeyValueAdapter(FeatureFlagTracing) - }; - - // Adds the default query to App Configuration if and are never called. - _selectors = new List { DefaultQuery }; - } - - /// - /// Specify what key-values to include in the configuration provider. - /// can be called multiple times to include multiple sets of key-values. - /// - /// - /// The key filter to apply when querying Azure App Configuration for key-values. - /// An asterisk (*) can be added to the end to return all key-values whose key begins with the key filter. - /// e.g. key filter `abc*` returns all key-values whose key starts with `abc`. - /// A comma (,) can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. - /// Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. - /// E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. - /// For all other cases the characters: asterisk (*), comma (,), and backslash (\) are reserved. Reserved characters must be escaped using a backslash (\). - /// e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. - /// Built-in key filter options: . - /// - /// - /// The label filter to apply when querying Azure App Configuration for key-values. By default the null label will be used. Built-in label filter options: - /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). - /// - public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null) - { - if (string.IsNullOrEmpty(keyFilter)) - { - throw new ArgumentNullException(nameof(keyFilter)); - } - - // Do not support * and , for label filter for now. - if (labelFilter != null && (labelFilter.Contains('*') || labelFilter.Contains(','))) - { - throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); - } - - if (string.IsNullOrWhiteSpace(labelFilter)) - { - labelFilter = LabelFilter.Null; - } - - if (!_selectCalled) - { - _selectors.Remove(DefaultQuery); - - _selectCalled = true; - } - - _selectors.AppendUnique(new KeyValueSelector - { - KeyFilter = keyFilter, - LabelFilter = labelFilter - }); - - return this; - } - - /// - /// Specify a snapshot and include its contained key-values in the configuration provider. - /// can be called multiple times to include key-values from multiple snapshots. - /// - /// The name of the snapshot in Azure App Configuration. - public AzureAppConfigurationOptions SelectSnapshot(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - if (!_selectCalled) - { - _selectors.Remove(DefaultQuery); - - _selectCalled = true; - } - - _selectors.AppendUnique(new KeyValueSelector - { - SnapshotName = name + Timeout = NetworkTimeout }); - - return this; - } - - /// - /// Configures options for Azure App Configuration feature flags that will be parsed and transformed into feature management configuration. - /// If no filtering is specified via the then all feature flags with no label are loaded. - /// All loaded feature flags will be automatically registered for refresh as a collection. - /// - /// A callback used to configure feature flag options. - public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) - { - FeatureFlagOptions options = new FeatureFlagOptions(); - configure?.Invoke(options); - - if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) - { - throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, - string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); - } - - if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) - { - throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both."); - } - - if (options.FeatureFlagSelectors.Count() == 0) - { - // Select clause is not present - options.FeatureFlagSelectors.Add(new KeyValueSelector - { - KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", - LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, - IsFeatureFlagSelector = true - }); - } - - foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) - { - _selectors.AppendUnique(featureFlagSelector); - - _ffWatchers.AppendUnique(new KeyValueWatcher - { - Key = featureFlagSelector.KeyFilter, - Label = featureFlagSelector.LabelFilter, - // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins - RefreshInterval = options.RefreshInterval - }); - } - - return this; - } - - /// - /// Connect the provider to the Azure App Configuration service via a connection string. - /// - /// - /// Used to authenticate with Azure App Configuration. - /// - public AzureAppConfigurationOptions Connect(string connectionString) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new ArgumentNullException(nameof(connectionString)); - } - - return Connect(new List { connectionString }); - } - - /// - /// Connect the provider to an Azure App Configuration store and its replicas via a list of connection strings. - /// - /// - /// Used to authenticate with Azure App Configuration. - /// - public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) - { - if (connectionStrings == null || !connectionStrings.Any()) - { - throw new ArgumentNullException(nameof(connectionStrings)); - } - - if (connectionStrings.Distinct().Count() != connectionStrings.Count()) - { - throw new ArgumentException($"All values in '{nameof(connectionStrings)}' must be unique."); - } - - Endpoints = null; - Credential = null; - ConnectionStrings = connectionStrings; - return this; - } - - /// - /// Connect the provider to Azure App Configuration using endpoint and token credentials. - /// - /// The endpoint of the Azure App Configuration to connect to. - /// Token credentials to use to connect. - public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential credential) - { - if (endpoint == null) - { - throw new ArgumentNullException(nameof(endpoint)); - } - - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - - return Connect(new List() { endpoint }, credential); - } - - /// - /// Connect the provider to an Azure App Configuration store and its replicas using a list of endpoints and a token credential. - /// - /// The list of endpoints of an Azure App Configuration store and its replicas to connect to. - /// Token credential to use to connect. - public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) - { - if (endpoints == null || !endpoints.Any()) - { - throw new ArgumentNullException(nameof(endpoints)); - } - - if (endpoints.Distinct(new EndpointComparer()).Count() != endpoints.Count()) - { - throw new ArgumentException($"All values in '{nameof(endpoints)}' must be unique."); - } - - Credential = credential ?? throw new ArgumentNullException(nameof(credential)); - - Endpoints = endpoints; - ConnectionStrings = null; - return this; - } - - /// - /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. - /// - /// The prefix to be trimmed. - public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) - { - if (string.IsNullOrEmpty(prefix)) - { - throw new ArgumentNullException(nameof(prefix)); - } - - _keyPrefixes.Add(prefix); - return this; - } - - /// - /// Configure the client(s) used to communicate with Azure App Configuration. - /// - /// A callback used to configure Azure App Configuration client options. - public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) - { - configure?.Invoke(ClientOptions); - return this; - } - - /// - /// Configure refresh for key-values in the configuration provider. - /// - /// A callback used to configure Azure App Configuration refresh options. - public AzureAppConfigurationOptions ConfigureRefresh(Action configure) - { - if (RegisterAllEnabled) - { - throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() cannot be invoked multiple times when {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} has been invoked."); - } - - var refreshOptions = new AzureAppConfigurationRefreshOptions(); - configure?.Invoke(refreshOptions); - - bool isRegisterCalled = refreshOptions.RefreshRegistrations.Any(); - RegisterAllEnabled = refreshOptions.RegisterAllEnabled; - - if (!isRegisterCalled && !RegisterAllEnabled) - { - throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() must call either {nameof(AzureAppConfigurationRefreshOptions.Register)}()" + - $" or {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)}()"); - } - - // Check if both register methods are called at any point - if (RegisterAllEnabled && (_individualKvWatchers.Any() || isRegisterCalled)) - { - throw new InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " - + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); - } - - if (RegisterAllEnabled) - { - KvCollectionRefreshInterval = refreshOptions.RefreshInterval; - } - else - { - foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) - { - item.RefreshInterval = refreshOptions.RefreshInterval; - _individualKvWatchers.Add(item); - } - } - - return this; - } - - /// - /// Get an instance of that can be used to trigger a refresh for the registered key-values. - /// - /// An instance of . - public IConfigurationRefresher GetRefresher() - { - return _refresher; - } - - /// - /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. - /// - /// A callback used to configure Azure App Configuration key vault options. - public AzureAppConfigurationOptions ConfigureKeyVault(Action configure) - { - var keyVaultOptions = new AzureAppConfigurationKeyVaultOptions(); - configure?.Invoke(keyVaultOptions); - - if (keyVaultOptions.Credential != null && keyVaultOptions.SecretResolver != null) - { - throw new InvalidOperationException($"Cannot configure both default credentials and secret resolver for Key Vault references. Please call either {nameof(keyVaultOptions.SetCredential)} or {nameof(keyVaultOptions.SetSecretResolver)} method, not both."); - } - - _adapters.RemoveAll(a => a is AzureKeyVaultKeyValueAdapter); - _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); - - IsKeyVaultRefreshConfigured = keyVaultOptions.IsKeyVaultRefreshConfigured; - IsKeyVaultConfigured = true; - return this; - } - - /// - /// Provides a way to transform settings retrieved from App Configuration before they are processed by the configuration provider. - /// - /// A callback registered by the user to transform each configuration setting. - public AzureAppConfigurationOptions Map(Func> mapper) - { - if (mapper == null) - { - throw new ArgumentNullException(nameof(mapper)); - } - - _mappers.Add(mapper); - return this; - } - - /// - /// Configure the provider behavior when loading data from Azure App Configuration on startup. - /// - /// A callback used to configure Azure App Configuration startup options. - public AzureAppConfigurationOptions ConfigureStartupOptions(Action configure) - { - configure?.Invoke(Startup); - return this; - } - - private static ConfigurationClientOptions GetDefaultClientOptions() - { - var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01); - clientOptions.Retry.MaxRetries = MaxRetries; - clientOptions.Retry.MaxDelay = MaxRetryDelay; - clientOptions.Retry.Mode = RetryMode.Exponential; - clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); - - return clientOptions; - } - } -} + + return clientOptions; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 9d61faca..795aeb10 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -1209,6 +1209,13 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => private bool IsFailOverable(AggregateException ex) { + TaskCanceledException tce = ex.InnerExceptions?.LastOrDefault(e => e is TaskCanceledException) as TaskCanceledException; + + if (tce != null && tce.InnerException is TimeoutException) + { + return true; + } + RequestFailedException rfe = ex.InnerExceptions?.LastOrDefault(e => e is RequestFailedException) as RequestFailedException; return rfe != null ? IsFailOverable(rfe) : false; From 2de6c93e5fb929f68d6d86b510e48c0175f36f8c Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:09:25 -0700 Subject: [PATCH 3/9] Use TryAddSingleton instead of AddSingleton for extension (#643) * use TryAddSingleton instead of AddSingleton in extension, fix refresherprovider accordingly * remove unused using * add comment --- .../AzureAppConfigurationExtensions.cs | 5 +-- .../AzureAppConfigurationRefresherProvider.cs | 35 ++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs index 8b4bf8c8..f8c2c5ca 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs @@ -3,6 +3,7 @@ // using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using System; using System.Collections.Generic; using System.Security; @@ -99,11 +100,11 @@ public static IServiceCollection AddAzureAppConfiguration(this IServiceCollectio if (!_isProviderDisabled) { services.AddLogging(); - services.AddSingleton(); + services.TryAddSingleton(); } else { - services.AddSingleton(); + services.TryAddSingleton(); } return services; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs index e86f6cc1..d2b31071 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs @@ -13,11 +13,38 @@ internal class AzureAppConfigurationRefresherProvider : IConfigurationRefresherP { private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); - public IEnumerable Refreshers { get; } + private readonly IConfiguration _configuration; + private readonly ILoggerFactory _loggerFactory; + private IEnumerable _refreshers; + private bool _rediscoveredRefreshers = false; - public AzureAppConfigurationRefresherProvider(IConfiguration configuration, ILoggerFactory _loggerFactory) + public IEnumerable Refreshers { - var configurationRoot = configuration as IConfigurationRoot; + get + { + // Ensure latest refreshers are discovered if the configuration has changed since the constructor was called + if (!_rediscoveredRefreshers) + { + _refreshers = DiscoverRefreshers(); + + _rediscoveredRefreshers = true; + } + + return _refreshers; + } + } + + public AzureAppConfigurationRefresherProvider(IConfiguration configuration, ILoggerFactory loggerFactory) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _loggerFactory = loggerFactory; + + _refreshers = DiscoverRefreshers(); + } + + private IEnumerable DiscoverRefreshers() + { + var configurationRoot = _configuration as IConfigurationRoot; var refreshers = new List(); FindRefreshers(configurationRoot, _loggerFactory, refreshers); @@ -27,7 +54,7 @@ public AzureAppConfigurationRefresherProvider(IConfiguration configuration, ILog throw new InvalidOperationException("Unable to access the Azure App Configuration provider. Please ensure that it has been configured correctly."); } - Refreshers = refreshers; + return refreshers; } private void FindRefreshers(IConfigurationRoot configurationRoot, ILoggerFactory loggerFactory, List refreshers) From 2b004fb827dbb9fbd3c82e17022305ab749eb04c Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 17 Apr 2025 10:31:45 +0800 Subject: [PATCH 4/9] Fix format (#647) * fix format * fix format * fix format * fix format --- .../AzureAppConfigurationOptions.cs | 10 +++++----- .../ConfigurationSettingPageExtensions.cs | 4 ++-- .../IConfigurationSettingPageIterator.cs | 4 ++-- .../FeatureManagementTests.cs | 7 +++++-- .../KeyVaultReferenceTests.cs | 4 ++-- tests/Tests.AzureAppConfiguration/RefreshTests.cs | 6 +++--- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index db3b6c3d..67e8e993 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -2,7 +2,7 @@ // Licensed under the MIT license. // using Azure.Core; -using Azure.Core.Pipeline; +using Azure.Core.Pipeline; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; @@ -11,7 +11,7 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; +using System.Net.Http; using System.Threading.Tasks; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration @@ -25,7 +25,7 @@ public class AzureAppConfigurationOptions private const int MaxRetries = 2; private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(10); - private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; + private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; private List _individualKvWatchers = new List(); private List _ffWatchers = new List(); @@ -514,9 +514,9 @@ private static ConfigurationClientOptions GetDefaultClientOptions() clientOptions.Retry.Mode = RetryMode.Exponential; clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); clientOptions.Transport = new HttpClientTransport(new HttpClient() - { + { Timeout = NetworkTimeout - }); + }); return clientOptions; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs index aba7684b..ad69330d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs @@ -1,5 +1,5 @@ -using Azure.Data.AppConfiguration; -using Azure; +using Azure; +using Azure.Data.AppConfiguration; using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs index 08c95751..7bbc3ded 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs @@ -1,5 +1,5 @@ -using Azure.Data.AppConfiguration; -using Azure; +using Azure; +using Azure.Data.AppConfiguration; using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index d96e9e39..2f935381 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -731,6 +731,7 @@ public async Task WatchesFeatureFlags() } [Fact] + [Obsolete] public async Task WatchesFeatureFlagsUsingCacheExpirationInterval() { var featureFlags = new List { _kv }; @@ -874,6 +875,7 @@ public async Task SkipRefreshIfRefreshIntervalHasNotElapsed() } [Fact] + [Obsolete] public async Task SkipRefreshIfCacheNotExpired() { var featureFlags = new List { _kv }; @@ -1174,7 +1176,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) if (newSetting != null) copy.Add(TestHelpers.CloneSetting(newSetting)); return new MockAsyncPageable(copy); - }; + } var testClient = mockClient.Object; @@ -1199,6 +1201,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) } [Fact] + [Obsolete] public void AlternateValidFeatureFlagFormats() { var mockResponse = new Mock(); @@ -1215,7 +1218,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) if (newSetting != null) copy.Add(TestHelpers.CloneSetting(newSetting)); return new MockAsyncPageable(copy); - }; + } var testClient = mockClient.Object; diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs index 274bfe7f..3e856a1b 100644 --- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs @@ -982,7 +982,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) if (newSetting != null) copy.Add(TestHelpers.CloneSetting(newSetting)); return new MockAsyncPageable(copy); - }; + } var testClient = mockClient.Object; @@ -1028,7 +1028,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) if (newSetting != null) copy.Add(TestHelpers.CloneSetting(newSetting)); return new MockAsyncPageable(copy); - }; + } var testClient = mockClient.Object; diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index e64b5184..c27a48ff 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -320,7 +320,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy); }); @@ -392,7 +392,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy); }); @@ -461,7 +461,7 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy, operationDelay); }); From 3d51200ee2b2f971166ade7a44fe5f72a5dd6c96 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Thu, 17 Apr 2025 09:50:20 -0700 Subject: [PATCH 5/9] Add request tracing for content type (#646) * add initial content type pattern * format fix * fix comment * update to account for chat completion vs ai profiles * in progress fix adapter to use existing requesttracingoptions * use content type tracing to pass to requesttracingoptions * fix comments and naming * remove unneeded file * add check for request tracing enabled * check content type in preparedata * remove errors * fix spacing * fix test * remove unused usings, add back catch for .net framework * fix parsing * rename constants * fix indent * update for PR comments * PR comments, update if conditions * add isjson check * update isjson extension --- .../AzureAppConfigurationOptions.cs | 2 +- .../AzureAppConfigurationProvider.cs | 14 +++- .../Constants/RequestTracingConstants.cs | 6 ++ .../Extensions/ContentTypeExtensions.cs | 81 +++++++++++++++++++ .../Extensions/StringExtensions.cs | 30 +++++++ .../JsonKeyValueAdapter.cs | 42 +--------- .../RequestTracingOptions.cs | 68 +++++++++++++++- 7 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 67e8e993..6be96b65 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -141,7 +141,7 @@ internal IEnumerable Adapters internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; /// - /// Indicates all types of feature filters used by the application. + /// Indicates all feature flag features used by the application. /// internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 795aeb10..d86133ae 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -597,9 +597,21 @@ private async Task> PrepareData(Dictionary kvp in data) { IEnumerable> keyValuePairs = null; + + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.UpdateAiConfigurationTracing(kvp.Value.ContentType); + } + keyValuePairs = await ProcessAdapters(kvp.Value, cancellationToken).ConfigureAwait(false); foreach (KeyValuePair kv in keyValuePairs) @@ -636,7 +648,7 @@ private async Task LoadAsync(bool ignoreFailures, CancellationToken cancellation { IEnumerable clients = _configClientManager.GetClients(); - if (_requestTracingOptions != null) + if (_requestTracingEnabled && _requestTracingOptions != null) { _requestTracingOptions.ReplicaCount = clients.Count() - 1; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index f732ab95..612e1bcc 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -31,6 +31,9 @@ internal class RequestTracingConstants public const string ReplicaCountKey = "ReplicaCount"; public const string FeaturesKey = "Features"; public const string LoadBalancingEnabledTag = "LB"; + public const string AIConfigurationTag = "AI"; + public const string AIChatCompletionConfigurationTag = "AICC"; + public const string SignalRUsedTag = "SignalR"; public const string FailoverRequestTag = "Failover"; public const string PushRefreshTag = "PushRefresh"; @@ -54,5 +57,8 @@ internal class RequestTracingConstants public const string SignalRAssemblyName = "Microsoft.AspNetCore.SignalR"; public const string Delimiter = "+"; + + public const string AIMimeProfile = "https://azconfig.io/mime-profiles/ai"; + public const string AIChatCompletionMimeProfile = "https://azconfig.io/mime-profiles/ai/chat-completion"; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs new file mode 100644 index 00000000..2f739a52 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Linq; +using System; +using System.Net.Mime; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions +{ + internal static class ContentTypeExtensions + { + private static readonly IEnumerable ExcludedJsonContentTypes = new[] + { + FeatureManagementConstants.ContentType, + KeyVaultConstants.ContentType + }; + + public static bool IsAi(this ContentType contentType) + { + return contentType != null && + contentType.IsJson() && + contentType.Parameters.ContainsKey("profile") && + !string.IsNullOrEmpty(contentType.Parameters["profile"]) && + contentType.Parameters["profile"].StartsWith(RequestTracingConstants.AIMimeProfile); + } + + public static bool IsAiChatCompletion(this ContentType contentType) + { + return contentType != null && + contentType.IsJson() && + contentType.Parameters.ContainsKey("profile") && + !string.IsNullOrEmpty(contentType.Parameters["profile"]) && + contentType.Parameters["profile"].StartsWith(RequestTracingConstants.AIChatCompletionMimeProfile); + } + + public static bool IsJson(this ContentType contentType) + { + if (contentType == null) + { + return false; + } + + string acceptedMainType = "application"; + string acceptedSubType = "json"; + string mediaType = contentType.MediaType; + + if (!ExcludedJsonContentTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) + { + ReadOnlySpan mediaTypeSpan = mediaType.AsSpan(); + + // Since contentType has been validated using System.Net.Mime.ContentType, + // mediaType will always have exactly 2 parts after splitting on '/' + int slashIndex = mediaTypeSpan.IndexOf('/'); + + if (mediaTypeSpan.Slice(0, slashIndex).Equals(acceptedMainType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan subTypeSpan = mediaTypeSpan.Slice(slashIndex + 1); + + while (!subTypeSpan.IsEmpty) + { + int plusIndex = subTypeSpan.IndexOf('+'); + + ReadOnlySpan currentSubType = plusIndex == -1 ? subTypeSpan : subTypeSpan.Slice(0, plusIndex); + + if (currentSubType.Equals(acceptedSubType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + subTypeSpan = plusIndex == -1 ? ReadOnlySpan.Empty : subTypeSpan.Slice(plusIndex + 1); + } + } + } + + return false; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs index e97dfa2f..7ee11482 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs @@ -1,10 +1,40 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System; +using System.Net.Mime; + namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class StringExtensions { + public static bool TryParseContentType(this string contentTypeString, out ContentType contentType) + { + contentType = null; + + if (string.IsNullOrWhiteSpace(contentTypeString)) + { + return false; + } + + try + { + contentType = new ContentType(contentTypeString.Trim()); + + return true; + } + catch (FormatException) + { + return false; + } + catch (IndexOutOfRangeException) + { + // Bug in System.Net.Mime.ContentType throws this if contentType is "xyz/" + // https://github.com/dotnet/runtime/issues/39337 + return false; + } + } + public static string NormalizeNull(this string s) { return s == LabelFilter.Null ? null : s; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index b4448e32..87c28d69 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -2,11 +2,10 @@ // Licensed under the MIT license. // using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using System; using System.Collections.Generic; -using System.Linq; using System.Net.Mime; using System.Text.Json; using System.Threading; @@ -16,12 +15,6 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal class JsonKeyValueAdapter : IKeyValueAdapter { - private static readonly IEnumerable ExcludedJsonContentTypes = new[] - { - FeatureManagementConstants.ContentType, - KeyVaultConstants.ContentType - }; - public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { if (setting == null) @@ -58,38 +51,9 @@ public bool CanProcess(ConfigurationSetting setting) return false; } - string acceptedMainType = "application"; - string acceptedSubType = "json"; - string mediaType; - - try + if (setting.ContentType.TryParseContentType(out ContentType contentType)) { - mediaType = new ContentType(setting.ContentType.Trim()).MediaType; - } - catch (FormatException) - { - return false; - } - catch (IndexOutOfRangeException) - { - // Bug in System.Net.Mime.ContentType throws this if contentType is "xyz/" - // https://github.com/dotnet/runtime/issues/39337 - return false; - } - - if (!ExcludedJsonContentTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) - { - // Since contentType has been validated using System.Net.Mime.ContentType, - // mediaType will always have exactly 2 parts after splitting on '/' - string[] types = mediaType.Split('/'); - if (string.Equals(types[0], acceptedMainType, StringComparison.OrdinalIgnoreCase)) - { - string[] subTypes = types[1].Split('+'); - if (subTypes.Contains(acceptedSubType, StringComparer.OrdinalIgnoreCase)) - { - return true; - } - } + return contentType.IsJson(); } return false; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index bd8b7582..21582db1 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using System.Net.Mime; using System.Text; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration @@ -68,13 +70,57 @@ internal class RequestTracingOptions /// public bool IsPushRefreshUsed { get; set; } = false; + /// + /// Flag to indicate whether any key-value uses the json content type and contains + /// a parameter indicating an AI profile. + /// + public bool UsesAIConfiguration { get; set; } = false; + + /// + /// Flag to indicate whether any key-value uses the json content type and contains + /// a parameter indicating an AI chat completion profile. + /// + public bool UsesAIChatCompletionConfiguration { get; set; } = false; + + /// + /// Resets the AI configuration tracing flags. + /// + public void ResetAiConfigurationTracing() + { + UsesAIConfiguration = false; + UsesAIChatCompletionConfiguration = false; + } + + /// + /// Updates AI configuration tracing flags based on the provided content type. + /// + /// The content type to analyze. + public void UpdateAiConfigurationTracing(string contentTypeString) + { + if (!UsesAIChatCompletionConfiguration && + !string.IsNullOrWhiteSpace(contentTypeString) && + contentTypeString.TryParseContentType(out ContentType contentType) && + contentType.IsAi()) + { + UsesAIConfiguration = true; + + if (contentType.IsAiChatCompletion()) + { + UsesAIChatCompletionConfiguration = true; + } + } + } + /// /// Checks whether any tracing feature is used. /// /// true if any tracing feature is used, otherwise false. public bool UsesAnyTracingFeature() { - return IsLoadBalancingEnabled || IsSignalRUsed; + return IsLoadBalancingEnabled || + IsSignalRUsed || + UsesAIConfiguration || + UsesAIChatCompletionConfiguration; } /// @@ -105,6 +151,26 @@ public string CreateFeaturesString() sb.Append(RequestTracingConstants.SignalRUsedTag); } + if (UsesAIConfiguration) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.AIConfigurationTag); + } + + if (UsesAIChatCompletionConfiguration) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.AIChatCompletionConfigurationTag); + } + return sb.ToString(); } } From fb7fb03e08f937c5c4fb062d314bd10b5b119111 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Thu, 17 Apr 2025 12:14:44 -0700 Subject: [PATCH 6/9] Fix test to use new refresh interval api (#650) * fix test to remove obsolete * change variable names --- tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 2f935381..6756b949 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -1201,12 +1201,11 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) } [Fact] - [Obsolete] public void AlternateValidFeatureFlagFormats() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); - var cacheExpiration = TimeSpan.FromSeconds(1); + var refreshInterval = TimeSpan.FromSeconds(1); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns((Func)GetTestKeys); @@ -1233,7 +1232,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration; + ff.SetRefreshInterval(refreshInterval); ff.Select(flagKey); }); }) From 4fd6ee7bc6971539d37965f0122609d2e3eb22f7 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:33:21 -0700 Subject: [PATCH 7/9] Separate content type exclusion logic from IsJson extension (#651) * fix isjson and separate exclusion logic * update adapters to use new extension * PR comments --- .../AzureKeyVaultKeyValueAdapter.cs | 14 ++++- .../Extensions/ContentTypeExtensions.cs | 56 ++++++++++--------- .../FeatureManagementKeyValueAdapter.cs | 17 +++++- .../JsonKeyValueAdapter.cs | 4 +- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs index a272b413..0498ba09 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs @@ -4,9 +4,12 @@ using Azure; using Azure.Data.AppConfiguration; using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using System; using System.Collections.Generic; using System.Linq; +using System.Net.Mime; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -72,8 +75,15 @@ KeyVaultReferenceException CreateKeyVaultReferenceException(string message, Conf public bool CanProcess(ConfigurationSetting setting) { - string contentType = setting?.ContentType?.Split(';')[0].Trim(); - return string.Equals(contentType, KeyVaultConstants.ContentType); + if (setting == null || + string.IsNullOrWhiteSpace(setting.Value) || + string.IsNullOrWhiteSpace(setting.ContentType)) + { + return false; + } + + return setting.ContentType.TryParseContentType(out ContentType contentType) + && contentType.IsKeyVaultReference(); } public void OnChangeDetected(ConfigurationSetting setting = null) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs index 2f739a52..ca2a9d53 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs @@ -12,16 +12,12 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ContentTypeExtensions { - private static readonly IEnumerable ExcludedJsonContentTypes = new[] - { - FeatureManagementConstants.ContentType, - KeyVaultConstants.ContentType - }; - public static bool IsAi(this ContentType contentType) { return contentType != null && contentType.IsJson() && + !contentType.IsFeatureFlag() && + !contentType.IsKeyVaultReference() && contentType.Parameters.ContainsKey("profile") && !string.IsNullOrEmpty(contentType.Parameters["profile"]) && contentType.Parameters["profile"].StartsWith(RequestTracingConstants.AIMimeProfile); @@ -31,6 +27,8 @@ public static bool IsAiChatCompletion(this ContentType contentType) { return contentType != null && contentType.IsJson() && + !contentType.IsFeatureFlag() && + !contentType.IsKeyVaultReference() && contentType.Parameters.ContainsKey("profile") && !string.IsNullOrEmpty(contentType.Parameters["profile"]) && contentType.Parameters["profile"].StartsWith(RequestTracingConstants.AIChatCompletionMimeProfile); @@ -45,37 +43,43 @@ public static bool IsJson(this ContentType contentType) string acceptedMainType = "application"; string acceptedSubType = "json"; - string mediaType = contentType.MediaType; - - if (!ExcludedJsonContentTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) - { - ReadOnlySpan mediaTypeSpan = mediaType.AsSpan(); - // Since contentType has been validated using System.Net.Mime.ContentType, - // mediaType will always have exactly 2 parts after splitting on '/' - int slashIndex = mediaTypeSpan.IndexOf('/'); + ReadOnlySpan mediaTypeSpan = contentType.MediaType.AsSpan(); - if (mediaTypeSpan.Slice(0, slashIndex).Equals(acceptedMainType.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - ReadOnlySpan subTypeSpan = mediaTypeSpan.Slice(slashIndex + 1); + // Since contentType has been validated using System.Net.Mime.ContentType, + // mediaType will always have exactly 2 parts after splitting on '/' + int slashIndex = mediaTypeSpan.IndexOf('/'); - while (!subTypeSpan.IsEmpty) - { - int plusIndex = subTypeSpan.IndexOf('+'); + if (mediaTypeSpan.Slice(0, slashIndex).Equals(acceptedMainType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan subTypeSpan = mediaTypeSpan.Slice(slashIndex + 1); - ReadOnlySpan currentSubType = plusIndex == -1 ? subTypeSpan : subTypeSpan.Slice(0, plusIndex); + while (!subTypeSpan.IsEmpty) + { + int plusIndex = subTypeSpan.IndexOf('+'); - if (currentSubType.Equals(acceptedSubType.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - return true; - } + ReadOnlySpan currentSubType = plusIndex == -1 ? subTypeSpan : subTypeSpan.Slice(0, plusIndex); - subTypeSpan = plusIndex == -1 ? ReadOnlySpan.Empty : subTypeSpan.Slice(plusIndex + 1); + if (currentSubType.Equals(acceptedSubType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return true; } + + subTypeSpan = plusIndex == -1 ? ReadOnlySpan.Empty : subTypeSpan.Slice(plusIndex + 1); } } return false; } + + public static bool IsFeatureFlag(this ContentType contentType) + { + return contentType.MediaType.Equals(FeatureManagementConstants.ContentType); + } + + public static bool IsKeyVaultReference(this ContentType contentType) + { + return contentType.MediaType.Equals(KeyVaultConstants.ContentType); + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index b6d137f3..76ab04e4 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Mime; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -45,10 +46,20 @@ public Task>> ProcessKeyValue(Configura public bool CanProcess(ConfigurationSetting setting) { - string contentType = setting?.ContentType?.Split(';')[0].Trim(); + if (setting == null || + string.IsNullOrWhiteSpace(setting.Value) || + string.IsNullOrWhiteSpace(setting.ContentType)) + { + return false; + } + + if (setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker)) + { + return true; + } - return string.Equals(contentType, FeatureManagementConstants.ContentType) || - setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker); + return setting.ContentType.TryParseContentType(out ContentType contentType) && + contentType.IsFeatureFlag(); } public bool NeedsRefresh() diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index 87c28d69..74d2b882 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -53,7 +53,9 @@ public bool CanProcess(ConfigurationSetting setting) if (setting.ContentType.TryParseContentType(out ContentType contentType)) { - return contentType.IsJson(); + return contentType.IsJson() && + !contentType.IsFeatureFlag() && + !contentType.IsKeyVaultReference(); } return false; From 5910dbf11d41445b75060b30d78adb1342b092f2 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Mon, 21 Apr 2025 09:26:11 -0700 Subject: [PATCH 8/9] update package versions to 8.1.2 (#648) --- .../Microsoft.Azure.AppConfiguration.AspNetCore.csproj | 2 +- .../Microsoft.Azure.AppConfiguration.Functions.Worker.csproj | 2 +- ...rosoft.Extensions.Configuration.AzureAppConfiguration.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj index 4cd6bf4e..b9f6bfc3 100644 --- a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj +++ b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj @@ -21,7 +21,7 @@ - 8.1.1 + 8.1.2 diff --git a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj index e327421b..6236ba4f 100644 --- a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj +++ b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj @@ -24,7 +24,7 @@ - 8.1.1 + 8.1.2 diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 91b90bb1..d5af2b14 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -35,7 +35,7 @@ - 8.1.1 + 8.1.2 From 65ed48033482e796143b41fa150d06293fbf8316 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Mon, 21 Apr 2025 13:12:54 -0700 Subject: [PATCH 9/9] Revert "Shorten the defeult timeout of individual call to backend (#620)" (#653) This reverts commit 87f0f85ca2e4011f82d93a864e35c6c804cd6c39. --- .../AzureAppConfigurationOptions.cs | 7 ------- .../AzureAppConfigurationProvider.cs | 7 ------- 2 files changed, 14 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 6be96b65..6e600fa2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. // using Azure.Core; -using Azure.Core.Pipeline; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; @@ -11,7 +10,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration @@ -24,7 +22,6 @@ public class AzureAppConfigurationOptions { private const int MaxRetries = 2; private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); - private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(10); private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; private List _individualKvWatchers = new List(); @@ -513,10 +510,6 @@ private static ConfigurationClientOptions GetDefaultClientOptions() clientOptions.Retry.MaxDelay = MaxRetryDelay; clientOptions.Retry.Mode = RetryMode.Exponential; clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); - clientOptions.Transport = new HttpClientTransport(new HttpClient() - { - Timeout = NetworkTimeout - }); return clientOptions; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index d86133ae..5e1bf8e0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -1221,13 +1221,6 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => private bool IsFailOverable(AggregateException ex) { - TaskCanceledException tce = ex.InnerExceptions?.LastOrDefault(e => e is TaskCanceledException) as TaskCanceledException; - - if (tce != null && tce.InnerException is TimeoutException) - { - return true; - } - RequestFailedException rfe = ex.InnerExceptions?.LastOrDefault(e => e is RequestFailedException) as RequestFailedException; return rfe != null ? IsFailOverable(rfe) : false;