diff --git a/.changeset/gentle-flags-retry.md b/.changeset/gentle-flags-retry.md new file mode 100644 index 0000000..8072030 --- /dev/null +++ b/.changeset/gentle-flags-retry.md @@ -0,0 +1,5 @@ +--- +'posthog-php': patch +--- + +Retry remote feature flag requests after transient 502 and 504 responses. diff --git a/lib/Client.php b/lib/Client.php index 4e4b40c..57fee93 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -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{ @@ -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; } @@ -1747,6 +1746,17 @@ 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, @@ -1754,6 +1764,11 @@ private function isRetryableFlagsCurlError(int $curlErrno): bool 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, featureFlagPayloads: array, flags: array} */ private function emptyFlagsResponse(): array { diff --git a/lib/PostHog.php b/lib/PostHog.php index 33a9635..60b823f 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -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{ diff --git a/test/FeatureFlagTest.php b/test/FeatureFlagTest.php index 9c699f3..f7aea60 100644 --- a/test/FeatureFlagTest.php +++ b/test/FeatureFlagTest.php @@ -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 [ @@ -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 */