Skip to content

Commit 256d2df

Browse files
authored
fix: add feature flag retry circuit breaker (#247)
* fix: retry feature flag requests on network errors * fix: align flags retry policy with transient errors * fix: make flags retry count configurable * address pr review feedback * address pr review feedback * fix: add feature flag retry circuit breaker * fix: address circuit breaker review feedback * fix: honor initial retry delay for flags * address pr review feedback * restore feature flag retry changeset * update feature flag circuit breaker changeset * add aspnet package to changeset * address circuit breaker review feedback
1 parent 8fad3ff commit 256d2df

4 files changed

Lines changed: 561 additions & 18 deletions

File tree

.changeset/quiet-flags-retry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'PostHog': patch
3+
'PostHog.AspNetCore': patch
4+
---
5+
6+
Add a per-client circuit breaker for feature flag requests after consecutive transient network failures, temporarily failing fast before probing for recovery.

src/PostHog/Api/PostHogApiClient.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ internal sealed class PostHogApiClient : IDisposable
2323
readonly HttpClient _httpClient;
2424
readonly IOptions<PostHogOptions> _options;
2525
readonly ILogger<PostHogApiClient> _logger;
26+
readonly FeatureFlagRequestCircuitBreaker _featureFlagRequestCircuitBreaker = new();
2627

2728
/// <summary>
2829
/// Initialize a new PostHog client
@@ -159,6 +160,7 @@ public async Task<ApiResult> SendEventAsync(
159160
payload,
160161
_timeProvider,
161162
_options.Value,
163+
_featureFlagRequestCircuitBreaker,
162164
cancellationToken);
163165
}
164166

src/PostHog/Library/HttpClientExtensions.cs

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)