diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md new file mode 100644 index 00000000..ab661016 --- /dev/null +++ b/.changeset/quiet-flags-retry.md @@ -0,0 +1,6 @@ +--- +'PostHog': patch +'PostHog.AspNetCore': patch +--- + +Add a per-client circuit breaker for feature flag requests after consecutive transient network failures, temporarily failing fast before probing for recovery. diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index f3c0d35b..18683d59 100644 --- a/src/PostHog/Api/PostHogApiClient.cs +++ b/src/PostHog/Api/PostHogApiClient.cs @@ -23,6 +23,7 @@ internal sealed class PostHogApiClient : IDisposable readonly HttpClient _httpClient; readonly IOptions _options; readonly ILogger _logger; + readonly FeatureFlagRequestCircuitBreaker _featureFlagRequestCircuitBreaker = new(); /// /// Initialize a new PostHog client @@ -159,6 +160,7 @@ public async Task SendEventAsync( payload, _timeProvider, _options.Value, + _featureFlagRequestCircuitBreaker, cancellationToken); } diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index cbb87445..b6144f77 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -55,6 +55,7 @@ internal static class HttpClientExtensions object content, TimeProvider timeProvider, PostHogOptions options, + FeatureFlagRequestCircuitBreaker circuitBreaker, CancellationToken cancellationToken) { var maxRetries = options.FeatureFlagRequestMaxRetries; @@ -62,6 +63,29 @@ internal static class HttpClientExtensions var maxDelay = options.MaxRetryDelay; var attempt = 0; + if (!circuitBreaker.TryEnter(timeProvider, out var isHalfOpenProbe)) + { + throw new HttpRequestException("Feature flag request circuit breaker is open."); + } + + async Task DelayBeforeRetry() + { + await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); + currentDelay = DoubleWithCap(currentDelay, maxDelay); + } + + async Task ShouldRetryAfterTransientFailure() + { + var circuitClosed = circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe); + if (attempt > maxRetries || !circuitClosed) + { + return false; + } + + await DelayBeforeRetry(); + return true; + } + while (true) { attempt++; @@ -75,24 +99,53 @@ internal static class HttpClientExtensions JsonSerializerHelper.Options, cancellationToken); } - catch (HttpRequestException e) when (attempt <= maxRetries && IsRetryableFlagsHttpRequestException(e)) + catch (HttpRequestException e) when (IsRetryableFlagsHttpRequestException(e)) { - await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); - currentDelay = DoubleWithCap(currentDelay, maxDelay); + if (!await ShouldRetryAfterTransientFailure()) + { + throw; + } + continue; } - catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested && attempt <= maxRetries) + catch (HttpRequestException) { - await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); - currentDelay = DoubleWithCap(currentDelay, maxDelay); + if (isHalfOpenProbe) + { + circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe: true); + } + throw; + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + if (!await ShouldRetryAfterTransientFailure()) + { + throw; + } + continue; } + catch (OperationCanceledException) when (isHalfOpenProbe && cancellationToken.IsCancellationRequested) + { + circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe: true); + throw; + } + catch (Exception) when (isHalfOpenProbe) + { + circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe: true); + throw; + } // Response processing is outside the try-catch so that exceptions from // EnsureSuccessfulApiCall (which may return HttpRequestException for 404s) won't // be caught by the retry logic above. using (response) { + if (isHalfOpenProbe || response.IsSuccessStatusCode) + { + circuitBreaker.Close(); + } + await response.EnsureSuccessfulApiCall(cancellationToken); var result = await response.Content.ReadAsStreamAsync(cancellationToken); @@ -418,4 +471,75 @@ public static async Task EnsureSuccessfulApiCall( throw await CreateApiException(response, cancellationToken); } -} \ No newline at end of file +} + +sealed class FeatureFlagRequestCircuitBreaker +{ + const int FailureThreshold = 5; + static readonly TimeSpan OpenDuration = TimeSpan.FromSeconds(30); + + readonly object _lock = new(); + State _state; + int _consecutiveFailures; + DateTimeOffset _openUntil; + + public bool TryEnter(TimeProvider timeProvider, out bool isHalfOpenProbe) + { + lock (_lock) + { + isHalfOpenProbe = false; + + if (_state == State.Open && timeProvider.GetUtcNow() < _openUntil) + { + return false; + } + + if (_state == State.Open) + { + _state = State.HalfOpen; + isHalfOpenProbe = true; + return true; + } + + return _state != State.HalfOpen; + } + } + + public bool RecordTransientFailure(TimeProvider timeProvider, bool isHalfOpenProbe) + { + lock (_lock) + { + if (isHalfOpenProbe || ++_consecutiveFailures >= FailureThreshold) + { + Open(timeProvider); + return false; + } + + return true; + } + } + + public void Close() + { + lock (_lock) + { + _state = State.Closed; + _consecutiveFailures = 0; + _openUntil = default; + } + } + + void Open(TimeProvider timeProvider) + { + _state = State.Open; + _consecutiveFailures = 0; + _openUntil = timeProvider.GetUtcNow() + OpenDuration; + } + + enum State + { + Closed, + Open, + HalfOpen + } +} diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 8d96dd9d..3b59c191 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -554,14 +554,17 @@ public class ThePostJsonWithNetworkRetryAsyncMethod { static readonly Uri FlagsUrl = new("https://us.i.posthog.com/flags/?v=2"); - static PostHogOptions CreateOptions(int maxRetries = 3) => new() - { - ProjectToken = "test-api-key", - MaxRetries = maxRetries, - FeatureFlagRequestMaxRetries = maxRetries, - InitialRetryDelay = TimeSpan.FromMilliseconds(1), - MaxRetryDelay = TimeSpan.FromSeconds(30) - }; + static PostHogOptions CreateOptions( + int maxRetries = 3, + TimeSpan? initialRetryDelay = null, + TimeSpan? maxRetryDelay = null) => new() + { + ProjectToken = "test-api-key", + MaxRetries = maxRetries, + FeatureFlagRequestMaxRetries = maxRetries, + InitialRetryDelay = initialRetryDelay ?? TimeSpan.FromMilliseconds(1), + MaxRetryDelay = maxRetryDelay ?? TimeSpan.FromSeconds(30) + }; static HttpClient CreateHttpClient(FakeRetryHttpMessageHandler handler) => new(handler) { BaseAddress = new Uri("https://us.i.posthog.com") }; @@ -581,16 +584,120 @@ public async Task RetriesOnConnectionResetThenSucceeds() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); await handler.WaitForRequestCountAsync(1); - timeProvider.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(1, handler.RequestCount); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); var result = await task; Assert.NotNull(result); Assert.Equal(2, handler.RequestCount); } + [Fact] + public async Task SuccessfulResponseResetsConsecutiveFailures() + { + var handler = new FakeRetryHttpMessageHandler(); + for (var i = 0; i < 4; i++) + { + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + } + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + for (var i = 0; i < 4; i++) + { + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + } + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 0); + var timeProvider = new FakeTimeProvider(); + var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); + + for (var i = 1; i <= 4; i++) + { + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(i, handler.RequestCount); + } + + var firstSuccess = await httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None); + + Assert.NotNull(firstSuccess); + Assert.Equal(5, handler.RequestCount); + + for (var i = 6; i <= 9; i++) + { + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(i, handler.RequestCount); + } + + var secondSuccess = await httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None); + + Assert.NotNull(secondSuccess); + Assert.Equal(10, handler.RequestCount); + } + + [Fact] + public async Task UsesInitialRetryDelayAndDoublesForFeatureFlagRetries() + { + var handler = new FakeRetryHttpMessageHandler(); + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 2, initialRetryDelay: TimeSpan.FromMilliseconds(10)); + var timeProvider = new FakeTimeProvider(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + new FeatureFlagRequestCircuitBreaker(), + CancellationToken.None); + + await handler.WaitForRequestCountAsync(1); + timeProvider.Advance(TimeSpan.FromMilliseconds(9)); + Assert.Equal(1, handler.RequestCount); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + await handler.WaitForRequestCountAsync(2); + + timeProvider.Advance(TimeSpan.FromMilliseconds(19)); + Assert.Equal(2, handler.RequestCount); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + var result = await task; + + Assert.NotNull(result); + Assert.Equal(3, handler.RequestCount); + } + [Fact] public async Task RetriesUntilSuccessAfterMultipleConnectionResetErrors() { @@ -608,12 +715,13 @@ public async Task RetriesUntilSuccessAfterMultipleConnectionResetErrors() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); for (var i = 1; i <= 4 && !task.IsCompleted; i++) { await handler.WaitForRequestCountAsync(i); - timeProvider.Advance(TimeSpan.FromMilliseconds(50)); + timeProvider.Advance(TimeSpan.FromSeconds(2)); } var result = await task; @@ -639,18 +747,316 @@ public async Task ThrowsAfterMaxRetriesWhenConnectionResetPersists() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); for (var i = 1; i <= 4 && !task.IsCompleted; i++) { await handler.WaitForRequestCountAsync(i); - timeProvider.Advance(TimeSpan.FromMilliseconds(50)); + timeProvider.Advance(TimeSpan.FromSeconds(2)); } await Assert.ThrowsAsync(() => task); Assert.Equal(4, handler.RequestCount); } + [Fact] + public async Task OpensCircuitAfterConsecutiveTransientFailuresAndFailsFast() + { + var handler = new FakeRetryHttpMessageHandler(); + for (var i = 0; i < 5; i++) + { + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + } + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); + var timeProvider = new FakeTimeProvider(); + var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None); + + for (var i = 1; i <= 5 && !task.IsCompleted; i++) + { + await handler.WaitForRequestCountAsync(i); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + } + + await Assert.ThrowsAsync(() => task); + Assert.Equal(5, handler.RequestCount); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + + Assert.Equal(5, handler.RequestCount); + } + + [Fact] + public async Task OpensCircuitAfterConsecutiveTransientFailuresWhenRetriesAreDisabled() + { + var handler = new FakeRetryHttpMessageHandler(); + for (var i = 0; i < 5; i++) + { + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + } + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 0); + var timeProvider = new FakeTimeProvider(); + var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); + + for (var i = 1; i <= 5; i++) + { + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(i, handler.RequestCount); + } + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(5, handler.RequestCount); + } + + [Fact] + public async Task HalfOpenProbeClosesCircuitAfterCooldown() + { + var handler = new FakeRetryHttpMessageHandler(); + for (var i = 0; i < 5; i++) + { + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + } + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); + var timeProvider = new FakeTimeProvider(); + var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None); + + for (var i = 1; i <= 5 && !task.IsCompleted; i++) + { + await handler.WaitForRequestCountAsync(i); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + } + + await Assert.ThrowsAsync(() => task); + + timeProvider.Advance(TimeSpan.FromSeconds(31)); + + var result = await httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(6, handler.RequestCount); + } + + [Fact] + public void AllowsOnlyOneHalfOpenProbeDuringRecovery() + { + var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); + var timeProvider = new FakeTimeProvider(); + + for (var i = 0; i < 5; i++) + { + circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe: false); + } + + timeProvider.Advance(TimeSpan.FromSeconds(31)); + + Assert.True(circuitBreaker.TryEnter(timeProvider, out var firstProbe)); + Assert.True(firstProbe); + Assert.False(circuitBreaker.TryEnter(timeProvider, out var secondProbe)); + Assert.False(secondProbe); + } + + [Fact] + public async Task HalfOpenProbeReopensCircuitWhenItFails() + { + var handler = new FakeRetryHttpMessageHandler(); + for (var i = 0; i < 6; i++) + { + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + } + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); + var timeProvider = new FakeTimeProvider(); + var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None); + + for (var i = 1; i <= 5 && !task.IsCompleted; i++) + { + await handler.WaitForRequestCountAsync(i); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + } + + await Assert.ThrowsAsync(() => task); + timeProvider.Advance(TimeSpan.FromSeconds(31)); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(6, handler.RequestCount); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(6, handler.RequestCount); + } + + [Fact] + public async Task HalfOpenProbeReopensCircuitWhenNonRetryableTransportFailureOccurs() + { + var handler = new FakeRetryHttpMessageHandler(); + for (var i = 0; i < 5; i++) + { + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + } + handler.AddException(new HttpRequestException("Connection refused", new SocketException((int)SocketError.ConnectionRefused))); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); + var timeProvider = new FakeTimeProvider(); + var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None); + + for (var i = 1; i <= 5 && !task.IsCompleted; i++) + { + await handler.WaitForRequestCountAsync(i); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + } + + await Assert.ThrowsAsync(() => task); + timeProvider.Advance(TimeSpan.FromSeconds(31)); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(6, handler.RequestCount); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(6, handler.RequestCount); + } + + [Fact] + public async Task HalfOpenProbeReopensCircuitWhenUnexpectedExceptionOccurs() + { + var handler = new FakeRetryHttpMessageHandler(); + for (var i = 0; i < 5; i++) + { + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + } + handler.AddException(new InvalidOperationException("Unexpected transport failure")); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); + var timeProvider = new FakeTimeProvider(); + var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None); + + for (var i = 1; i <= 5 && !task.IsCompleted; i++) + { + await handler.WaitForRequestCountAsync(i); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + } + + await Assert.ThrowsAsync(() => task); + timeProvider.Advance(TimeSpan.FromSeconds(31)); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(6, handler.RequestCount); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + circuitBreaker, + CancellationToken.None)); + Assert.Equal(6, handler.RequestCount); + } + [Fact] public async Task DoesNotRetryWhenFeatureFlagRequestMaxRetriesIsZero() { @@ -667,6 +1073,7 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None)); Assert.Equal(1, handler.RequestCount); @@ -691,6 +1098,7 @@ await Assert.ThrowsAnyAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), cts.Token)); Assert.Equal(1, handler.RequestCount); @@ -713,6 +1121,7 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None)); Assert.Equal(1, handler.RequestCount); @@ -733,6 +1142,7 @@ public async Task RetriesOnTaskCanceledExceptionFromTimeoutThenSucceeds() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); await handler.WaitForRequestCountAsync(1); @@ -765,6 +1175,7 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None)); Assert.Equal(1, handler.RequestCount);