Skip to content

Commit c631799

Browse files
authored
fix: Retry flags requests on 502 and 504 (#255)
* fix: Retry flags requests on 502 and 504 * test: Cover flags gateway retry exhaustion * chore: add flags retry changeset
1 parent 43430cd commit c631799

4 files changed

Lines changed: 110 additions & 7 deletions

File tree

.changeset/gentle-flags-retry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'PostHog': patch
3+
'PostHog.AspNetCore': patch
4+
---
5+
6+
Retry remote feature flag requests after transient 502 and 504 responses.

src/PostHog/Config/PostHogOptions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ public string? ProjectApiKey
174174
public int MaxRetries { get; set; } = 3;
175175

176176
/// <summary>
177-
/// The maximum number of retries for feature flag requests after transient network errors. (Default: 1)
177+
/// The maximum number of retries for feature flag requests after transient network errors
178+
/// or HTTP 502/504 responses. (Default: 1)
178179
/// Set to 0 to disable feature flag request retries.
179180
/// </summary>
180181
public int FeatureFlagRequestMaxRetries { get; set; } = 1;

src/PostHog/Library/HttpClientExtensions.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ internal static class HttpClientExtensions
4646
}
4747

4848
/// <summary>
49-
/// Sends a POST request with retry logic only for network/transport failures and timeouts.
50-
/// Non-successful HTTP responses are not retried.
49+
/// Sends a POST request with retry logic only for network/transport failures, timeouts,
50+
/// and HTTP 502/504 responses.
5151
/// </summary>
5252
public static async Task<TBody?> PostJsonWithNetworkRetryAsync<TBody>(
5353
this HttpClient httpClient,
@@ -141,7 +141,13 @@ async Task<bool> ShouldRetryAfterTransientFailure()
141141
// be caught by the retry logic above.
142142
using (response)
143143
{
144-
if (isHalfOpenProbe || response.IsSuccessStatusCode)
144+
var isRetryableFlagsStatusCode = ShouldRetryFlagsStatusCode(response.StatusCode);
145+
if (isRetryableFlagsStatusCode && await ShouldRetryAfterTransientFailure())
146+
{
147+
continue;
148+
}
149+
150+
if ((isHalfOpenProbe && !isRetryableFlagsStatusCode) || response.IsSuccessStatusCode)
145151
{
146152
circuitBreaker.Close();
147153
}
@@ -156,6 +162,9 @@ async Task<bool> ShouldRetryAfterTransientFailure()
156162
}
157163
}
158164

165+
static bool ShouldRetryFlagsStatusCode(HttpStatusCode statusCode)
166+
=> statusCode is HttpStatusCode.BadGateway or HttpStatusCode.GatewayTimeout;
167+
159168
static bool IsRetryableFlagsHttpRequestException(HttpRequestException exception)
160169
{
161170
for (Exception? current = exception; current != null; current = current.InnerException)

tests/UnitTests/Library/HttpClientExtensionsTests.cs

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,14 +1153,101 @@ public async Task RetriesOnTaskCanceledExceptionFromTimeoutThenSucceeds()
11531153
Assert.Equal(2, handler.RequestCount);
11541154
}
11551155

