From 7240b5e49429cdbdcf48e5768f0077af70963458 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 25 Jun 2026 08:41:58 +0200 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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);