Skip to content

Commit ff455b1

Browse files
support health check
1 parent 87f0f85 commit ff455b1

File tree

6 files changed

+188
-8
lines changed

6 files changed

+188
-8
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using Microsoft.Extensions.Diagnostics.HealthChecks;
5+
using System;
6+
using System.Threading.Tasks;
7+
using System.Threading;
8+
9+
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
10+
{
11+
/// <summary>
12+
/// Health check for Azure App Configuration.
13+
/// </summary>
14+
public sealed class AzureAppConfigurationHealthCheck : IHealthCheck
15+
{
16+
private AzureAppConfigurationProvider _provider = null;
17+
18+
internal void SetProvider(AzureAppConfigurationProvider provider)
19+
{
20+
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
21+
}
22+
23+
/// <summary>
24+
/// Checks the health of the Azure App Configuration provider.
25+
/// </summary>
26+
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
27+
{
28+
if (_provider == null)
29+
{
30+
return HealthCheckResult.Unhealthy("Configuration provider is not set.");
31+
}
32+
33+
if (!_provider.LastSuccessfulAttempt.HasValue)
34+
{
35+
return HealthCheckResult.Unhealthy("The initial load is not completed.");
36+
}
37+
38+
if (_provider.LastFailedAttempt.HasValue &&
39+
_provider.LastSuccessfulAttempt.Value < _provider.LastFailedAttempt.Value)
40+
{
41+
return HealthCheckResult.Unhealthy("The last refresh attempt failed.");
42+
}
43+
44+
return HealthCheckResult.Healthy();
45+
}
46+
}
47+
}

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT license.
33
//
44
using Azure.Core;
5-
using Azure.Core.Pipeline;
5+
using Azure.Core.Pipeline;
66
using Azure.Data.AppConfiguration;
77
using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault;
88
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions;
@@ -11,7 +11,7 @@
1111
using System;
1212
using System.Collections.Generic;
1313
using System.Linq;
14-
using System.Net.Http;
14+
using System.Net.Http;
1515
using System.Threading.Tasks;
1616

1717
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
@@ -25,7 +25,7 @@ public class AzureAppConfigurationOptions
2525
private const int MaxRetries = 2;
2626
private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1);
2727
private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(10);
28-
private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null };
28+
private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null };
2929

3030
private List<KeyValueWatcher> _individualKvWatchers = new List<KeyValueWatcher>();
3131
private List<KeyValueWatcher> _ffWatchers = new List<KeyValueWatcher>();
@@ -39,6 +39,11 @@ public class AzureAppConfigurationOptions
3939
// Since multiple prefixes could start with the same characters, we need to trim the longest prefix first.
4040
private SortedSet<string> _keyPrefixes = new SortedSet<string>(Comparer<string>.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase)));
4141

42+
/// <summary>
43+
/// Health check for Azure App Configuration.
44+
/// </summary>
45+
public AzureAppConfigurationHealthCheck HealthCheck { get; set; } = new AzureAppConfigurationHealthCheck();
46+
4247
/// <summary>
4348
/// Flag to indicate whether replica discovery is enabled.
4449
/// </summary>
@@ -514,9 +519,9 @@ private static ConfigurationClientOptions GetDefaultClientOptions()
514519
clientOptions.Retry.Mode = RetryMode.Exponential;
515520
clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall);
516521
clientOptions.Transport = new HttpClientTransport(new HttpClient()
517-
{
522+
{
518523
Timeout = NetworkTimeout
519-
});
524+
});
520525

521526
return clientOptions;
522527
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ private class ConfigurationClientBackoffStatus
5858
public DateTimeOffset BackoffEndTime { get; set; }
5959
}
6060

61+
public DateTimeOffset? LastSuccessfulAttempt { get; private set; } = null;
62+
public DateTimeOffset? LastFailedAttempt { get; private set; } = null;
63+
6164
public Uri AppConfigurationEndpoint
6265
{
6366
get
@@ -116,6 +119,11 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan
116119
bool hasWatchers = watchers.Any();
117120
TimeSpan minWatcherRefreshInterval = hasWatchers ? watchers.Min(w => w.RefreshInterval) : TimeSpan.MaxValue;
118121

122+
if (options.HealthCheck != null)
123+
{
124+
options.HealthCheck.SetProvider(this);
125+
}
126+
119127
if (options.RegisterAllEnabled)
120128
{
121129
if (options.KvCollectionRefreshInterval <= TimeSpan.Zero)
@@ -198,6 +206,7 @@ public override void Load()
198206

199207
// Mark all settings have loaded at startup.
200208
_isInitialLoadComplete = true;
209+
LastSuccessfulAttempt = DateTime.UtcNow;
201210
}
202211

203212
public async Task RefreshAsync(CancellationToken cancellationToken)
@@ -255,6 +264,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken)
255264

256265
_logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage());
257266

267+
LastFailedAttempt = DateTime.UtcNow;
268+
258269
return;
259270
}
260271

