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/quiet-flags-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-php': 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.
91 changes: 75 additions & 16 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,28 @@ class Client implements FeatureFlagEvaluationsHost
private $personalAPIKey;

/**
* Feature flag request timeout in milliseconds. Defaults to 3000ms.
*
* @var integer
*/
private $featureFlagsRequestTimeout;

/**
* Maximum number of retries for /flags/?v=2 transient network errors.
* Defaults to 1. Set to 0 to disable feature flag request retries.
*
* @var integer
*/
private $featureFlagRequestMaxRetries;

/**
* Maximum retry backoff duration in milliseconds. Defaults to 10000ms.
* Retry backoff starts at 100ms and doubles until capped by this value.
*
* @var integer
*/
private $maximumBackoffDurationMs;

/**
* Consumer object handles queueing and bundling requests to PostHog.
*
Expand Down Expand Up @@ -130,16 +148,22 @@ class Client implements FeatureFlagEvaluationsHost
* @param string|null $apiKey Your project API key. When omitted or empty, the client is disabled
* and uses the noop consumer.
* Time-based options use milliseconds unless the option name says otherwise:
* `timeout` and `maximum_backoff_duration` are in milliseconds for libcurl/HTTP requests,
* while `flush_interval_seconds` is in seconds. For the socket consumer, `timeout` is passed
* to pfsockopen() and is in seconds.
* `timeout` defaults to 10000ms, `feature_flag_request_timeout_ms` defaults to 3000ms,
* and `maximum_backoff_duration` defaults to 10000ms for retry backoff. Retry backoff starts
* at 100ms and doubles until capped by `maximum_backoff_duration`. `flush_interval_seconds`
* 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_request_max_retries` defaults to 1; set it to 0 to disable these retries.
*
* @param array{
* host?: string,
* ssl?: bool,
* timeout?: int|float,
* verify_batch_events_request?: bool,
* feature_flag_request_timeout_ms?: int,
* feature_flag_request_max_retries?: int,
* maximum_backoff_duration?: int,
* consumer?: 'socket'|'file'|'fork_curl'|'lib_curl'|'noop',
* debug?: bool,
Expand Down Expand Up @@ -189,16 +213,18 @@ public function __construct(
}
$Consumer = self::CONSUMERS[$this->options["consumer"] ?? "lib_curl"];
$this->consumer = new $Consumer($this->apiKey, $this->options, $httpClient);
$this->maximumBackoffDurationMs = (int) ($options['maximum_backoff_duration'] ?? 10000);
$this->httpClient = $httpClient !== null ? $httpClient : new HttpClient(
$this->options['host'],
$options['ssl'] ?? true,
(int) ($options['maximum_backoff_duration'] ?? 10000),
$this->maximumBackoffDurationMs,
false,
$options["debug"] ?? false,
null,
(int) ($options['timeout'] ?? 10000)
);
$this->featureFlagsRequestTimeout = (int) ($options['feature_flag_request_timeout_ms'] ?? 3000);
$this->featureFlagRequestMaxRetries = max(0, (int) ($options['feature_flag_request_max_retries'] ?? 1));
$this->featureFlags = [];
$this->groupTypeMapping = [];
$this->cohorts = [];
Expand Down Expand Up @@ -1658,18 +1684,7 @@ private function requestFlags(
$payload["flag_keys_to_evaluate"] = array_values($flagKeys);
}

$httpResponse = $this->httpClient->sendRequest(
'/flags/?v=2',
json_encode($payload),
[
// Send user agent in the form of {library_name}/{library_version} as per RFC 7231.
"User-Agent: " . PostHog::LIBRARY . "/" . PostHog::VERSION,
],
[
"shouldRetry" => false,
"timeout" => $this->featureFlagsRequestTimeout
]
);
$httpResponse = $this->sendFeatureFlagsRequest($payload);

$responseCode = $httpResponse->getResponseCode();
$curlErrno = $httpResponse->getCurlErrno();
Expand Down Expand Up @@ -1707,6 +1722,50 @@ private function requestFlags(
return $this->normalizeFeatureFlags($httpResponse->getResponse());
}

/**
* @param array<string, mixed> $payload
*/
private function sendFeatureFlagsRequest(array $payload): HttpResponse
{
$backoff = 100; // Set initial waiting time to 100ms
$requestPayload = json_encode($payload);
$retries = 0;

while (true) {
$httpResponse = $this->httpClient->sendRequest(
'/flags/?v=2',
$requestPayload,
[
// Send user agent in the form of {library_name}/{library_version} as per RFC 7231.
"User-Agent: " . PostHog::LIBRARY . "/" . PostHog::VERSION,
],
[
"shouldRetry" => false,
"timeout" => $this->featureFlagsRequestTimeout
]
);

if (
$httpResponse->getResponseCode() !== 0
|| $retries >= $this->featureFlagRequestMaxRetries
|| !$this->isRetryableFlagsCurlError($httpResponse->getCurlErrno())
) {
return $httpResponse;
}

$retries++;
usleep(min($backoff, $this->maximumBackoffDurationMs) * 1000);
$backoff = min($backoff * 2, $this->maximumBackoffDurationMs);
}
}

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);
}