1156+
[Theory]
1157+
[InlineData(HttpStatusCode.BadGateway)] // 502
1158+
[InlineData(HttpStatusCode.GatewayTimeout)] // 504
1159+
public async Task RetriesOnGatewayHttpStatusCodeThenSucceeds(HttpStatusCode statusCode)
1160+
{
1161+
var handler = new FakeRetryHttpMessageHandler();
1162+
handler.AddResponse(statusCode, new { type = "error", detail = "server error" });
1163+
handler.AddResponse(HttpStatusCode.OK, new { featureFlags = new { retry_flag = true } });
1164+
using var httpClient = CreateHttpClient(handler);
1165+
var options = CreateOptions();
1166+
var timeProvider = new FakeTimeProvider();
1167+
1168+
var task = httpClient.PostJsonWithNetworkRetryAsync<FlagsApiResult>(
1169+
FlagsUrl,
1170+
new { api_key = "test", distinct_id = "user-1" },
1171+
timeProvider,
1172+
options,
1173+
new FeatureFlagRequestCircuitBreaker(),
1174+
CancellationToken.None);
1175+
1176+
await handler.WaitForRequestCountAsync(1);
1177+
Assert.Equal(1, handler.RequestCount);
1178+
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
1179+
var result = await task;
1180+
1181+
Assert.NotNull(result);
1182+
Assert.NotNull(result!.FeatureFlags);
1183+
Assert.True(result.FeatureFlags!.TryGetValue("retry_flag", out var retryFlag));
1184+
Assert.True(retryFlag == true);
1185+
Assert.Equal(2, handler.RequestCount);
1186+
}
1187+
1188+
[Theory]
1189+
[InlineData(HttpStatusCode.BadGateway)] // 502
1190+
[InlineData(HttpStatusCode.GatewayTimeout)] // 504
1191+
public async Task DoesNotRetryGatewayHttpStatusCodeWhenFeatureFlagRequestMaxRetriesIsZero(HttpStatusCode statusCode)
1192+
{
1193+
var handler = new FakeRetryHttpMessageHandler();
1194+
handler.AddResponse(statusCode, new { type = "error", detail = "server error" });
1195+
handler.AddResponse(HttpStatusCode.OK, new { featureFlags = new { retry_flag = true } }); // Should never be reached
1196+
using var httpClient = CreateHttpClient(handler);
1197+
var options = CreateOptions(maxRetries: 0);
1198+
var timeProvider = new FakeTimeProvider();
1199+
1200+
await Assert.ThrowsAsync<ApiException>(() =>
1201+
httpClient.PostJsonWithNetworkRetryAsync<FlagsApiResult>(
1202+
FlagsUrl,
1203+
new { api_key = "test", distinct_id = "user-1" },
1204+
timeProvider,
1205+
options,
1206+
new FeatureFlagRequestCircuitBreaker(),
1207+
CancellationToken.None));
1208+
1209+
Assert.Equal(1, handler.RequestCount);
1210+
}
1211+
1212+
[Theory]
1213+
[InlineData(HttpStatusCode.BadGateway)] // 502
1214+
[InlineData(HttpStatusCode.GatewayTimeout)] // 504
1215+
public async Task ThrowsAfterFeatureFlagRequestMaxRetriesWhenGatewayHttpStatusCodesKeepFailing(
1216+
HttpStatusCode statusCode)
1217+
{
1218+
var handler = new FakeRetryHttpMessageHandler();
1219+
handler.AddResponse(statusCode, new { type = "error", detail = "server error" });
1220+
handler.AddResponse(statusCode, new { type = "error", detail = "server error" });
1221+
handler.AddResponse(statusCode, new { type = "error", detail = "server error" });
1222+
using var httpClient = CreateHttpClient(handler);
1223+
var options = CreateOptions(maxRetries: 2);
1224+
var timeProvider = new FakeTimeProvider();
1225+
1226+
var task = httpClient.PostJsonWithNetworkRetryAsync<FlagsApiResult>(
1227+
FlagsUrl,
1228+
new { api_key = "test", distinct_id = "user-1" },
1229+
timeProvider,
1230+
options,
1231+
new FeatureFlagRequestCircuitBreaker(),
1232+
CancellationToken.None);
1233+
1234+
for (var i = 1; i <= 3 && !task.IsCompleted; i++)
1235+
{
1236+
await handler.WaitForRequestCountAsync(i);
1237+
timeProvider.Advance(TimeSpan.FromSeconds(1));
1238+
}
1239+
1240+
var exception = await Assert.ThrowsAsync<ApiException>(() => task);
1241+
Assert.Equal(statusCode, exception.Status);
1242+
Assert.Equal(3, handler.RequestCount);
1243+
}
1244+
11561245
[Theory]
11571246
[InlineData(HttpStatusCode.RequestTimeout)] // 408
11581247
[InlineData(HttpStatusCode.TooManyRequests)] // 429
11591248
[InlineData(HttpStatusCode.InternalServerError)] // 500
1160-
[InlineData(HttpStatusCode.BadGateway)] // 502
11611249
[InlineData(HttpStatusCode.ServiceUnavailable)] // 503
1162-
[InlineData(HttpStatusCode.GatewayTimeout)] // 504
1163-
public async Task DoesNotRetryOnHttpErrorStatusCodes(HttpStatusCode statusCode)
1250+
public async Task DoesNotRetryOnOtherHttpErrorStatusCodes(HttpStatusCode statusCode)
11641251
{
11651252
var handler = new FakeRetryHttpMessageHandler();
11661253
handler.AddResponse(statusCode, new { type = "error", detail = "server error" });

0 commit comments

Comments
 (0)