Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gentle-flags-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-php': patch
---

Retry remote feature flag requests after transient 502 and 504 responses.
23 changes: 19 additions & 4 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ class Client implements FeatureFlagEvaluationsHost
* defaults to 5 seconds. For the socket consumer, `timeout` is passed to pfsockopen() and is
* in seconds.
*
* Feature flag requests to `/flags/?v=2` retry transient curl/network errors only.
* Feature flag requests to `/flags/?v=2` retry transient curl/network errors and HTTP 502/504.
* `feature_flag_request_max_retries` defaults to 1; set it to 0 to disable these retries.
*
* @param array{
Expand Down Expand Up @@ -1734,9 +1734,8 @@ private function sendFeatureFlagsRequest(array $payload): HttpResponse
);

if (
$httpResponse->getResponseCode() !== 0
|| $retries >= $this->featureFlagRequestMaxRetries
|| !$this->isRetryableFlagsCurlError($httpResponse->getCurlErrno())
$retries >= $this->featureFlagRequestMaxRetries
|| !$this->shouldRetryFeatureFlagsRequest($httpResponse)
) {
return $httpResponse;
}
Expand All @@ -1747,13 +1746,29 @@ private function sendFeatureFlagsRequest(array $payload): HttpResponse
}
}

private function shouldRetryFeatureFlagsRequest(HttpResponse $httpResponse): bool
{
$responseCode = $httpResponse->getResponseCode();

if ($responseCode === 0) {
return $this->isRetryableFlagsCurlError($httpResponse->getCurlErrno());
}

return $this->isRetryableFlagsStatusCode($responseCode);
}

private function isRetryableFlagsCurlError(int $curlErrno): bool
{
// Match Ruby's transient subset: timeouts, connection resets/receive failures,
// and empty replies/EOF. Do not retry refused connections or DNS failures.
return in_array($curlErrno, [28, 52, 56], true);
}

private function isRetryableFlagsStatusCode(int $statusCode): bool
{
return in_array($statusCode, [502, 504], true);
}

/** @return array{featureFlags: array<string, mixed>, featureFlagPayloads: array<string, mixed>, flags: array<string, mixed>} */
private function emptyFlagsResponse(): array
{
Expand Down
2 changes: 1 addition & 1 deletion lib/PostHog.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class PostHog
* defaults to 5 seconds. For the socket consumer, `timeout` is passed to pfsockopen() and is
* in seconds.
*
* Feature flag requests to `/flags/?v=2` retry transient curl/network errors only.
* Feature flag requests to `/flags/?v=2` retry transient curl/network errors and HTTP 502/504.
* `feature_flag_request_max_retries` defaults to 1; set it to 0 to disable these retries.
*
* @param array{
Expand Down
102 changes: 102 additions & 0 deletions test/FeatureFlagTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ public static function nonRetryableFlagsStatusCodes(): array
];
}

public static function retryableFlagsStatusCodes(): array
{
return [
'bad gateway' => [502],
'gateway timeout' => [504],
];
}

public static function retryableFlagsCurlErrors(): array
{
return [
Expand Down Expand Up @@ -233,6 +241,100 @@ public function testFlagsRequestDoesNotRetryHttpStatusErrors(int $statusCode): v
$this->assertSame('/flags/?v=2', $this->http_client->calls[0]['path']);
}

/**
* @dataProvider retryableFlagsStatusCodes
*/
public function testFlagsRequestRetriesHttp502And504(int $statusCode): void
{
$this->http_client = new MockedHttpClient("app.posthog.com");
$this->http_client->setFlagsEndpointResponseQueue([
['response' => [], 'responseCode' => $statusCode, 'curlErrno' => 0],
['response' => MockedResponses::FLAGS_RESPONSE, 'responseCode' => 200, 'curlErrno' => 0],
]);
$this->client = new Client(
self::FAKE_API_KEY,
[
"debug" => true,
"maximum_backoff_duration" => 101,
],
$this->http_client,
null
);

$response = $this->client->flags('user-id');

$this->assertTrue($response['featureFlags']['simpleFlag']);
$this->assertCount(2, $this->http_client->calls);
foreach ($this->http_client->calls as $call) {
$this->assertSame('/flags/?v=2', $call['path']);
$this->assertEquals(["timeout" => 3000, "shouldRetry" => false], $call['requestOptions']);
}
}

/**
* @dataProvider retryableFlagsStatusCodes
*/
public function testFlagsRequestStopsAfterExhaustingHttp502And504Retries(int $statusCode): void
{
$this->http_client = new MockedHttpClient("app.posthog.com");
$this->http_client->setFlagsEndpointResponseQueue([
['response' => [], 'responseCode' => $statusCode, 'curlErrno' => 0],
['response' => [], 'responseCode' => $statusCode, 'curlErrno' => 0],
['response' => [], 'responseCode' => $statusCode, 'curlErrno' => 0],
['response' => MockedResponses::FLAGS_RESPONSE, 'responseCode' => 200, 'curlErrno' => 0],
]);
$this->client = new Client(
self::FAKE_API_KEY,
[
"debug" => true,
"feature_flag_request_max_retries" => 2,
"maximum_backoff_duration" => 101,
],
$this->http_client,
null
);

$this->assertSame([
'featureFlags' => [],
'featureFlagPayloads' => [],
'flags' => [],
], $this->client->flags('user-id'));
$this->assertCount(3, $this->http_client->calls);
foreach ($this->http_client->calls as $call) {
$this->assertSame('/flags/?v=2', $call['path']);
$this->assertEquals(["timeout" => 3000, "shouldRetry" => false], $call['requestOptions']);
}
}

/**
* @dataProvider retryableFlagsStatusCodes
*/
public function testFlagsRequestDoesNotRetryHttp502And504WhenConfiguredMaxRetriesIsZero(int $statusCode): void
{
$this->http_client = new MockedHttpClient("app.posthog.com");
$this->http_client->setFlagsEndpointResponseQueue([
['response' => [], 'responseCode' => $statusCode, 'curlErrno' => 0],
['response' => MockedResponses::FLAGS_RESPONSE, 'responseCode' => 200, 'curlErrno' => 0],
]);
$this->client = new Client(
self::FAKE_API_KEY,
[
"debug" => true,
"feature_flag_request_max_retries" => 0,
"maximum_backoff_duration" => 101,
],
$this->http_client,
null
);

$this->assertSame([
'featureFlags' => [],
'featureFlagPayloads' => [],
'flags' => [],
], $this->client->flags('user-id'));
$this->assertCount(1, $this->http_client->calls);
}

/**
* @dataProvider decideResponseCases
*/
Comment thread
marandaneto marked this conversation as resolved.
Expand Down
Loading