@@ -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