/** @return array{featureFlags: array<string, mixed>, featureFlagPayloads: array<string, mixed>, flags: array<string, mixed>} */
private function emptyFlagsResponse(): array
{
Expand Down
8 changes: 4 additions & 4 deletions lib/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ class HttpClient
private $useSsl;

/**
* @var int
* @var int Maximum retry backoff duration in milliseconds.
*/
private $maximumBackoffDuration;
private $maximumBackoffDurationMs;

/**
* @var bool
Expand Down Expand Up @@ -67,7 +67,7 @@ public function __construct(
) {
$this->host = $host;
$this->useSsl = $useSsl;
$this->maximumBackoffDuration = $maximumBackoffDuration;
$this->maximumBackoffDurationMs = $maximumBackoffDuration;
$this->compressRequests = $compressRequests;
$this->debug = $debug;
$this->errorHandler = $errorHandler;
Expand Down Expand Up @@ -168,7 +168,7 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders
} else {
break; // no error
}
} while ($shouldRetry && $backoff < $this->maximumBackoffDuration);
} while ($shouldRetry && $backoff < $this->maximumBackoffDurationMs);

return $httpResponse;
}
Expand Down
12 changes: 9 additions & 3 deletions lib/PostHog.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,22 @@ class PostHog
*
* @param string|null $apiKey Your project API key.
* Time-based options use milliseconds unless the option name says otherwise:
* `timeout` and `maximum_backoff_duration` are in milliseconds for libcurl/HTTP requests,
* while `flush_interval_seconds` is in seconds. For the socket consumer, `timeout` is passed
* to pfsockopen() and is in seconds.
* `timeout` defaults to 10000ms, `feature_flag_request_timeout_ms` defaults to 3000ms,
* and `maximum_backoff_duration` defaults to 10000ms for retry backoff. Retry backoff starts
* at 100ms and doubles until capped by `maximum_backoff_duration`. `flush_interval_seconds`
* 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_request_max_retries` defaults to 1; set it to 0 to disable these retries.
*
* @param array{
* host?: string,
* ssl?: bool,
* timeout?: int|float,
* verify_batch_events_request?: bool,
* feature_flag_request_timeout_ms?: int,
* feature_flag_request_max_retries?: int,
* maximum_backoff_duration?: int,
* consumer?: 'socket'|'file'|'fork_curl'|'lib_curl'|'noop',
* debug?: bool,
Expand Down
161 changes: 161 additions & 0 deletions test/FeatureFlagTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,167 @@ public static function decideResponseCases(): array
];
}

public static function nonRetryableFlagsStatusCodes(): array
{
return [
'request timeout' => [408],
'rate limited' => [429],
'server error' => [500],
];
}

public static function retryableFlagsCurlErrors(): array
{
return [
'operation timed out' => [28],
'got nothing' => [52],
'receive error' => [56],
];
}

/**
* @dataProvider retryableFlagsCurlErrors
*/
public function testFlagsRequestRetriesTransientCurlErrors(int $curlErrno): void
{
$this->http_client = new MockedHttpClient("app.posthog.com");
$this->http_client->setFlagsEndpointResponseQueue([
['response' => [], 'responseCode' => 0, 'curlErrno' => $curlErrno],
['response' => [], 'responseCode' => 0, 'curlErrno' => $curlErrno],
['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
);

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

$this->assertTrue($response['featureFlags']['simpleFlag']);
$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 retryableFlagsCurlErrors
*/
public function testFlagsRequestStopsAfterExhaustingTransientCurlErrorRetries(int $curlErrno): void
{
$this->http_client = new MockedHttpClient("app.posthog.com");
$this->http_client->setFlagsEndpointResponseQueue([
['response' => [], 'responseCode' => 0, 'curlErrno' => $curlErrno],
['response' => [], 'responseCode' => 0, 'curlErrno' => $curlErrno],
['response' => [], 'responseCode' => 0, 'curlErrno' => $curlErrno],
['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);
}

public function testFlagsRequestDoesNotRetryWhenConfiguredMaxRetriesIsZero(): void
{
$this->http_client = new MockedHttpClient("app.posthog.com");
$this->http_client->setFlagsEndpointResponseQueue([
['response' => [], 'responseCode' => 0, 'curlErrno' => 28],
['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);
}

public function testFlagsRequestDoesNotRetryConnectionRefused(): void
{
$this->http_client = new MockedHttpClient("app.posthog.com");
$this->http_client->setFlagsEndpointResponseQueue([
['response' => [], 'responseCode' => 0, 'curlErrno' => 7],
['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
);

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

/**
* @dataProvider nonRetryableFlagsStatusCodes
*/
public function testFlagsRequestDoesNotRetryHttpStatusErrors(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
);

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

/**
* @dataProvider decideResponseCases
*/
Expand Down
Loading
Loading