From 7240b5e49429cdbdcf48e5768f0077af70963458 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 25 Jun 2026 08:41:58 +0200 Subject: [PATCH 01/13] fix: retry feature flag requests on network errors --- .changeset/quiet-flags-retry.md | 5 + src/PostHog/Api/PostHogApiClient.cs | 4 +- src/PostHog/Library/HttpClientExtensions.cs | 55 +++++++++++ .../Library/HttpClientExtensionsTests.cs | 93 +++++++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-flags-retry.md diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md new file mode 100644 index 00000000..7f6351bd --- /dev/null +++ b/.changeset/quiet-flags-retry.md @@ -0,0 +1,5 @@ +--- +'PostHog': patch +--- + +Retry feature flag requests after network errors only. diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index 44d7d2af..d2ba17fa 100644 --- a/src/PostHog/Api/PostHogApiClient.cs +++ b/src/PostHog/Api/PostHogApiClient.cs @@ -142,9 +142,11 @@ public async Task SendEventAsync( PrepareAndMutatePayload(payload); - return await _httpClient.PostJsonAsync( + return await _httpClient.PostJsonWithNetworkRetryAsync( endpointUrl, payload, + _timeProvider, + _options.Value, cancellationToken); } diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index 4a64b68a..4379b2bf 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -42,6 +42,61 @@ internal static class HttpClientExtensions cancellationToken: cancellationToken); } + /// + /// Sends a POST request with retry logic only for network/transport failures and timeouts. + /// Non-successful HTTP responses are not retried. + /// + public static async Task PostJsonWithNetworkRetryAsync( + this HttpClient httpClient, + Uri requestUri, + object content, + TimeProvider timeProvider, + PostHogOptions options, + CancellationToken cancellationToken) + { + var maxRetries = options.MaxRetries; + var currentDelay = options.InitialRetryDelay; + var maxDelay = options.MaxRetryDelay; + var attempt = 0; + + while (true) + { + attempt++; + + HttpResponseMessage response; + try + { + response = await httpClient.PostAsJsonAsync( + requestUri, + content, + JsonSerializerHelper.Options, + cancellationToken); + } + catch (HttpRequestException) when (attempt <= maxRetries) + { + await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); + currentDelay = DoubleWithCap(currentDelay, maxDelay); + continue; + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested && attempt <= maxRetries) + { + await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); + currentDelay = DoubleWithCap(currentDelay, maxDelay); + continue; + } + + using (response) + { + await response.EnsureSuccessfulApiCall(cancellationToken); + + var result = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializerHelper.DeserializeFromCamelCaseJsonAsync( + result, + cancellationToken: cancellationToken); + } + } + } + /// /// Sends a POST request with retry logic for transient failures. /// Retries on 5xx, 408 (Request Timeout), and 429 (Too Many Requests) status codes. diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 5a9cb371..1901de10 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -549,6 +549,99 @@ await Assert.ThrowsAnyAsync(() => #endif } +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, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + MaxRetryDelay = TimeSpan.FromSeconds(30) + }; + + static HttpClient CreateHttpClient(FakeRetryHttpMessageHandler handler) + => new(handler) { BaseAddress = new Uri("https://us.i.posthog.com") }; + + [Fact] + public async Task RetriesOnHttpRequestExceptionThenSucceeds() + { + var handler = new FakeRetryHttpMessageHandler(); + handler.AddException(new HttpRequestException("Network error")); + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(); + var timeProvider = new FakeTimeProvider(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None); + + await handler.WaitForRequestCountAsync(1); + timeProvider.Advance(TimeSpan.FromSeconds(1)); + var result = await task; + + Assert.NotNull(result); + Assert.Equal(2, handler.RequestCount); + } + + [Fact] + public async Task RetriesOnTaskCanceledExceptionFromTimeoutThenSucceeds() + { + var handler = new FakeRetryHttpMessageHandler(); + handler.AddException(new TaskCanceledException("The request timed out.")); + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(); + var timeProvider = new FakeTimeProvider(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None); + + await handler.WaitForRequestCountAsync(1); + timeProvider.Advance(TimeSpan.FromSeconds(1)); + var result = await task; + + Assert.NotNull(result); + Assert.Equal(2, handler.RequestCount); + } + + [Theory] + [InlineData(HttpStatusCode.RequestTimeout)] // 408 + [InlineData(HttpStatusCode.TooManyRequests)] // 429 + [InlineData(HttpStatusCode.InternalServerError)] // 500 + [InlineData(HttpStatusCode.BadGateway)] // 502 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + public async Task DoesNotRetryOnHttpErrorStatusCodes(HttpStatusCode statusCode) + { + var handler = new FakeRetryHttpMessageHandler(); + handler.AddResponse(statusCode, new { type = "error", detail = "server error" }); + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); // Should never be reached + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(); + var timeProvider = new FakeTimeProvider(); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None)); + + Assert.Equal(1, handler.RequestCount); + } +} + public class ThePostCompressedJsonAsyncMethod { static readonly Uri BatchUrl = new("https://us.i.posthog.com/batch"); From 6bc124baf5f1e48113a1238ad93ae9c345da89cc Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 25 Jun 2026 09:00:24 +0200 Subject: [PATCH 02/13] fix: align flags retry policy with transient errors --- .changeset/quiet-flags-retry.md | 2 +- src/PostHog/Library/HttpClientExtensions.cs | 23 +++++++++++++++- .../Library/HttpClientExtensionsTests.cs | 26 +++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md index 7f6351bd..bc11031c 100644 --- a/.changeset/quiet-flags-retry.md +++ b/.changeset/quiet-flags-retry.md @@ -2,4 +2,4 @@ 'PostHog': patch --- -Retry feature flag requests after network errors only. +Retry feature flag requests after transient network errors only. diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index 4379b2bf..cd06662d 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -1,6 +1,7 @@ using System.IO.Compression; using System.Net; using System.Net.Http.Json; +using System.Net.Sockets; using System.Text.Json; using PostHog.Api; using PostHog.Json; @@ -72,7 +73,7 @@ internal static class HttpClientExtensions JsonSerializerHelper.Options, cancellationToken); } - catch (HttpRequestException) when (attempt <= maxRetries) + catch (HttpRequestException e) when (attempt <= maxRetries && IsRetryableFlagsHttpRequestException(e)) { await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); currentDelay = DoubleWithCap(currentDelay, maxDelay); @@ -97,6 +98,26 @@ internal static class HttpClientExtensions } } + static bool IsRetryableFlagsHttpRequestException(HttpRequestException exception) + { + for (Exception? current = exception; current != null; current = current.InnerException) + { + if (current is SocketException socketException) + { + return socketException.SocketErrorCode is SocketError.ConnectionReset + or SocketError.NetworkReset + or SocketError.TimedOut; + } + + if (current is EndOfStreamException) + { + return true; + } + } + + return false; + } + /// /// Sends a POST request with retry logic for transient failures. /// Retries on 5xx, 408 (Request Timeout), and 429 (Too Many Requests) status codes. diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 1901de10..45adf772 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -1,6 +1,7 @@ using System.IO.Compression; using System.Net; using System.Net.Http.Headers; +using System.Net.Sockets; using System.Text; using System.Text.Json; using Microsoft.Extensions.Time.Testing; @@ -565,10 +566,10 @@ static HttpClient CreateHttpClient(FakeRetryHttpMessageHandler handler) => new(handler) { BaseAddress = new Uri("https://us.i.posthog.com") }; [Fact] - public async Task RetriesOnHttpRequestExceptionThenSucceeds() + public async Task RetriesOnConnectionResetThenSucceeds() { var handler = new FakeRetryHttpMessageHandler(); - handler.AddException(new HttpRequestException("Network error")); + 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(); @@ -589,6 +590,27 @@ public async Task RetriesOnHttpRequestExceptionThenSucceeds() Assert.Equal(2, handler.RequestCount); } + [Fact] + public async Task DoesNotRetryConnectionRefused() + { + var handler = new FakeRetryHttpMessageHandler(); + handler.AddException(new HttpRequestException("Connection refused", new SocketException((int)SocketError.ConnectionRefused))); + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(); + var timeProvider = new FakeTimeProvider(); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None)); + + Assert.Equal(1, handler.RequestCount); + } + [Fact] public async Task RetriesOnTaskCanceledExceptionFromTimeoutThenSucceeds() { From f50c80eb1b8dbcf5e7530d43cbac14e419eec3e1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 26 Jun 2026 10:41:49 +0200 Subject: [PATCH 03/13] fix: make flags retry count configurable --- .changeset/quiet-flags-retry.md | 2 +- src/PostHog/Config/PostHogOptions.cs | 6 +++++ src/PostHog/Library/HttpClientExtensions.cs | 2 +- src/PostHog/PublicAPI.Unshipped.txt | 2 ++ .../Library/HttpClientExtensionsTests.cs | 22 +++++++++++++++++++ 5 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md index bc11031c..bc44d550 100644 --- a/.changeset/quiet-flags-retry.md +++ b/.changeset/quiet-flags-retry.md @@ -2,4 +2,4 @@ 'PostHog': patch --- -Retry feature flag requests after transient network errors only. +Retry feature flag requests after transient network errors only. The feature flag request retry count defaults to 1 and can be set to 0 to disable retries. diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index c6a92e67..aab84bfd 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -173,6 +173,12 @@ public string? ProjectApiKey /// public int MaxRetries { get; set; } = 3; + /// + /// The maximum number of retries for feature flag requests after transient network errors. (Default: 1) + /// Set to 0 to disable feature flag request retries. + /// + public int FeatureFlagRequestMaxRetries { get; set; } = 1; + /// /// The initial delay between retries. (Default: 1 second) /// diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index cd06662d..50fece6f 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -55,7 +55,7 @@ internal static class HttpClientExtensions PostHogOptions options, CancellationToken cancellationToken) { - var maxRetries = options.MaxRetries; + var maxRetries = options.FeatureFlagRequestMaxRetries; var currentDelay = options.InitialRetryDelay; var maxDelay = options.MaxRetryDelay; var attempt = 0; diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index 7dc5c581..56331f82 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int +PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.set -> void diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 45adf772..093067f4 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -590,6 +590,28 @@ public async Task RetriesOnConnectionResetThenSucceeds() Assert.Equal(2, handler.RequestCount); } + [Fact] + public async Task DoesNotRetryWhenFeatureFlagRequestMaxRetriesIsZero() + { + var handler = new FakeRetryHttpMessageHandler(); + 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(); + options.FeatureFlagRequestMaxRetries = 0; + var timeProvider = new FakeTimeProvider(); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None)); + + Assert.Equal(1, handler.RequestCount); + } + [Fact] public async Task DoesNotRetryConnectionRefused() { From e2f7ffa5d112f40a258ab9896b94d91280c117a0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 26 Jun 2026 15:42:27 +0200 Subject: [PATCH 04/13] address pr review feedback --- sdk_compliance_adapter/Program.cs | 70 +++++++++++++++ src/PostHog/Api/PostHogApiClient.cs | 12 ++- src/PostHog/Features/FeatureFlagOptions.cs | 5 ++ src/PostHog/Library/HttpClientExtensions.cs | 3 + src/PostHog/PostHogClient.cs | 1 + src/PostHog/PublicAPI.Unshipped.txt | 2 + tests/UnitTests/Features/FeatureFlagsTests.cs | 31 ++++--- .../Library/HttpClientExtensionsTests.cs | 89 ++++++++++++++++++- 8 files changed, 199 insertions(+), 14 deletions(-) diff --git a/sdk_compliance_adapter/Program.cs b/sdk_compliance_adapter/Program.cs index d07b883c..9290800f 100644 --- a/sdk_compliance_adapter/Program.cs +++ b/sdk_compliance_adapter/Program.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using PostHog; +using PostHog.Features; using PostHog.Versioning; var builder = WebApplication.CreateBuilder(args); @@ -102,6 +103,41 @@ return Results.Ok(new { success = true }); }); +app.MapPost("/get_feature_flag", async (GetFeatureFlagRequest request, CancellationToken cancellationToken) => +{ + if (state.Client is null) + { + return Results.BadRequest(new { error = "SDK not initialized" }); + } + + if (string.IsNullOrEmpty(request.Key) || string.IsNullOrEmpty(request.DistinctId)) + { + return Results.BadRequest(new { error = "key and distinct_id are required" }); + } + + var options = new FeatureFlagOptions + { + PersonProperties = request.PersonProperties, + Groups = BuildGroupCollection(request.Groups, request.GroupProperties), + FlagKeysToEvaluate = [request.Key], + DisableGeoip = request.DisableGeoip ?? false + }; + +#pragma warning disable CS0618 // Compliance adapter exercises the deprecated single-flag API contract. + var flag = await state.Client.GetFeatureFlagAsync( + request.Key, + request.DistinctId, + options, + cancellationToken); +#pragma warning restore CS0618 + + // Flush the side-effect $feature_flag_called event immediately so adapter resets do not + // dispose a later client and leak the previous test's pending event into the next mock state. + await state.Client.FlushAsync(); + + return Results.Ok(new { success = true, value = ToFeatureFlagValue(flag) }); +}); + app.MapPost("/flush", async () => { if (state.Client is null) @@ -143,6 +179,30 @@ return Results.Ok(new { success = true }); }); +static GroupCollection? BuildGroupCollection( + Dictionary? groups, + Dictionary>? groupProperties) +{ + if (groups is null or { Count: 0 }) + { + return null; + } + + var collection = new GroupCollection(); + foreach (var (groupType, groupKey) in groups) + { + var properties = groupProperties?.GetValueOrDefault(groupType) ?? []; + collection.Add(new Group(groupType, groupKey, properties)); + } + + return collection; +} + +static object ToFeatureFlagValue(FeatureFlag? flag) => + flag is null + ? "undefined" + : flag.VariantKey ?? (object)flag.IsEnabled; + app.Run(); // --- Models --- @@ -170,6 +230,16 @@ record CaptureRequest( [property: JsonPropertyName("timestamp")] string? Timestamp = null ); +record GetFeatureFlagRequest( + [property: JsonPropertyName("key")] string Key, + [property: JsonPropertyName("distinct_id")] string DistinctId, + [property: JsonPropertyName("person_properties")] Dictionary? PersonProperties = null, + [property: JsonPropertyName("groups")] Dictionary? Groups = null, + [property: JsonPropertyName("group_properties")] Dictionary>? GroupProperties = null, + [property: JsonPropertyName("disable_geoip")] bool? DisableGeoip = null, + [property: JsonPropertyName("force_remote")] bool? ForceRemote = null +); + record StateResponse( [property: JsonPropertyName("pending_events")] int PendingEvents, [property: JsonPropertyName("total_events_captured")] int TotalEventsCaptured, diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index d2ba17fa..721deab6 100644 --- a/src/PostHog/Api/PostHogApiClient.cs +++ b/src/PostHog/Api/PostHogApiClient.cs @@ -107,6 +107,7 @@ public async Task SendEventAsync( /// Optional: What person properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// Optional: What group properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// The set of flag keys to evaluate. If empty, this returns all flags. + /// Whether to disable GeoIP lookup for this request. /// The cancellation token that can be used to cancel the operation. /// A . public async Task GetFeatureFlagsAsync( @@ -114,18 +115,25 @@ public async Task SendEventAsync( Dictionary? personProperties, GroupCollection? groupProperties, IReadOnlyList? flagKeysToEvaluate, + bool disableGeoip, CancellationToken cancellationToken) { var endpointUrl = new Uri(HostUrl, "flags/?v=2"); var payload = new Dictionary { - ["distinct_id"] = distinctUserId + ["distinct_id"] = distinctUserId, + ["groups"] = new Dictionary(), + ["group_properties"] = new Dictionary>(), + ["geoip_disable"] = disableGeoip }; if (personProperties is { Count: > 0 }) { - payload["person_properties"] = personProperties; + payload["person_properties"] = new Dictionary(personProperties) + { + ["distinct_id"] = distinctUserId + }; } if (flagKeysToEvaluate is { Count: > 0 }) diff --git a/src/PostHog/Features/FeatureFlagOptions.cs b/src/PostHog/Features/FeatureFlagOptions.cs index 5e2a6667..b0136c53 100644 --- a/src/PostHog/Features/FeatureFlagOptions.cs +++ b/src/PostHog/Features/FeatureFlagOptions.cs @@ -38,6 +38,11 @@ public class AllFeatureFlagsOptions /// public Dictionary? PersonProperties { get; init; } + /// + /// Whether to disable GeoIP lookup for this feature flag request. Defaults to false. + /// + public bool DisableGeoip { get; init; } + /// /// A list of the currently active groups. Required if the flag depends on groups. Each group can optionally /// include properties that override what's on PostHog's server when evaluating feature flags. diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index 50fece6f..6f8c1ec5 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -86,6 +86,9 @@ internal static class HttpClientExtensions continue; } + // 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) { await response.EnsureSuccessfulApiCall(cancellationToken); diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index ca25739f..a31b6446 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -1146,6 +1146,7 @@ async Task FetchFlagsAsync(string distId, CancellationToken ctx) options?.PersonProperties, options?.Groups, options?.FlagKeysToEvaluate, + options?.DisableGeoip ?? false, ctx); return results.ToFlagsResult(); } diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index 56331f82..32319b51 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable +PostHog.AllFeatureFlagsOptions.DisableGeoip.get -> bool +PostHog.AllFeatureFlagsOptions.DisableGeoip.init -> void PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.set -> void diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index 5e1bf1d2..57dd259e 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -2387,13 +2387,12 @@ public async Task CallsDecideWithFlagKeyToEvaluate() Assert.NotNull(result); Assert.Equal(new FeatureFlag { Key = "beta-feature", VariantKey = "alakazam" }, result); var receivedBody = handler.GetReceivedRequestBody(true); - Assert.StartsWith( + Assert.Contains("\"distinct_id\": \"some-distinct-id\"", receivedBody, StringComparison.Ordinal); + Assert.Contains( """ - { - "distinct_id": "some-distinct-id", - "flag_keys_to_evaluate": [ + "flag_keys_to_evaluate": [ "beta-feature" - ], + ] """, receivedBody, StringComparison.Ordinal); @@ -3978,11 +3977,23 @@ public async Task DoesNotIncludeErrorPropertyWhenNoErrors() [Fact] public async Task IncludesTimeoutErrorWhenRequestTimesOut() - => await AssertCapturedFeatureFlagErrorAsync( - handler => handler.AddFlagsResponseException(new TaskCanceledException("Request timed out")), - featureKey: "some-flag", - expectedResult: false, - expectedError: "timeout"); + { + var container = new TestContainer(services => services.Configure(options => + { + options.FeatureFlagRequestMaxRetries = 0; + })); + container.FakeHttpMessageHandler.AddFlagsResponseException(new TaskCanceledException("Request timed out")); + var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var result = await client.GetFeatureFlagAsync("some-flag", "distinct-id"); + + Assert.NotNull(result); + Assert.False(result.IsEnabled); + await client.FlushAsync(); + var received = captureRequestHandler.GetReceivedRequestBody(indented: true); + Assert.Contains("\"$feature_flag_error\": \"timeout\"", received, StringComparison.Ordinal); + } [Fact] public async Task IncludesConnectionErrorWhenNetworkFails() diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 093067f4..c1c9919b 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -558,6 +558,7 @@ public class ThePostJsonWithNetworkRetryAsyncMethod { ProjectToken = "test-api-key", MaxRetries = maxRetries, + FeatureFlagRequestMaxRetries = maxRetries, InitialRetryDelay = TimeSpan.FromMilliseconds(1), MaxRetryDelay = TimeSpan.FromSeconds(30) }; @@ -590,6 +591,66 @@ public async Task RetriesOnConnectionResetThenSucceeds() Assert.Equal(2, handler.RequestCount); } + [Fact] + public async Task RetriesUntilSuccessAfterMultipleConnectionResetErrors() + { + 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.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: 3); + var timeProvider = new FakeTimeProvider(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None); + + for (var i = 1; i <= 4 && !task.IsCompleted; i++) + { + await handler.WaitForRequestCountAsync(i); + timeProvider.Advance(TimeSpan.FromMilliseconds(50)); + } + + var result = await task; + + Assert.NotNull(result); + Assert.Equal(4, handler.RequestCount); + } + + [Fact] + public async Task ThrowsAfterMaxRetriesWhenConnectionResetPersists() + { + 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.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(maxRetries: 3); + var timeProvider = new FakeTimeProvider(); + + var task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None); + + for (var i = 1; i <= 4 && !task.IsCompleted; i++) + { + await handler.WaitForRequestCountAsync(i); + timeProvider.Advance(TimeSpan.FromMilliseconds(50)); + } + + await Assert.ThrowsAsync(() => task); + Assert.Equal(4, handler.RequestCount); + } + [Fact] public async Task DoesNotRetryWhenFeatureFlagRequestMaxRetriesIsZero() { @@ -597,8 +658,7 @@ public async Task DoesNotRetryWhenFeatureFlagRequestMaxRetriesIsZero() 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(); - options.FeatureFlagRequestMaxRetries = 0; + var options = CreateOptions(maxRetries: 0); var timeProvider = new FakeTimeProvider(); await Assert.ThrowsAsync(() => @@ -612,6 +672,31 @@ await Assert.ThrowsAsync(() => Assert.Equal(1, handler.RequestCount); } +#if NET8_0_OR_GREATER + [Fact] + public async Task DoesNotRetryOnUserCancellation() + { + var handler = new FakeRetryHttpMessageHandler(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + handler.AddException(new TaskCanceledException("Operation was canceled.", null, cts.Token)); + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + using var httpClient = CreateHttpClient(handler); + var options = CreateOptions(); + var timeProvider = new FakeTimeProvider(); + + await Assert.ThrowsAnyAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + cts.Token)); + + Assert.Equal(1, handler.RequestCount); + } +#endif + [Fact] public async Task DoesNotRetryConnectionRefused() { From b9596991940b5d43239e77df21f917a47e677b13 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 26 Jun 2026 16:08:16 +0200 Subject: [PATCH 05/13] address pr review feedback --- sdk_compliance_adapter/Program.cs | 70 ------------------- src/PostHog/Api/PostHogApiClient.cs | 12 +--- src/PostHog/Features/FeatureFlagOptions.cs | 5 -- src/PostHog/PostHogClient.cs | 1 - src/PostHog/PublicAPI.Unshipped.txt | 2 - tests/UnitTests/Features/FeatureFlagsTests.cs | 9 +-- 6 files changed, 7 insertions(+), 92 deletions(-) diff --git a/sdk_compliance_adapter/Program.cs b/sdk_compliance_adapter/Program.cs index 9290800f..d07b883c 100644 --- a/sdk_compliance_adapter/Program.cs +++ b/sdk_compliance_adapter/Program.cs @@ -3,7 +3,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using PostHog; -using PostHog.Features; using PostHog.Versioning; var builder = WebApplication.CreateBuilder(args); @@ -103,41 +102,6 @@ return Results.Ok(new { success = true }); }); -app.MapPost("/get_feature_flag", async (GetFeatureFlagRequest request, CancellationToken cancellationToken) => -{ - if (state.Client is null) - { - return Results.BadRequest(new { error = "SDK not initialized" }); - } - - if (string.IsNullOrEmpty(request.Key) || string.IsNullOrEmpty(request.DistinctId)) - { - return Results.BadRequest(new { error = "key and distinct_id are required" }); - } - - var options = new FeatureFlagOptions - { - PersonProperties = request.PersonProperties, - Groups = BuildGroupCollection(request.Groups, request.GroupProperties), - FlagKeysToEvaluate = [request.Key], - DisableGeoip = request.DisableGeoip ?? false - }; - -#pragma warning disable CS0618 // Compliance adapter exercises the deprecated single-flag API contract. - var flag = await state.Client.GetFeatureFlagAsync( - request.Key, - request.DistinctId, - options, - cancellationToken); -#pragma warning restore CS0618 - - // Flush the side-effect $feature_flag_called event immediately so adapter resets do not - // dispose a later client and leak the previous test's pending event into the next mock state. - await state.Client.FlushAsync(); - - return Results.Ok(new { success = true, value = ToFeatureFlagValue(flag) }); -}); - app.MapPost("/flush", async () => { if (state.Client is null) @@ -179,30 +143,6 @@ return Results.Ok(new { success = true }); }); -static GroupCollection? BuildGroupCollection( - Dictionary? groups, - Dictionary>? groupProperties) -{ - if (groups is null or { Count: 0 }) - { - return null; - } - - var collection = new GroupCollection(); - foreach (var (groupType, groupKey) in groups) - { - var properties = groupProperties?.GetValueOrDefault(groupType) ?? []; - collection.Add(new Group(groupType, groupKey, properties)); - } - - return collection; -} - -static object ToFeatureFlagValue(FeatureFlag? flag) => - flag is null - ? "undefined" - : flag.VariantKey ?? (object)flag.IsEnabled; - app.Run(); // --- Models --- @@ -230,16 +170,6 @@ record CaptureRequest( [property: JsonPropertyName("timestamp")] string? Timestamp = null ); -record GetFeatureFlagRequest( - [property: JsonPropertyName("key")] string Key, - [property: JsonPropertyName("distinct_id")] string DistinctId, - [property: JsonPropertyName("person_properties")] Dictionary? PersonProperties = null, - [property: JsonPropertyName("groups")] Dictionary? Groups = null, - [property: JsonPropertyName("group_properties")] Dictionary>? GroupProperties = null, - [property: JsonPropertyName("disable_geoip")] bool? DisableGeoip = null, - [property: JsonPropertyName("force_remote")] bool? ForceRemote = null -); - record StateResponse( [property: JsonPropertyName("pending_events")] int PendingEvents, [property: JsonPropertyName("total_events_captured")] int TotalEventsCaptured, diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index 721deab6..d2ba17fa 100644 --- a/src/PostHog/Api/PostHogApiClient.cs +++ b/src/PostHog/Api/PostHogApiClient.cs @@ -107,7 +107,6 @@ public async Task SendEventAsync( /// Optional: What person properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// Optional: What group properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// The set of flag keys to evaluate. If empty, this returns all flags. - /// Whether to disable GeoIP lookup for this request. /// The cancellation token that can be used to cancel the operation. /// A . public async Task GetFeatureFlagsAsync( @@ -115,25 +114,18 @@ public async Task SendEventAsync( Dictionary? personProperties, GroupCollection? groupProperties, IReadOnlyList? flagKeysToEvaluate, - bool disableGeoip, CancellationToken cancellationToken) { var endpointUrl = new Uri(HostUrl, "flags/?v=2"); var payload = new Dictionary { - ["distinct_id"] = distinctUserId, - ["groups"] = new Dictionary(), - ["group_properties"] = new Dictionary>(), - ["geoip_disable"] = disableGeoip + ["distinct_id"] = distinctUserId }; if (personProperties is { Count: > 0 }) { - payload["person_properties"] = new Dictionary(personProperties) - { - ["distinct_id"] = distinctUserId - }; + payload["person_properties"] = personProperties; } if (flagKeysToEvaluate is { Count: > 0 }) diff --git a/src/PostHog/Features/FeatureFlagOptions.cs b/src/PostHog/Features/FeatureFlagOptions.cs index b0136c53..5e2a6667 100644 --- a/src/PostHog/Features/FeatureFlagOptions.cs +++ b/src/PostHog/Features/FeatureFlagOptions.cs @@ -38,11 +38,6 @@ public class AllFeatureFlagsOptions /// public Dictionary? PersonProperties { get; init; } - /// - /// Whether to disable GeoIP lookup for this feature flag request. Defaults to false. - /// - public bool DisableGeoip { get; init; } - /// /// A list of the currently active groups. Required if the flag depends on groups. Each group can optionally /// include properties that override what's on PostHog's server when evaluating feature flags. diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index a31b6446..ca25739f 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -1146,7 +1146,6 @@ async Task FetchFlagsAsync(string distId, CancellationToken ctx) options?.PersonProperties, options?.Groups, options?.FlagKeysToEvaluate, - options?.DisableGeoip ?? false, ctx); return results.ToFlagsResult(); } diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index 32319b51..56331f82 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -1,5 +1,3 @@ #nullable enable -PostHog.AllFeatureFlagsOptions.DisableGeoip.get -> bool -PostHog.AllFeatureFlagsOptions.DisableGeoip.init -> void PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.set -> void diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index 57dd259e..fb403c59 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -2387,12 +2387,13 @@ public async Task CallsDecideWithFlagKeyToEvaluate() Assert.NotNull(result); Assert.Equal(new FeatureFlag { Key = "beta-feature", VariantKey = "alakazam" }, result); var receivedBody = handler.GetReceivedRequestBody(true); - Assert.Contains("\"distinct_id\": \"some-distinct-id\"", receivedBody, StringComparison.Ordinal); - Assert.Contains( + Assert.StartsWith( """ - "flag_keys_to_evaluate": [ + { + "distinct_id": "some-distinct-id", + "flag_keys_to_evaluate": [ "beta-feature" - ] + ], """, receivedBody, StringComparison.Ordinal); From f073e4c9a5ea9e9122e9f4cd570fe809a6501a2f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 26 Jun 2026 21:39:31 +0200 Subject: [PATCH 06/13] fix: add feature flag retry circuit breaker --- src/PostHog/Library/HttpClientExtensions.cs | 134 +++++++++++++++++- .../Library/HttpClientExtensionsTests.cs | 106 ++++++++++++-- 2 files changed, 226 insertions(+), 14 deletions(-) diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index 6f8c1ec5..187300a5 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -13,6 +13,9 @@ namespace PostHog.Library; /// internal static class HttpClientExtensions { + static readonly TimeSpan FeatureFlagInitialRetryDelay = TimeSpan.FromMilliseconds(300); + static readonly FeatureFlagRequestCircuitBreaker FeatureFlagCircuitBreaker = new(); + /// /// Sends a POST request to the specified Uri containing the value serialized as JSON in the request body. /// Returns the response body deserialized as . @@ -56,10 +59,15 @@ internal static class HttpClientExtensions CancellationToken cancellationToken) { var maxRetries = options.FeatureFlagRequestMaxRetries; - var currentDelay = options.InitialRetryDelay; + var currentDelay = FeatureFlagInitialRetryDelay; var maxDelay = options.MaxRetryDelay; var attempt = 0; + if (!FeatureFlagCircuitBreaker.TryEnter(timeProvider, out var isHalfOpenProbe)) + { + throw new HttpRequestException("Feature flag request circuit breaker is open."); + } + while (true) { attempt++; @@ -73,24 +81,48 @@ internal static class HttpClientExtensions JsonSerializerHelper.Options, cancellationToken); } - catch (HttpRequestException e) when (attempt <= maxRetries && IsRetryableFlagsHttpRequestException(e)) + catch (HttpRequestException e) when (IsRetryableFlagsHttpRequestException(e)) { + if (attempt > maxRetries || + !FeatureFlagCircuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe)) + { + throw; + } + await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); currentDelay = DoubleWithCap(currentDelay, maxDelay); continue; } - catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested && attempt <= maxRetries) + catch (HttpRequestException) { + FeatureFlagCircuitBreaker.RecordNonTransientFailure(isHalfOpenProbe); + throw; + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + if (attempt > maxRetries || + !FeatureFlagCircuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe)) + { + throw; + } + await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); currentDelay = DoubleWithCap(currentDelay, maxDelay); continue; } + catch (Exception) when (isHalfOpenProbe) + { + FeatureFlagCircuitBreaker.RecordNonTransientFailure(isHalfOpenProbe); + 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) { + FeatureFlagCircuitBreaker.RecordResponseReceived(); + await response.EnsureSuccessfulApiCall(cancellationToken); var result = await response.Content.ReadAsStreamAsync(cancellationToken); @@ -101,6 +133,9 @@ internal static class HttpClientExtensions } } + internal static void ResetFeatureFlagCircuitBreakerForTests() + => FeatureFlagCircuitBreaker.Reset(); + static bool IsRetryableFlagsHttpRequestException(HttpRequestException exception) { for (Exception? current = exception; current != null; current = current.InnerException) @@ -385,4 +420,95 @@ 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 RecordResponseReceived() + { + lock (_lock) + { + _state = State.Closed; + _consecutiveFailures = 0; + _openUntil = default; + } + } + + public void RecordNonTransientFailure(bool isHalfOpenProbe) + { + if (!isHalfOpenProbe) + { + return; + } + + RecordResponseReceived(); + } + + public void Reset() + { + 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 c1c9919b..1e427170 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -554,14 +554,18 @@ public class ThePostJsonWithNetworkRetryAsyncMethod { static readonly Uri FlagsUrl = new("https://us.i.posthog.com/flags/?v=2"); - static PostHogOptions CreateOptions(int maxRetries = 3) => new() + static PostHogOptions CreateOptions(int maxRetries = 3, TimeSpan? maxRetryDelay = null) { - ProjectToken = "test-api-key", - MaxRetries = maxRetries, - FeatureFlagRequestMaxRetries = maxRetries, - InitialRetryDelay = TimeSpan.FromMilliseconds(1), - MaxRetryDelay = TimeSpan.FromSeconds(30) - }; + HttpClientExtensions.ResetFeatureFlagCircuitBreakerForTests(); + return new PostHogOptions + { + ProjectToken = "test-api-key", + MaxRetries = maxRetries, + FeatureFlagRequestMaxRetries = maxRetries, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + MaxRetryDelay = maxRetryDelay ?? TimeSpan.FromSeconds(30) + }; + } static HttpClient CreateHttpClient(FakeRetryHttpMessageHandler handler) => new(handler) { BaseAddress = new Uri("https://us.i.posthog.com") }; @@ -584,7 +588,9 @@ public async Task RetriesOnConnectionResetThenSucceeds() CancellationToken.None); await handler.WaitForRequestCountAsync(1); - timeProvider.Advance(TimeSpan.FromSeconds(1)); + timeProvider.Advance(TimeSpan.FromMilliseconds(299)); + Assert.Equal(1, handler.RequestCount); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); var result = await task; Assert.NotNull(result); @@ -613,7 +619,7 @@ public async Task RetriesUntilSuccessAfterMultipleConnectionResetErrors() 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; @@ -644,13 +650,93 @@ public async Task ThrowsAfterMaxRetriesWhenConnectionResetPersists() 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 task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + 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, + 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 task = httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + 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, + CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(6, handler.RequestCount); + } + [Fact] public async Task DoesNotRetryWhenFeatureFlagRequestMaxRetriesIsZero() { From da7599024bed8f5b14bac909dcf8e1adc5cd51cf Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 27 Jun 2026 11:14:05 +0200 Subject: [PATCH 07/13] fix: address circuit breaker review feedback --- src/PostHog/Api/PostHogApiClient.cs | 4 +- src/PostHog/Library/HttpClientExtensions.cs | 67 +++++----- .../Library/HttpClientExtensionsTests.cs | 120 +++++++++++++++--- 3 files changed, 138 insertions(+), 53 deletions(-) diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index d2ba17fa..ea93e209 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 @@ -147,7 +148,8 @@ public async Task SendEventAsync( payload, _timeProvider, _options.Value, - cancellationToken); + cancellationToken, + _featureFlagRequestCircuitBreaker); } /// diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index 187300a5..e9d220dd 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -14,7 +14,6 @@ namespace PostHog.Library; internal static class HttpClientExtensions { static readonly TimeSpan FeatureFlagInitialRetryDelay = TimeSpan.FromMilliseconds(300); - static readonly FeatureFlagRequestCircuitBreaker FeatureFlagCircuitBreaker = new(); /// /// Sends a POST request to the specified Uri containing the value serialized as JSON in the request body. @@ -56,18 +55,26 @@ internal static class HttpClientExtensions object content, TimeProvider timeProvider, PostHogOptions options, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + FeatureFlagRequestCircuitBreaker? circuitBreaker = null) { var maxRetries = options.FeatureFlagRequestMaxRetries; var currentDelay = FeatureFlagInitialRetryDelay; var maxDelay = options.MaxRetryDelay; var attempt = 0; + circuitBreaker ??= new FeatureFlagRequestCircuitBreaker(); - if (!FeatureFlagCircuitBreaker.TryEnter(timeProvider, out var isHalfOpenProbe)) + 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); + } + while (true) { attempt++; @@ -83,36 +90,42 @@ internal static class HttpClientExtensions } catch (HttpRequestException e) when (IsRetryableFlagsHttpRequestException(e)) { - if (attempt > maxRetries || - !FeatureFlagCircuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe)) + var circuitClosed = circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe); + if (attempt > maxRetries || !circuitClosed) { throw; } - await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); - currentDelay = DoubleWithCap(currentDelay, maxDelay); + await DelayBeforeRetry(); continue; } catch (HttpRequestException) { - FeatureFlagCircuitBreaker.RecordNonTransientFailure(isHalfOpenProbe); + if (isHalfOpenProbe) + { + circuitBreaker.RecordResponseReceived(); + } throw; } catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { - if (attempt > maxRetries || - !FeatureFlagCircuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe)) + var circuitClosed = circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe); + if (attempt > maxRetries || !circuitClosed) { throw; } - await Delay(timeProvider, currentDelay > maxDelay ? maxDelay : currentDelay, cancellationToken); - currentDelay = DoubleWithCap(currentDelay, maxDelay); + await DelayBeforeRetry(); continue; } + catch (OperationCanceledException) when (isHalfOpenProbe && cancellationToken.IsCancellationRequested) + { + circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe: true); + throw; + } catch (Exception) when (isHalfOpenProbe) { - FeatureFlagCircuitBreaker.RecordNonTransientFailure(isHalfOpenProbe); + circuitBreaker.RecordResponseReceived(); throw; } @@ -121,7 +134,10 @@ internal static class HttpClientExtensions // be caught by the retry logic above. using (response) { - FeatureFlagCircuitBreaker.RecordResponseReceived(); + if (isHalfOpenProbe || response.IsSuccessStatusCode) + { + circuitBreaker.RecordResponseReceived(); + } await response.EnsureSuccessfulApiCall(cancellationToken); @@ -133,9 +149,6 @@ internal static class HttpClientExtensions } } - internal static void ResetFeatureFlagCircuitBreakerForTests() - => FeatureFlagCircuitBreaker.Reset(); - static bool IsRetryableFlagsHttpRequestException(HttpRequestException exception) { for (Exception? current = exception; current != null; current = current.InnerException) @@ -478,26 +491,6 @@ public void RecordResponseReceived() } } - public void RecordNonTransientFailure(bool isHalfOpenProbe) - { - if (!isHalfOpenProbe) - { - return; - } - - RecordResponseReceived(); - } - - public void Reset() - { - lock (_lock) - { - _state = State.Closed; - _consecutiveFailures = 0; - _openUntil = default; - } - } - void Open(TimeProvider timeProvider) { _state = State.Open; diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 1e427170..1286d3c8 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -554,18 +554,14 @@ public class ThePostJsonWithNetworkRetryAsyncMethod { static readonly Uri FlagsUrl = new("https://us.i.posthog.com/flags/?v=2"); - static PostHogOptions CreateOptions(int maxRetries = 3, TimeSpan? maxRetryDelay = null) + static PostHogOptions CreateOptions(int maxRetries = 3, TimeSpan? maxRetryDelay = null) => new() { - HttpClientExtensions.ResetFeatureFlagCircuitBreakerForTests(); - return new PostHogOptions - { - ProjectToken = "test-api-key", - MaxRetries = maxRetries, - FeatureFlagRequestMaxRetries = maxRetries, - InitialRetryDelay = TimeSpan.FromMilliseconds(1), - MaxRetryDelay = maxRetryDelay ?? TimeSpan.FromSeconds(30) - }; - } + ProjectToken = "test-api-key", + MaxRetries = maxRetries, + FeatureFlagRequestMaxRetries = maxRetries, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + MaxRetryDelay = maxRetryDelay ?? TimeSpan.FromSeconds(30) + }; static HttpClient CreateHttpClient(FakeRetryHttpMessageHandler handler) => new(handler) { BaseAddress = new Uri("https://us.i.posthog.com") }; @@ -668,13 +664,15 @@ public async Task OpensCircuitAfterConsecutiveTransientFailuresAndFailsFast() 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, - CancellationToken.None); + CancellationToken.None, + circuitBreaker); for (var i = 1; i <= 5 && !task.IsCompleted; i++) { @@ -691,11 +689,49 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None)); + CancellationToken.None, + circuitBreaker)); 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, + CancellationToken.None, + circuitBreaker)); + Assert.Equal(i, handler.RequestCount); + } + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None, + circuitBreaker)); + Assert.Equal(5, handler.RequestCount); + } + [Fact] public async Task HalfOpenProbeClosesCircuitAfterCooldown() { @@ -708,13 +744,15 @@ public async Task HalfOpenProbeClosesCircuitAfterCooldown() 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, - CancellationToken.None); + CancellationToken.None, + circuitBreaker); for (var i = 1; i <= 5 && !task.IsCompleted; i++) { @@ -731,12 +769,64 @@ public async Task HalfOpenProbeClosesCircuitAfterCooldown() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None); + CancellationToken.None, + circuitBreaker); Assert.NotNull(result); Assert.Equal(6, handler.RequestCount); } + [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, + CancellationToken.None, + circuitBreaker); + + 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, + CancellationToken.None, + circuitBreaker)); + Assert.Equal(6, handler.RequestCount); + + await Assert.ThrowsAsync(() => + httpClient.PostJsonWithNetworkRetryAsync( + FlagsUrl, + new { api_key = "test", distinct_id = "user-1" }, + timeProvider, + options, + CancellationToken.None, + circuitBreaker)); + Assert.Equal(6, handler.RequestCount); + } + [Fact] public async Task DoesNotRetryWhenFeatureFlagRequestMaxRetriesIsZero() { From 346bcd1f26630f82ee52aa957ef96db80c33d603 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 27 Jun 2026 12:47:20 +0200 Subject: [PATCH 08/13] fix: honor initial retry delay for flags --- src/PostHog/Library/HttpClientExtensions.cs | 4 +- .../Library/HttpClientExtensionsTests.cs | 53 +++++++++++++++---- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index e9d220dd..b2227a1a 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -13,8 +13,6 @@ namespace PostHog.Library; /// internal static class HttpClientExtensions { - static readonly TimeSpan FeatureFlagInitialRetryDelay = TimeSpan.FromMilliseconds(300); - /// /// Sends a POST request to the specified Uri containing the value serialized as JSON in the request body. /// Returns the response body deserialized as . @@ -59,7 +57,7 @@ internal static class HttpClientExtensions FeatureFlagRequestCircuitBreaker? circuitBreaker = null) { var maxRetries = options.FeatureFlagRequestMaxRetries; - var currentDelay = FeatureFlagInitialRetryDelay; + var currentDelay = options.InitialRetryDelay; var maxDelay = options.MaxRetryDelay; var attempt = 0; circuitBreaker ??= new FeatureFlagRequestCircuitBreaker(); diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 1286d3c8..261b2ae6 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, TimeSpan? maxRetryDelay = null) => new() - { - ProjectToken = "test-api-key", - MaxRetries = maxRetries, - FeatureFlagRequestMaxRetries = maxRetries, - InitialRetryDelay = TimeSpan.FromMilliseconds(1), - MaxRetryDelay = 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") }; @@ -584,7 +587,6 @@ public async Task RetriesOnConnectionResetThenSucceeds() CancellationToken.None); await handler.WaitForRequestCountAsync(1); - timeProvider.Advance(TimeSpan.FromMilliseconds(299)); Assert.Equal(1, handler.RequestCount); timeProvider.Advance(TimeSpan.FromMilliseconds(1)); var result = await task; @@ -593,6 +595,39 @@ public async Task RetriesOnConnectionResetThenSucceeds() Assert.Equal(2, 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, + 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() { From af30ef27b4062dbb967394569156ed752fdc47e3 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 1 Jul 2026 12:20:48 +0200 Subject: [PATCH 09/13] address pr review feedback --- .changeset/quiet-flags-retry.md | 5 --- src/PostHog/Api/PostHogApiClient.cs | 4 +- src/PostHog/Library/HttpClientExtensions.cs | 5 +-- .../Library/HttpClientExtensionsTests.cs | 45 +++++++++++-------- 4 files changed, 31 insertions(+), 28 deletions(-) delete mode 100644 .changeset/quiet-flags-retry.md diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md deleted file mode 100644 index bc44d550..00000000 --- a/.changeset/quiet-flags-retry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'PostHog': patch ---- - -Retry feature flag requests after transient network errors only. The feature flag request retry count defaults to 1 and can be set to 0 to disable retries. diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index cc727810..18683d59 100644 --- a/src/PostHog/Api/PostHogApiClient.cs +++ b/src/PostHog/Api/PostHogApiClient.cs @@ -160,8 +160,8 @@ public async Task SendEventAsync( payload, _timeProvider, _options.Value, - cancellationToken, - _featureFlagRequestCircuitBreaker); + _featureFlagRequestCircuitBreaker, + cancellationToken); } /// diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index d02059ca..b093efcd 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -55,14 +55,13 @@ internal static class HttpClientExtensions object content, TimeProvider timeProvider, PostHogOptions options, - CancellationToken cancellationToken, - FeatureFlagRequestCircuitBreaker? circuitBreaker = null) + FeatureFlagRequestCircuitBreaker circuitBreaker, + CancellationToken cancellationToken) { var maxRetries = options.FeatureFlagRequestMaxRetries; var currentDelay = options.InitialRetryDelay; var maxDelay = options.MaxRetryDelay; var attempt = 0; - circuitBreaker ??= new FeatureFlagRequestCircuitBreaker(); if (!circuitBreaker.TryEnter(timeProvider, out var isHalfOpenProbe)) { diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 0940a547..35848572 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -584,6 +584,7 @@ public async Task RetriesOnConnectionResetThenSucceeds() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); await handler.WaitForRequestCountAsync(1); @@ -611,6 +612,7 @@ public async Task UsesInitialRetryDelayAndDoublesForFeatureFlagRetries() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); await handler.WaitForRequestCountAsync(1); @@ -645,6 +647,7 @@ 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++) @@ -676,6 +679,7 @@ 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++) @@ -706,8 +710,8 @@ public async Task OpensCircuitAfterConsecutiveTransientFailuresAndFailsFast() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker); + circuitBreaker, + CancellationToken.None); for (var i = 1; i <= 5 && !task.IsCompleted; i++) { @@ -724,8 +728,8 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker)); + circuitBreaker, + CancellationToken.None)); Assert.Equal(5, handler.RequestCount); } @@ -751,8 +755,8 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker)); + circuitBreaker, + CancellationToken.None)); Assert.Equal(i, handler.RequestCount); } @@ -762,8 +766,8 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker)); + circuitBreaker, + CancellationToken.None)); Assert.Equal(5, handler.RequestCount); } @@ -786,8 +790,8 @@ public async Task HalfOpenProbeClosesCircuitAfterCooldown() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker); + circuitBreaker, + CancellationToken.None); for (var i = 1; i <= 5 && !task.IsCompleted; i++) { @@ -804,8 +808,8 @@ public async Task HalfOpenProbeClosesCircuitAfterCooldown() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker); + circuitBreaker, + CancellationToken.None); Assert.NotNull(result); Assert.Equal(6, handler.RequestCount); @@ -829,8 +833,8 @@ public async Task HalfOpenProbeReopensCircuitWhenItFails() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker); + circuitBreaker, + CancellationToken.None); for (var i = 1; i <= 5 && !task.IsCompleted; i++) { @@ -847,8 +851,8 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker)); + circuitBreaker, + CancellationToken.None)); Assert.Equal(6, handler.RequestCount); await Assert.ThrowsAsync(() => @@ -857,8 +861,8 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, - CancellationToken.None, - circuitBreaker)); + circuitBreaker, + CancellationToken.None)); Assert.Equal(6, handler.RequestCount); } @@ -878,6 +882,7 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None)); Assert.Equal(1, handler.RequestCount); @@ -902,6 +907,7 @@ await Assert.ThrowsAnyAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), cts.Token)); Assert.Equal(1, handler.RequestCount); @@ -924,6 +930,7 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None)); Assert.Equal(1, handler.RequestCount); @@ -944,6 +951,7 @@ public async Task RetriesOnTaskCanceledExceptionFromTimeoutThenSucceeds() new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); await handler.WaitForRequestCountAsync(1); @@ -976,6 +984,7 @@ await Assert.ThrowsAsync(() => new { api_key = "test", distinct_id = "user-1" }, timeProvider, options, + new FeatureFlagRequestCircuitBreaker(), CancellationToken.None)); Assert.Equal(1, handler.RequestCount); From 0bd20b3c9112904de15f3e29621d5f68e6fc087d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 1 Jul 2026 12:22:37 +0200 Subject: [PATCH 10/13] restore feature flag retry changeset --- .changeset/quiet-flags-retry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-flags-retry.md diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md new file mode 100644 index 00000000..bc44d550 --- /dev/null +++ b/.changeset/quiet-flags-retry.md @@ -0,0 +1,5 @@ +--- +'PostHog': patch +--- + +Retry feature flag requests after transient network errors only. The feature flag request retry count defaults to 1 and can be set to 0 to disable retries. From 910e4256fce0f7a9318d6100b97e46aadec9168a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 1 Jul 2026 12:23:07 +0200 Subject: [PATCH 11/13] update feature flag circuit breaker changeset --- .changeset/quiet-flags-retry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md index bc44d550..e1abe8ef 100644 --- a/.changeset/quiet-flags-retry.md +++ b/.changeset/quiet-flags-retry.md @@ -2,4 +2,4 @@ 'PostHog': patch --- -Retry feature flag requests after transient network errors only. The feature flag request retry count defaults to 1 and can be set to 0 to disable retries. +Add a per-client circuit breaker for feature flag requests after consecutive transient network failures, temporarily failing fast before probing for recovery. From 1117640c2ed9180ccedc7b3a08702f66870a8e5b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 1 Jul 2026 12:23:44 +0200 Subject: [PATCH 12/13] add aspnet package to changeset --- .changeset/quiet-flags-retry.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md index e1abe8ef..ab661016 100644 --- a/.changeset/quiet-flags-retry.md +++ b/.changeset/quiet-flags-retry.md @@ -1,5 +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. From 4ad8cc89db28cd40199c2dc502d4297a572b395e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 2 Jul 2026 12:40:50 +0200 Subject: [PATCH 13/13] address circuit breaker review feedback --- src/PostHog/Library/HttpClientExtensions.cs | 28 ++- .../Library/HttpClientExtensionsTests.cs | 191 ++++++++++++++++++ 2 files changed, 209 insertions(+), 10 deletions(-) diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index b093efcd..b6144f77 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -74,6 +74,18 @@ async Task DelayBeforeRetry() 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++; @@ -89,32 +101,28 @@ async Task DelayBeforeRetry() } catch (HttpRequestException e) when (IsRetryableFlagsHttpRequestException(e)) { - var circuitClosed = circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe); - if (attempt > maxRetries || !circuitClosed) + if (!await ShouldRetryAfterTransientFailure()) { throw; } - await DelayBeforeRetry(); continue; } catch (HttpRequestException) { if (isHalfOpenProbe) { - circuitBreaker.RecordResponseReceived(); + circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe: true); } throw; } catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { - var circuitClosed = circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe); - if (attempt > maxRetries || !circuitClosed) + if (!await ShouldRetryAfterTransientFailure()) { throw; } - await DelayBeforeRetry(); continue; } catch (OperationCanceledException) when (isHalfOpenProbe && cancellationToken.IsCancellationRequested) @@ -124,7 +132,7 @@ async Task DelayBeforeRetry() } catch (Exception) when (isHalfOpenProbe) { - circuitBreaker.RecordResponseReceived(); + circuitBreaker.RecordTransientFailure(timeProvider, isHalfOpenProbe: true); throw; } @@ -135,7 +143,7 @@ async Task DelayBeforeRetry() { if (isHalfOpenProbe || response.IsSuccessStatusCode) { - circuitBreaker.RecordResponseReceived(); + circuitBreaker.Close(); } await response.EnsureSuccessfulApiCall(cancellationToken); @@ -511,7 +519,7 @@ public bool RecordTransientFailure(TimeProvider timeProvider, bool isHalfOpenPro } } - public void RecordResponseReceived() + public void Close() { lock (_lock) { diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 35848572..3b59c191 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -596,6 +596,74 @@ public async Task RetriesOnConnectionResetThenSucceeds() 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() { @@ -815,6 +883,25 @@ public async Task HalfOpenProbeClosesCircuitAfterCooldown() 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() { @@ -866,6 +953,110 @@ await Assert.ThrowsAsync(() => 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() {