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 2477d611..cc55dcf3 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.3.0 + 8.4.0 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 1990839d..2a359874 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.3.0 + 8.4.0 diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index cba9dc1b..27cd262e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -1214,7 +1214,7 @@ private async Task ExecuteWithFailOverPolicyAsync( do { - UpdateClientBackoffStatus(previousEndpoint, success); + UpdateClientBackoffStatus(_configClientManager.GetEndpointForClient(currentClient), success); clientEnumerator.MoveNext(); @@ -1331,6 +1331,8 @@ private void EnsureAssemblyInspected() _requestTracingOptions.FeatureManagementAspNetCoreVersion = TracingUtils.GetAssemblyVersion(RequestTracingConstants.FeatureManagementAspNetCoreAssemblyName); + _requestTracingOptions.AspireComponentVersion = TracingUtils.GetAssemblyVersion(RequestTracingConstants.AspireComponentAssemblyName); + if (TracingUtils.GetAssemblyVersion(RequestTracingConstants.SignalRAssemblyName) != null) { _requestTracingOptions.IsSignalRUsed = true; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index 979ea9ad..340fba85 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -24,6 +24,7 @@ internal class RequestTracingConstants public const string EnvironmentKey = "Env"; public const string FeatureManagementVersionKey = "FMVer"; public const string FeatureManagementAspNetCoreVersionKey = "FMANCVer"; + public const string AspireComponentVersionKey = "DNACVer"; public const string DevEnvironmentValue = "Dev"; public const string KeyVaultConfiguredTag = "UsesKeyVault"; public const string KeyVaultRefreshConfiguredTag = "RefreshesKeyVault"; @@ -53,6 +54,7 @@ internal class RequestTracingConstants public const string FeatureManagementAssemblyName = "Microsoft.FeatureManagement"; public const string FeatureManagementAspNetCoreAssemblyName = "Microsoft.FeatureManagement.AspNetCore"; + public const string AspireComponentAssemblyName = "Aspire.Microsoft.Extensions.Configuration.AzureAppConfiguration"; public const string SignalRAssemblyName = "Microsoft.AspNetCore.SignalR"; public const string Delimiter = "+"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index 74d2b882..d353439f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -15,6 +15,11 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal class JsonKeyValueAdapter : IKeyValueAdapter { + private static readonly JsonDocumentOptions JsonParseOptions = new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip + }; + public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { if (setting == null) @@ -28,7 +33,7 @@ public Task>> ProcessKeyValue(Configura try { - using (JsonDocument document = JsonDocument.Parse(rootJson)) + using (JsonDocument document = JsonDocument.Parse(rootJson, JsonParseOptions)) { keyValuePairs = new JsonFlattener().FlattenJson(document.RootElement); } 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 4a1ce37a..022a2084 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -38,7 +38,7 @@ - 8.3.0 + 8.4.0 diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index 21582db1..8a3a7b20 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -50,6 +50,11 @@ internal class RequestTracingOptions /// public string FeatureManagementAspNetCoreVersion { get; set; } + /// + /// Version of the Aspire.Microsoft.Extensions.Configuration.AzureAppConfiguration assembly, if present in the application. + /// + public string AspireComponentVersion { get; set; } + /// /// Flag to indicate whether Microsoft.AspNetCore.SignalR assembly is present in the application. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs index b3e12913..33050b5b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs @@ -181,6 +181,11 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureManagementAspNetCoreVersionKey, requestTracingOptions.FeatureManagementAspNetCoreVersion)); } + if (requestTracingOptions.AspireComponentVersion != null) + { + correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.AspireComponentVersionKey, requestTracingOptions.AspireComponentVersion)); + } + if (requestTracingOptions.UsesAnyTracingFeature()) { correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeaturesKey, requestTracingOptions.CreateFeaturesString())); diff --git a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs index 810f9400..57735f37 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs @@ -415,5 +415,89 @@ ae.InnerException is AggregateException ae2 && ae2.InnerExceptions.All(ex => ex is TaskCanceledException) && ae2.InnerException is TaskCanceledException tce); } + + [Fact] + public async Task FailOverTests_AllClientsBackedOffAfterNonFailoverableException() + { + IConfigurationRefresher refresher = null; + var mockResponse = new Mock(); + + // Setup first client - succeeds on startup, fails with 404 (non-failoverable) on first refresh + var mockClient1 = new Mock(); + mockClient1.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) + .Throws(new RequestFailedException(412, "Request failed.")) + .Throws(new RequestFailedException(412, "Request failed.")); + mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) + .Throws(new RequestFailedException(412, "Request failed.")) + .Throws(new RequestFailedException(412, "Request failed.")); + mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new RequestFailedException(412, "Request failed.")) + .Throws(new RequestFailedException(412, "Request failed.")); + mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true); + + // Setup second client - succeeds on startup, should not be called during refresh + var mockClient2 = new Mock(); + mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); + + ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); + ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + + var clientList = new List() { cw1, cw2 }; + var configClientManager = new ConfigurationClientManager(clientList); + + // Verify 2 clients are available + Assert.Equal(2, configClientManager.GetClients().Count()); + + // Act & Assert - Build configuration successfully with both clients + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = configClientManager; + options.Select("TestKey*"); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + options.ReplicaDiscoveryEnabled = false; + refresher = options.GetRefresher(); + }).Build(); + + // First refresh - should call client 1 and fail with non-failoverable exception + // This should cause all clients to be backed off + await Task.Delay(1500); + await refresher.TryRefreshAsync(); + + // Verify that client 1 was called during the first refresh + mockClient1.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + + // Verify that client 2 was not called during the first refresh + mockClient2.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Never); + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + + // Second refresh - no clients should be called as all are backed off + await Task.Delay(1500); + await refresher.TryRefreshAsync(); + + // Verify that no additional calls were made to any client during the second refresh + mockClient1.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + mockClient2.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Never); + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } } } diff --git a/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs b/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs index 91cb03a8..eaca360f 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs @@ -276,6 +276,90 @@ public void JsonContentTypeTests_JsonKeyValueAdapterCannotProcessKeyVaultReferen Assert.False(jsonKeyValueAdapter.CanProcess(setting)); } + [Fact] + public void JsonContentTypeTests_LoadJsonValuesWithComments() + { + List _kvCollection = new List + { + // Test various comment styles and positions + ConfigurationModelFactory.ConfigurationSetting( + key: "MixedCommentStyles", + value: @"{ + // Single line comment at start + ""ApiSettings"": { + ""BaseUrl"": ""https://api.example.com"", // Inline single line + /* Multi-line comment + spanning multiple lines */ + ""ApiKey"": ""secret-key"", + ""Endpoints"": [ + // Comment before array element + ""/users"", + /* Comment between elements */ + ""/orders"", + ""/products"" // Comment after element + ] + }, + // Test edge cases + ""StringWithSlashes"": ""This is not a // comment"", + ""StringWithStars"": ""This is not a /* comment */"", + ""UrlValue"": ""https://example.com/path"", // This is a real comment + ""EmptyComment"": ""value"", // + /**/ + ""AfterEmptyComment"": ""value2"" + /* Final multi-line comment */ + }", + contentType: "application/json"), + // Test invalid JSON with comments + ConfigurationModelFactory.ConfigurationSetting( + key: "InvalidJsonWithComments", + value: @"// This is a comment + { invalid json structure + // Another comment + missing quotes and braces", + contentType: "application/json"), + // Test only comments (should be invalid JSON) + ConfigurationModelFactory.ConfigurationSetting( + key: "OnlyComments", + value: @" + // Just comments + /* No actual content */ + ", + contentType: "application/json") + }; + + var mockClientManager = GetMockConfigurationClientManager(_kvCollection); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => options.ClientManager = mockClientManager) + .Build(); + + // Verify mixed comment styles are properly parsed + Assert.Equal("https://api.example.com", config["MixedCommentStyles:ApiSettings:BaseUrl"]); + Assert.Equal("secret-key", config["MixedCommentStyles:ApiSettings:ApiKey"]); + Assert.Equal("/users", config["MixedCommentStyles:ApiSettings:Endpoints:0"]); + Assert.Equal("/orders", config["MixedCommentStyles:ApiSettings:Endpoints:1"]); + Assert.Equal("/products", config["MixedCommentStyles:ApiSettings:Endpoints:2"]); + + // Verify edge cases where comment-like text appears in strings + Assert.Equal("This is not a // comment", config["MixedCommentStyles:StringWithSlashes"]); + Assert.Equal("This is not a /* comment */", config["MixedCommentStyles:StringWithStars"]); + Assert.Equal("https://example.com/path", config["MixedCommentStyles:UrlValue"]); + Assert.Equal("value", config["MixedCommentStyles:EmptyComment"]); + Assert.Equal("value2", config["MixedCommentStyles:AfterEmptyComment"]); + + // Invalid JSON should fall back to string value + Assert.Equal(@"// This is a comment + { invalid json structure + // Another comment + missing quotes and braces", config["InvalidJsonWithComments"]); + + // Only comments should be treated as string value (invalid JSON) + Assert.Equal(@" + // Just comments + /* No actual content */ + ", config["OnlyComments"]); + } + private IConfigurationClientManager GetMockConfigurationClientManager(List _kvCollection) { var mockResponse = new Mock(); diff --git a/tests/Tests.AzureAppConfiguration/Unit/Tests.cs b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs index 7e68f1cf..37fb970f 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/Tests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs @@ -352,10 +352,12 @@ public void TestKeepSelectorPrecedenceAfterDedup() [Fact] public void TestActivitySource() { + string activitySourceName = Guid.NewGuid().ToString(); + var _activities = new List(); var _activityListener = new ActivityListener { - ShouldListenTo = source => source.Name == "Microsoft.Extensions.Configuration.AzureAppConfiguration", + ShouldListenTo = source => source.Name == activitySourceName, Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, ActivityStarted = activity => _activities.Add(activity), }; @@ -371,7 +373,11 @@ public void TestActivitySource() .ReturnsAsync(Response.FromValue(_kv, mockResponse.Object)); var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object)) + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ActivitySourceName = activitySourceName; + }) .Build(); Assert.Contains(_activities, a => a.OperationName == "Load");