diff --git a/.changeset/quiet-flags-retry.md b/.changeset/quiet-flags-retry.md new file mode 100644 index 0000000..ad7128d --- /dev/null +++ b/.changeset/quiet-flags-retry.md @@ -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. diff --git a/lib/Client.php b/lib/Client.php index 47b8bcf..0fe816b 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -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. * @@ -130,9 +148,14 @@ 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, @@ -140,6 +163,7 @@ class Client implements FeatureFlagEvaluationsHost * 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, @@ -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 = []; @@ -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(); @@ -1707,6 +1722,50 @@ private function requestFlags( return $this->normalizeFeatureFlags($httpResponse->getResponse()); } + /** + * @param array $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, featureFlagPayloads: array, flags: array} */ private function emptyFlagsResponse(): array { diff --git a/lib/HttpClient.php b/lib/HttpClient.php index bbf78fd..6eeb14c 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -22,9 +22,9 @@ class HttpClient private $useSsl; /** - * @var int + * @var int Maximum retry backoff duration in milliseconds. */ - private $maximumBackoffDuration; + private $maximumBackoffDurationMs; /** * @var bool @@ -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; @@ -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; } diff --git a/lib/PostHog.php b/lib/PostHog.php index 169efcb..36b890d 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -25,9 +25,14 @@ 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, @@ -35,6 +40,7 @@ class PostHog * 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, diff --git a/test/FeatureFlagTest.php b/test/FeatureFlagTest.php index 8fb6ec5..b51d067 100644 --- a/test/FeatureFlagTest.php +++ b/test/FeatureFlagTest.php @@ -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 */ diff --git a/test/MockedHttpClient.php b/test/MockedHttpClient.php index ccce706..0be72bb 100644 --- a/test/MockedHttpClient.php +++ b/test/MockedHttpClient.php @@ -24,6 +24,9 @@ class MockedHttpClient extends \PostHog\HttpClient /** @var int Curl error number for /flags/ endpoint (for error simulation) */ private $flagsEndpointCurlErrno; + /** @var array|null Queue of responses for sequential /flags/ calls */ + private $flagsEndpointResponseQueue; + private $batchEndpointResponse; private $batchEndpointResponseCode; private $batchEndpointCurlErrno; @@ -64,6 +67,7 @@ public function __construct( $this->flagEndpointResponseQueue = null; $this->flagsEndpointResponseCode = $flagsEndpointResponseCode; $this->flagsEndpointCurlErrno = $flagsEndpointCurlErrno; + $this->flagsEndpointResponseQueue = null; $this->batchEndpointResponse = $batchEndpointResponse; $this->batchEndpointResponseCode = $batchEndpointResponseCode; $this->batchEndpointCurlErrno = $batchEndpointCurlErrno; @@ -80,6 +84,17 @@ public function setFlagEndpointResponseQueue(array $responses): void $this->flagEndpointResponseQueue = $responses; } + /** + * Set a queue of responses for the /flags/ endpoint. + * Each call will consume the next response in the queue. + * + * @param array $responses Array of ['response' => array, 'responseCode' => int, 'curlErrno' => int] + */ + public function setFlagsEndpointResponseQueue(array $responses): void + { + $this->flagsEndpointResponseQueue = $responses; + } + // phpcs:ignore Generic.Files.LineLength.TooLong public function sendRequest(string $path, ?string $payload, array $extraHeaders = [], array $requestOptions = []): HttpResponse { @@ -124,6 +139,15 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders // Decide endpoint: /flags/?v=2 if (str_starts_with($path, "/flags/?")) { + if ($this->flagsEndpointResponseQueue !== null && !empty($this->flagsEndpointResponseQueue)) { + $nextResponse = array_shift($this->flagsEndpointResponseQueue); + $response = $nextResponse['response'] ?? []; + $responseCode = $nextResponse['responseCode'] ?? 200; + $curlErrno = $nextResponse['curlErrno'] ?? 0; + + return new HttpResponse(json_encode($response), $responseCode, null, $curlErrno); + } + return new HttpResponse( json_encode($this->flagsEndpointResponse), $this->flagsEndpointResponseCode,