@@ -55,13 +55,37 @@ internal static class HttpClientExtensions
5555 object content ,
5656 TimeProvider timeProvider ,
5757 PostHogOptions options ,
58+ FeatureFlagRequestCircuitBreaker circuitBreaker ,
5859 CancellationToken cancellationToken )
5960 {
6061 var maxRetries = options . FeatureFlagRequestMaxRetries ;
6162 var currentDelay = options . InitialRetryDelay ;
6263 var maxDelay = options . MaxRetryDelay ;
6364 var attempt = 0 ;
6465
66+ if ( ! circuitBreaker . TryEnter ( timeProvider , out var isHalfOpenProbe ) )
67+ {
68+ throw new HttpRequestException ( "Feature flag request circuit breaker is open." ) ;
69+ }
70+
71+ async Task DelayBeforeRetry ( )
72+ {
73+ await Delay ( timeProvider , currentDelay > maxDelay ? maxDelay : currentDelay , cancellationToken ) ;
74+ currentDelay = DoubleWithCap ( currentDelay , maxDelay ) ;
75+ }
76+
77+ async Task < bool > ShouldRetryAfterTransientFailure ( )
78+ {
79+ var circuitClosed = circuitBreaker . RecordTransientFailure ( timeProvider , isHalfOpenProbe ) ;
80+ if ( attempt > maxRetries || ! circuitClosed )
81+ {
82+ return false ;
83+ }
84+
85+ await DelayBeforeRetry ( ) ;
86+ return true ;
87+ }
88+
6589 while ( true )
6690 {
6791 attempt ++ ;
@@ -75,24 +99,53 @@ internal static class HttpClientExtensions
7599 JsonSerializerHelper . Options ,
76100 cancellationToken ) ;
77101 }
78- catch ( HttpRequestException e ) when ( attempt <= maxRetries && IsRetryableFlagsHttpRequestException ( e ) )
102+ catch ( HttpRequestException e ) when ( IsRetryableFlagsHttpRequestException ( e ) )
79103 {
80- await Delay ( timeProvider , currentDelay > maxDelay ? maxDelay : currentDelay , cancellationToken ) ;
81- currentDelay = DoubleWithCap ( currentDelay , maxDelay ) ;
104+ if ( ! await ShouldRetryAfterTransientFailure ( ) )
105+ {
106+ throw ;
107+ }
108+
82109 continue ;
83110 }
84- catch ( TaskCanceledException ) when ( ! cancellationToken . IsCancellationRequested && attempt <= maxRetries )
111+ catch ( HttpRequestException )
85112 {
86- await Delay ( timeProvider , currentDelay > maxDelay ? maxDelay : currentDelay , cancellationToken ) ;
87- currentDelay = DoubleWithCap ( currentDelay , maxDelay ) ;
113+ if ( isHalfOpenProbe )
114+ {
115+ circuitBreaker . RecordTransientFailure ( timeProvider , isHalfOpenProbe : true ) ;
116+ }
117+ throw ;
118+ }
119+ catch ( TaskCanceledException ) when ( ! cancellationToken . IsCancellationRequested )
120+ {
121+ if ( ! await ShouldRetryAfterTransientFailure ( ) )
122+ {
123+ throw ;
124+ }
125+
88126 continue ;
89127 }
128+ catch ( OperationCanceledException ) when ( isHalfOpenProbe && cancellationToken . IsCancellationRequested )
129+ {
130+ circuitBreaker . RecordTransientFailure ( timeProvider , isHalfOpenProbe : true ) ;
131+ throw ;
132+ }
133+ catch ( Exception ) when ( isHalfOpenProbe )
134+ {
135+ circuitBreaker . RecordTransientFailure ( timeProvider , isHalfOpenProbe : true ) ;
136+ throw ;
137+ }
90138
91139 // Response processing is outside the try-catch so that exceptions from
92140 // EnsureSuccessfulApiCall (which may return HttpRequestException for 404s) won't
93141 // be caught by the retry logic above.
94142 using ( response )
95143 {
144+ if ( isHalfOpenProbe || response . IsSuccessStatusCode )
145+ {
146+ circuitBreaker . Close ( ) ;
147+ }
148+
96149 await response . EnsureSuccessfulApiCall ( cancellationToken ) ;
97150
98151 var result = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
@@ -418,4 +471,75 @@ public static async Task EnsureSuccessfulApiCall(
418471
419472 throw await CreateApiException ( response , cancellationToken ) ;
420473 }
421- }
474+ }
475+
476+ sealed class FeatureFlagRequestCircuitBreaker
477+ {
478+ const int FailureThreshold = 5 ;
479+ static readonly TimeSpan OpenDuration = TimeSpan . FromSeconds ( 30 ) ;
480+
481+ readonly object _lock = new ( ) ;
482+ State _state ;
483+ int _consecutiveFailures ;
484+ DateTimeOffset _openUntil ;
485+
486+ public bool TryEnter ( TimeProvider timeProvider , out bool isHalfOpenProbe )
487+ {
488+ lock ( _lock )
489+ {
490+ isHalfOpenProbe = false ;
491+
492+ if ( _state == State . Open && timeProvider . GetUtcNow ( ) < _openUntil )
493+ {
494+ return false ;
495+ }
496+
497+ if ( _state == State . Open )
498+ {
499+ _state = State . HalfOpen ;
500+ isHalfOpenProbe = true ;
501+ return true ;
502+ }
503+
504+ return _state != State . HalfOpen ;
505+ }
506+ }
507+
508+ public bool RecordTransientFailure ( TimeProvider timeProvider , bool isHalfOpenProbe )
509+ {
510+ lock ( _lock )
511+ {
512+ if ( isHalfOpenProbe || ++ _consecutiveFailures >= FailureThreshold )
513+ {
514+ Open ( timeProvider ) ;
515+ return false ;
516+ }
517+
518+ return true ;
519+ }
520+ }
521+
522+ public void Close ( )
523+ {
524+ lock ( _lock )
525+ {
526+ _state = State . Closed ;
527+ _consecutiveFailures = 0 ;
528+ _openUntil = default ;
529+ }
530+ }
531+
532+ void Open ( TimeProvider timeProvider )
533+ {
534+ _state = State . Open ;
535+ _consecutiveFailures = 0 ;
536+ _openUntil = timeProvider . GetUtcNow ( ) + OpenDuration ;
537+ }
538+
539+ enum State
540+ {
541+ Closed ,
542+ Open ,
543+ HalfOpen
544+ }
545+ }
0 commit comments