@@ -449,6 +460,8 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
449460
// As long as adapter.NeedsRefresh is true, we will attempt to update keyvault again the next time RefreshAsync is called.
450461
SetData(await PrepareData(_mappedData, cancellationToken).ConfigureAwait(false));
451462
}
463+
464+
LastSuccessfulAttempt = DateTime.UtcNow;
452465
}
453466
finally
454467
{
@@ -1143,6 +1156,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
11431156
if (!IsFailOverable(rfe) || !clientEnumerator.MoveNext())
11441157
{
11451158
backoffAllClients = true;
1159+
LastFailedAttempt = DateTime.UtcNow;
11461160

11471161
throw;
11481162
}
@@ -1152,6 +1166,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
11521166
if (!IsFailOverable(ae) || !clientEnumerator.MoveNext())
11531167
{
11541168
backoffAllClients = true;
1169+
LastFailedAttempt = DateTime.UtcNow;
11551170

11561171
throw;
11571172
}

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.7.0" />
2020
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.6.0" />
2121
<PackageReference Include="DnsClient" Version="1.7.0" />
22+
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.36" />
2223
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
2324
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
2425
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using Azure;
5+
using Azure.Data.AppConfiguration;
6+
using Microsoft.Extensions.Configuration;
7+
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
8+
using Microsoft.Extensions.Diagnostics.HealthChecks;
9+
using Moq;
10+
using System.Threading;
11+
using System.Collections.Generic;
12+
using System.Threading.Tasks;
13+
using Xunit;
14+
using System;
15+
using System.Linq;
16+
17+
namespace Tests.AzureAppConfiguration
18+
{
19+
public class HealthCheckTest
20+
{
21+
readonly List<ConfigurationSetting> kvCollection = new List<ConfigurationSetting>
22+
{
23+
ConfigurationModelFactory.ConfigurationSetting("TestKey1", "TestValue1", "label",
24+
eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"),
25+
contentType:"text"),
26+
ConfigurationModelFactory.ConfigurationSetting("TestKey2", "TestValue2", "label",
27+
eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"),
28+
contentType: "text"),
29+
ConfigurationModelFactory.ConfigurationSetting("TestKey3", "TestValue3", "label",
30+
31+
eTag: new ETag("bb203f2b-c113-44fc-995d-b933c2143339"),
32+
contentType: "text"),
33+
ConfigurationModelFactory.ConfigurationSetting("TestKey4", "TestValue4", "label",
34+
eTag: new ETag("3ca43b3e-d544-4b0c-b3a2-e7a7284217a2"),
35+
contentType: "text"),
36+
};
37+
38+
[Fact]
39+
public async Task HealthCheckTests_ReturnsUnhealthyWhenInitialLoadIsNotCompleted()
40+
{
41+
var healthCheck = new AzureAppConfigurationHealthCheck();
42+
HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
43+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
44+
45+
var mockResponse = new Mock<Response>();
46+
var mockClient = new Mock<ConfigurationClient>(MockBehavior.Strict);
47+
48+
mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
49+
.Returns(new MockAsyncPageable(kvCollection));
50+
51+
var config = new ConfigurationBuilder()
52+
.AddAzureAppConfiguration(options =>
53+
{
54+
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
55+
options.HealthCheck = healthCheck;
56+
})
57+
.Build();
58+
59+
Assert.True(config["TestKey1"] == "TestValue1");
60+
result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
61+
Assert.Equal(HealthStatus.Healthy, result.Status);
62+
}
63+
64+
[Fact]
65+
public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed()
66+
{
67+
IConfigurationRefresher refresher = null;
68+
var healthCheck = new AzureAppConfigurationHealthCheck();
69+
var mockResponse = new Mock<Response>();
70+
var mockClient = new Mock<ConfigurationClient>(MockBehavior.Strict);
71+
72+
mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
73+
.Returns(new MockAsyncPageable(kvCollection))
74+
.Throws(new RequestFailedException(503, "Request failed."))
75+
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().ToList()))
76+
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().ToList()));
77+
78+
var config = new ConfigurationBuilder()
79+
.AddAzureAppConfiguration(options =>
80+
{
81+
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
82+
options.MinBackoffDuration = TimeSpan.FromSeconds(2);
83+
options.HealthCheck = healthCheck;
84+
options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator();
85+
options.ConfigureRefresh(refreshOptions =>
86+
{
87+
refreshOptions.RegisterAll()
88+
.SetRefreshInterval(TimeSpan.FromSeconds(1));
89+
});
90+
refresher = options.GetRefresher();
91+
})
92+
.Build();
93+
94+
HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
95+
Assert.Equal(HealthStatus.Healthy, result.Status);
96+
97+
// Wait for the refresh interval to expire
98+
Thread.Sleep(1000);
99+
100+
await refresher.TryRefreshAsync();
101+
result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
102+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
103+
104+
// Wait for client backoff to end
105+
Thread.Sleep(3000);
106+
107+
await refresher.RefreshAsync();
108+
result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
109+
Assert.Equal(HealthStatus.Healthy, result.Status);
110+
}
111+
}
112+
}

tests/Tests.AzureAppConfiguration/RefreshTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ Response<ConfigurationSetting> GetIfChanged(ConfigurationSetting setting, bool o
320320
foreach (var setting in keyValueCollection)
321321
{
322322
copy.Add(TestHelpers.CloneSetting(setting));
323-
};
323+
}
324324

325325
return new MockAsyncPageable(copy);
326326
});
@@ -392,7 +392,7 @@ Response<ConfigurationSetting> GetIfChanged(ConfigurationSetting setting, bool o
392392
foreach (var setting in keyValueCollection)
393393
{
394394
copy.Add(TestHelpers.CloneSetting(setting));
395-
};
395+
}
396396

397397
return new MockAsyncPageable(copy);
398398
});
@@ -461,7 +461,7 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh()
461461
foreach (var setting in keyValueCollection)
462462
{
463463
copy.Add(TestHelpers.CloneSetting(setting));
464-
};
464+
}
465465

466466
return new MockAsyncPageable(copy, operationDelay);
467467
});

0 commit comments

Comments
 (0)