Skip to content

Commit 46c1901

Browse files
authored
DC-264 - Fix AppConfig startup race condition with Agent sidecar (#151)
* Fix AppConfig startup race condition with Agent sidecar * Add log message when initial AppConfig load starts
1 parent 6f04b62 commit 46c1901

File tree

2 files changed

+212
-1
lines changed

2 files changed

+212
-1
lines changed

src/SEBT.Portal.Infrastructure/Configuration/AppConfigAgentConfigurationProvider.cs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ namespace SEBT.Portal.Infrastructure.Configuration;
1111
public sealed class AppConfigAgentConfigurationProvider : ConfigurationProvider, IDisposable
1212
{
1313
private const int LockReleaseTimeout = 3_000;
14+
private const int InitialLoadMaxRetries = 10;
15+
private static readonly TimeSpan InitialLoadRetryDelay = TimeSpan.FromSeconds(1);
1416

1517
private readonly HttpClient _httpClient;
1618
private readonly AppConfigAgentProfile _profile;
@@ -21,6 +23,7 @@ public sealed class AppConfigAgentConfigurationProvider : ConfigurationProvider,
2123
private Timer? _reloadTimer;
2224
private int _isLoading; // 0 = not loading, 1 = loading
2325
private volatile bool _disposed;
26+
private bool _initialLoadCompleted;
2427

2528
public AppConfigAgentConfigurationProvider(
2629
HttpClient httpClient,
@@ -45,7 +48,19 @@ public override void Load()
4548

4649
try
4750
{
48-
LoadAsync().GetAwaiter().GetResult();
51+
if (!_initialLoadCompleted)
52+
{
53+
_logger?.LogInformation(
54+
"Initial load starting for profile {ProfileId} (will retry up to {MaxRetries} times if agent is not ready)",
55+
_profile.ProfileId,
56+
InitialLoadMaxRetries);
57+
LoadWithRetryAsync().GetAwaiter().GetResult();
58+
_initialLoadCompleted = true;
59+
}
60+
else
61+
{
62+
LoadAsync().GetAwaiter().GetResult();
63+
}
4964

5065
if (_profile.ReloadAfterSeconds.HasValue)
5166
{
@@ -60,6 +75,45 @@ public override void Load()
6075
}
6176
}
6277

78+
/// <summary>
79+
/// Attempts to load configuration with retries. Used only for the initial load
80+
/// to handle the race condition where the AppConfig Agent sidecar may not be
81+
/// ready when the API container starts.
82+
/// </summary>
83+
private async Task LoadWithRetryAsync()
84+
{
85+
for (var attempt = 1; attempt <= InitialLoadMaxRetries; attempt++)
86+
{
87+
try
88+
{
89+
await LoadAsync().ConfigureAwait(false);
90+
91+
// If Data has items, the load succeeded.
92+
if (Data.Count > 0)
93+
return;
94+
}
95+
catch
96+
{
97+
// LoadAsync handles its own exceptions — this is a safety net.
98+
}
99+
100+
if (attempt < InitialLoadMaxRetries)
101+
{
102+
_logger?.LogInformation(
103+
"AppConfig Agent not ready (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}s...",
104+
attempt,
105+
InitialLoadMaxRetries,
106+
InitialLoadRetryDelay.TotalSeconds);
107+
await Task.Delay(InitialLoadRetryDelay).ConfigureAwait(false);
108+
}
109+
}
110+
111+
_logger?.LogError(
112+
"AppConfig Agent not available after {MaxRetries} attempts for profile {ProfileId}. Starting without AppConfig values.",
113+
InitialLoadMaxRetries,
114+
_profile.ProfileId);
115+
}
116+
63117
private void OnReloadTimerFired()
64118
{
65119
if (!_disposed)

test/SEBT.Portal.Tests/Unit/Configuration/AppConfigAgentConfigurationProviderTests.cs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,163 @@ public void AppConfigAgentConfigurationSource_Build_WithoutHttpClient_ShouldCrea
927927
Assert.Null(exception);
928928
}
929929

930+
[Fact]
931+
public void Load_InitialLoad_ShouldRetryOnConnectionRefused_ThenSucceed()
932+
{
933+
// Arrange — simulate AppConfig Agent sidecar not ready for the first 3 attempts
934+
var profile = new AppConfigAgentProfile
935+
{
936+
BaseUrl = "http://localhost:2772",
937+
ApplicationId = "test-app",
938+
EnvironmentId = "test-env",
939+
ProfileId = "test-profile",
940+
IsFeatureFlag = false,
941+
ReloadAfterSeconds = null // Disable reload timer for test isolation
942+
};
943+
944+
var configJson = """{"Cbms": {"UseMockResponses": true}}""";
945+
var handler = new FailThenSucceedHandler(
946+
failCount: 3,
947+
successResponse: new HttpResponseMessage(HttpStatusCode.OK)
948+
{
949+
Content = new StringContent(configJson, Encoding.UTF8, "application/json")
950+
});
951+
952+
using var httpClient = new HttpClient(handler);
953+
var provider = new AppConfigAgentConfigurationProvider(httpClient, profile, _logger, ownsHttpClient: false);
954+
955+
// Act
956+
provider.Load();
957+
958+
// Assert — config should be loaded after retries
959+
Assert.True(provider.TryGet("Cbms:UseMockResponses", out var value));
960+
Assert.Equal("true", value);
961+
Assert.Equal(4, handler.CallCount); // 3 failures + 1 success
962+
963+
provider.Dispose();
964+
}
965+
966+
[Fact]
967+
public void Load_InitialLoad_ShouldGiveUpAfterMaxRetries()
968+
{
969+
// Arrange — agent never becomes available
970+
var profile = new AppConfigAgentProfile
971+
{
972+
BaseUrl = "http://localhost:2772",
973+
ApplicationId = "test-app",
974+
EnvironmentId = "test-env",
975+
ProfileId = "test-profile",
976+
IsFeatureFlag = false,
977+
ReloadAfterSeconds = null
978+
};
979+
980+
var handler = new FailThenSucceedHandler(
981+
failCount: 100, // More than max retries
982+
successResponse: new HttpResponseMessage(HttpStatusCode.OK));
983+
984+
using var httpClient = new HttpClient(handler);
985+
var provider = new AppConfigAgentConfigurationProvider(httpClient, profile, _logger, ownsHttpClient: false);
986+
987+
// Act — should not throw
988+
var exception = Record.Exception(() => provider.Load());
989+
990+
// Assert
991+
Assert.Null(exception);
992+
Assert.False(provider.TryGet("any-key", out _)); // No config loaded
993+
Assert.Equal(10, handler.CallCount); // Should have tried exactly 10 times (max retries)
994+
995+
provider.Dispose();
996+
}
997+
998+
[Fact]
999+
public void Load_SubsequentReloads_ShouldNotRetryOnFailure()
1000+
{
1001+
// Arrange — first load succeeds, second load (simulating a reload) should not retry
1002+
var profile = new AppConfigAgentProfile
1003+
{
1004+
BaseUrl = "http://localhost:2772",
1005+
ApplicationId = "test-app",
1006+
EnvironmentId = "test-env",
1007+
ProfileId = "test-profile",
1008+
IsFeatureFlag = false,
1009+
ReloadAfterSeconds = null
1010+
};
1011+
1012+
var configJson = """{"Key1": "value1"}""";
1013+
// First call succeeds, then all subsequent calls fail
1014+
var handler = new FailThenSucceedHandler(
1015+
failCount: 0,
1016+
successResponse: new HttpResponseMessage(HttpStatusCode.OK)
1017+
{
1018+
Content = new StringContent(configJson, Encoding.UTF8, "application/json")
1019+
},
1020+
failAfterSuccess: true);
1021+
1022+
using var httpClient = new HttpClient(handler);
1023+
var provider = new AppConfigAgentConfigurationProvider(httpClient, profile, _logger, ownsHttpClient: false);
1024+
1025+
// Act — initial load succeeds
1026+
provider.Load();
1027+
Assert.True(provider.TryGet("Key1", out var value));
1028+
Assert.Equal("value1", value);
1029+
Assert.Equal(1, handler.CallCount);
1030+
1031+
// Act — subsequent load (reload) fails, should only try once (no retry)
1032+
provider.Load();
1033+
1034+
// Assert — only 2 total calls (1 initial + 1 reload), no retries on reload
1035+
Assert.Equal(2, handler.CallCount);
1036+
1037+
provider.Dispose();
1038+
}
1039+
1040+
/// <summary>
1041+
/// Test handler that throws HttpRequestException for the first N requests,
1042+
/// then returns a success response. Simulates the AppConfig Agent sidecar
1043+
/// startup race condition.
1044+
/// </summary>
1045+
private sealed class FailThenSucceedHandler : HttpMessageHandler
1046+
{
1047+
private readonly int _failCount;
1048+
private readonly HttpResponseMessage _successResponse;
1049+
private readonly bool _failAfterSuccess;
1050+
private int _callCount;
1051+
1052+
public int CallCount => _callCount;
1053+
1054+
public FailThenSucceedHandler(
1055+
int failCount,
1056+
HttpResponseMessage successResponse,
1057+
bool failAfterSuccess = false)
1058+
{
1059+
_failCount = failCount;
1060+
_successResponse = successResponse;
1061+
_failAfterSuccess = failAfterSuccess;
1062+
}
1063+
1064+
protected override Task<HttpResponseMessage> SendAsync(
1065+
HttpRequestMessage request, CancellationToken cancellationToken)
1066+
{
1067+
var currentCall = Interlocked.Increment(ref _callCount);
1068+
1069+
if (currentCall <= _failCount)
1070+
{
1071+
throw new HttpRequestException(
1072+
"Connection refused (localhost:2772)",
1073+
new System.Net.Sockets.SocketException(111));
1074+
}
1075+
1076+
if (_failAfterSuccess && currentCall > _failCount + 1)
1077+
{
1078+
throw new HttpRequestException(
1079+
"Connection refused (localhost:2772)",
1080+
new System.Net.Sockets.SocketException(111));
1081+
}
1082+
1083+
return Task.FromResult(_successResponse);
1084+
}
1085+
}
1086+
9301087
public void Dispose()
9311088
{
9321089
_httpClient?.Dispose();

0 commit comments

Comments
 (0)