From 4c2e56053383451a3cb27757e0530fcdc55fa2e0 Mon Sep 17 00:00:00 2001 From: dorsha Date: Mon, 23 Mar 2026 12:34:26 +0200 Subject: [PATCH 1/3] feat(http): retry requests on transient error status codes Retry on 503, 521, 522, 524, 530 with delays of 100ms, 5s, 5s (max 3 retries). Adds executeWithRetry() helper used by doPost, doGet, and doDelete. Delays are a protected array for easy test overriding. Co-Authored-By: Claude Sonnet 4.6 --- phpunit.xml | 2 + src/SDK/API.php | 48 +++++++++--- src/tests/APIRetryTest.php | 148 +++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 src/tests/APIRetryTest.php diff --git a/phpunit.xml b/phpunit.xml index 360e06d..886c438 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,6 +5,8 @@ src/tests/DescopeSDKTest.php src/tests/InMemoryCacheTest.php src/tests/SDKConfigCacheTest.php + src/tests/APIExceptionMappingTest.php + src/tests/APIRetryTest.php \ No newline at end of file diff --git a/src/SDK/API.php b/src/SDK/API.php index c58e0c5..f25b516 100644 --- a/src/SDK/API.php +++ b/src/SDK/API.php @@ -15,11 +15,16 @@ class API { + private const RETRYABLE_STATUS_CODES = [503, 521, 522, 524, 530]; + private $httpClient; private $projectId; private $managementKey; private $debug; + /** @var int[] Delays between retries in microseconds: 100ms, 5s, 5s */ + protected array $retryDelaysUs = [100_000, 5_000_000, 5_000_000]; + /** * Constructor for API class. * @@ -109,14 +114,14 @@ public function doPost(string $uri, array $body, ?bool $useManagementKey = false $body = $this->transformEmptyArraysToObjects($body); $jsonBody = empty($body) ? '{}' : json_encode($body); try { - $response = $this->httpClient->post( + $response = $this->executeWithRetry(fn() => $this->httpClient->post( $uri, [ 'headers' => $this->getHeaders($authToken), 'body' => $jsonBody, ] - ); - + )); + // Ensure the response is an object with getBody method if (!is_object($response) || !method_exists($response, 'getBody') || !method_exists($response, 'getHeader')) { throw new AuthException(500, 'internal error', 'Invalid response from API'); @@ -158,12 +163,12 @@ public function doGet(string $uri, bool $useManagementKey, ?string $refreshToken } try { - $response = $this->httpClient->get( + $response = $this->executeWithRetry(fn() => $this->httpClient->get( $uri, [ - 'headers' => $this->getHeaders($authToken), + 'headers' => $this->getHeaders($authToken), ] - ); + )); // Ensure the response is an object with getBody method if (!is_object($response) || !method_exists($response, 'getBody') || !method_exists($response, 'getHeader')) { @@ -198,12 +203,12 @@ public function doDelete(string $uri): array $authToken = $this->getAuthToken(true); try { - $response = $this->httpClient->delete( + $response = $this->executeWithRetry(fn() => $this->httpClient->delete( $uri, [ - 'headers' => $this->getHeaders($authToken), + 'headers' => $this->getHeaders($authToken), ] - ); + )); // Ensure the response is an object with getBody method if (!is_object($response) || !method_exists($response, 'getBody') || !method_exists($response, 'getHeader')) { @@ -244,6 +249,31 @@ public function generateJwtResponse(array $responseBody, ?string $refreshToken = return $jwtResponse; } + /** + * Executes an HTTP request callable, retrying on transient status codes + * (503, 521, 522, 524, 530) with delays of 100ms, 5s, 5s. + * Non-retryable RequestExceptions are re-thrown immediately. + * + * @param callable $requestFn Zero-argument callable that performs the Guzzle request. + * @return mixed Guzzle response on success. + * @throws RequestException On non-retryable errors or after all retries are exhausted. + */ + private function executeWithRetry(callable $requestFn): mixed + { + foreach ($this->retryDelaysUs as $delay) { + try { + return $requestFn(); + } catch (RequestException $e) { + $statusCode = $e->getResponse()?->getStatusCode() ?? 0; + if (!in_array($statusCode, self::RETRYABLE_STATUS_CODES, true)) { + throw $e; + } + usleep($delay); + } + } + return $requestFn(); + } + /** * Builds an AuthException or RateLimitException from a Guzzle RequestException. * Parses the response body for Descope error fields when present. diff --git a/src/tests/APIRetryTest.php b/src/tests/APIRetryTest.php new file mode 100644 index 0000000..d848ff6 --- /dev/null +++ b/src/tests/APIRetryTest.php @@ -0,0 +1,148 @@ + $handlerStack]); + + $api = new ZeroDelayAPI('project', null, false); + $reflection = new ReflectionClass($api); + $httpClientProp = $reflection->getProperty('httpClient'); + $httpClientProp->setAccessible(true); + $httpClientProp->setValue($api, $client); + + return $api; + } + + private function retryableException(int $statusCode): RequestException + { + $request = new Request('GET', 'https://example.com/test'); + $response = new Response($statusCode, [], ''); + return new RequestException('transient error', $request, $response); + } + + private function successResponse(): Response + { + return new Response(200, [], json_encode(['ok' => true])); + } + + /** + * @dataProvider retryableStatusCodeProvider + */ + public function testRetriesOnRetryableStatusCodeAndSucceedsOnSecondAttempt(int $statusCode): void + { + $api = $this->apiWithMockedClient(new MockHandler([ + $this->retryableException($statusCode), + $this->successResponse(), + ])); + + $result = $api->doGet('https://example.com/test', false); + $this->assertSame(['ok' => true], $result); + } + + public static function retryableStatusCodeProvider(): array + { + return [[503], [521], [522], [524], [530]]; + } + + public function testRetriesUpToThreeTimesAndThrowsOnExhaustion(): void + { + $api = $this->apiWithMockedClient(new MockHandler([ + $this->retryableException(503), + $this->retryableException(503), + $this->retryableException(503), + $this->retryableException(503), // 4th call = original + 3 retries + ])); + + $this->expectException(AuthException::class); + $api->doGet('https://example.com/test', false); + } + + public function testDoesNotRetryOnNonRetryableStatusCodes(): void + { + foreach ([400, 401, 403, 404, 500, 502] as $statusCode) { + $request = new Request('GET', 'https://example.com/test'); + $response = new Response($statusCode, [], ''); + $exception = new RequestException('error', $request, $response); + + // Only one exception queued — if retry happened it would fail with "No more responses" + $api = $this->apiWithMockedClient(new MockHandler([$exception])); + + try { + $api->doGet('https://example.com/test', false); + $this->fail("Expected exception for status $statusCode"); + } catch (AuthException $e) { + $this->assertSame($statusCode, $e->getStatusCode()); + } + } + } + + public function testRetryWorksForDoPost(): void + { + $api = $this->apiWithMockedClient(new MockHandler([ + $this->retryableException(503), + $this->successResponse(), + ])); + + $result = $api->doPost('https://example.com/test', []); + $this->assertSame(['ok' => true], $result); + } + + public function testRetryWorksForDoDelete(): void + { + $api = $this->apiWithMockedClient(new MockHandler([ + $this->retryableException(503), + $this->successResponse(), + ])); + + $result = $api->doDelete('https://example.com/test'); + $this->assertSame(['ok' => true], $result); + } + + public function testSucceedsImmediatelyWithNoRetry(): void + { + // Only one response queued — confirms no retry attempted + $api = $this->apiWithMockedClient(new MockHandler([ + $this->successResponse(), + ])); + + $result = $api->doGet('https://example.com/test', false); + $this->assertSame(['ok' => true], $result); + } + + public function testSucceedsOnThirdRetry(): void + { + $api = $this->apiWithMockedClient(new MockHandler([ + $this->retryableException(503), + $this->retryableException(522), + $this->retryableException(530), + $this->successResponse(), + ])); + + $result = $api->doGet('https://example.com/test', false); + $this->assertSame(['ok' => true], $result); + } +} From 76e9237f2aa6e6ab400614f1808a041fd092e889 Mon Sep 17 00:00:00 2001 From: dorsha Date: Mon, 23 Mar 2026 12:38:54 +0200 Subject: [PATCH 2/3] fix(tests): move ZeroDelayAPI inline via reflection, fix httpClient property lookup - Remove ZeroDelayAPI subclass (PSR2 requires one class per file) - Use ReflectionClass(API::class) to access private httpClient and protected retryDelaysUs on the parent class, matching the pattern used by APIExceptionMappingTest Co-Authored-By: Claude Sonnet 4.6 --- src/tests/APIRetryTest.php | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/tests/APIRetryTest.php b/src/tests/APIRetryTest.php index d848ff6..29516d9 100644 --- a/src/tests/APIRetryTest.php +++ b/src/tests/APIRetryTest.php @@ -13,27 +13,24 @@ use PHPUnit\Framework\TestCase; use ReflectionClass; -/** - * API subclass with zero retry delays to keep tests fast. - */ -class ZeroDelayAPI extends API -{ - protected array $retryDelaysUs = [0, 0, 0]; -} - final class APIRetryTest extends TestCase { - private function apiWithMockedClient(MockHandler $mockHandler): ZeroDelayAPI + private function apiWithMockedClient(MockHandler $mockHandler): API { $handlerStack = HandlerStack::create($mockHandler); $client = new Client(['handler' => $handlerStack]); - $api = new ZeroDelayAPI('project', null, false); - $reflection = new ReflectionClass($api); + $api = new API('project', null, false); + $reflection = new ReflectionClass(API::class); + $httpClientProp = $reflection->getProperty('httpClient'); $httpClientProp->setAccessible(true); $httpClientProp->setValue($api, $client); + $retryDelaysProp = $reflection->getProperty('retryDelaysUs'); + $retryDelaysProp->setAccessible(true); + $retryDelaysProp->setValue($api, [0, 0, 0]); + return $api; } @@ -74,7 +71,7 @@ public function testRetriesUpToThreeTimesAndThrowsOnExhaustion(): void $this->retryableException(503), $this->retryableException(503), $this->retryableException(503), - $this->retryableException(503), // 4th call = original + 3 retries + $this->retryableException(503), ])); $this->expectException(AuthException::class); @@ -88,7 +85,6 @@ public function testDoesNotRetryOnNonRetryableStatusCodes(): void $response = new Response($statusCode, [], ''); $exception = new RequestException('error', $request, $response); - // Only one exception queued — if retry happened it would fail with "No more responses" $api = $this->apiWithMockedClient(new MockHandler([$exception])); try { @@ -124,7 +120,6 @@ public function testRetryWorksForDoDelete(): void public function testSucceedsImmediatelyWithNoRetry(): void { - // Only one response queued — confirms no retry attempted $api = $this->apiWithMockedClient(new MockHandler([ $this->successResponse(), ])); From de12b421bbd5dd74f4ee7c32ac4143c7db4428bf Mon Sep 17 00:00:00 2001 From: dorsha Date: Mon, 23 Mar 2026 15:09:20 +0200 Subject: [PATCH 3/3] fix: PHP 7.3 compatibility and test assertion fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove typed property declaration (array) and numeric separators from retryDelaysUs — requires PHP 7.4+ - Remove mixed return type from executeWithRetry — requires PHP 8.0+ - Replace nullsafe operator (?->) with explicit null check — requires PHP 8.0+ - Replace arrow functions (fn() =>) with traditional closures — requires PHP 7.4+ - Fix testDoesNotRetryOnNonRetryableStatusCodes: assert via (string)$e instead of getStatusCode() which does not exist on AuthException Co-Authored-By: Claude Sonnet 4.6 --- src/SDK/API.php | 38 ++++++++++++++++---------------------- src/tests/APIRetryTest.php | 2 +- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/SDK/API.php b/src/SDK/API.php index f25b516..8f616b4 100644 --- a/src/SDK/API.php +++ b/src/SDK/API.php @@ -23,7 +23,7 @@ class API private $debug; /** @var int[] Delays between retries in microseconds: 100ms, 5s, 5s */ - protected array $retryDelaysUs = [100_000, 5_000_000, 5_000_000]; + protected $retryDelaysUs = [100000, 5000000, 5000000]; /** * Constructor for API class. @@ -114,13 +114,10 @@ public function doPost(string $uri, array $body, ?bool $useManagementKey = false $body = $this->transformEmptyArraysToObjects($body); $jsonBody = empty($body) ? '{}' : json_encode($body); try { - $response = $this->executeWithRetry(fn() => $this->httpClient->post( - $uri, - [ - 'headers' => $this->getHeaders($authToken), - 'body' => $jsonBody, - ] - )); + $headers = $this->getHeaders($authToken); + $response = $this->executeWithRetry(function () use ($uri, $jsonBody, $headers) { + return $this->httpClient->post($uri, ['headers' => $headers, 'body' => $jsonBody]); + }); // Ensure the response is an object with getBody method if (!is_object($response) || !method_exists($response, 'getBody') || !method_exists($response, 'getHeader')) { @@ -163,12 +160,10 @@ public function doGet(string $uri, bool $useManagementKey, ?string $refreshToken } try { - $response = $this->executeWithRetry(fn() => $this->httpClient->get( - $uri, - [ - 'headers' => $this->getHeaders($authToken), - ] - )); + $headers = $this->getHeaders($authToken); + $response = $this->executeWithRetry(function () use ($uri, $headers) { + return $this->httpClient->get($uri, ['headers' => $headers]); + }); // Ensure the response is an object with getBody method if (!is_object($response) || !method_exists($response, 'getBody') || !method_exists($response, 'getHeader')) { @@ -203,12 +198,10 @@ public function doDelete(string $uri): array $authToken = $this->getAuthToken(true); try { - $response = $this->executeWithRetry(fn() => $this->httpClient->delete( - $uri, - [ - 'headers' => $this->getHeaders($authToken), - ] - )); + $headers = $this->getHeaders($authToken); + $response = $this->executeWithRetry(function () use ($uri, $headers) { + return $this->httpClient->delete($uri, ['headers' => $headers]); + }); // Ensure the response is an object with getBody method if (!is_object($response) || !method_exists($response, 'getBody') || !method_exists($response, 'getHeader')) { @@ -258,13 +251,14 @@ public function generateJwtResponse(array $responseBody, ?string $refreshToken = * @return mixed Guzzle response on success. * @throws RequestException On non-retryable errors or after all retries are exhausted. */ - private function executeWithRetry(callable $requestFn): mixed + private function executeWithRetry(callable $requestFn) { foreach ($this->retryDelaysUs as $delay) { try { return $requestFn(); } catch (RequestException $e) { - $statusCode = $e->getResponse()?->getStatusCode() ?? 0; + $response = $e->getResponse(); + $statusCode = $response ? $response->getStatusCode() : 0; if (!in_array($statusCode, self::RETRYABLE_STATUS_CODES, true)) { throw $e; } diff --git a/src/tests/APIRetryTest.php b/src/tests/APIRetryTest.php index 29516d9..ef367e0 100644 --- a/src/tests/APIRetryTest.php +++ b/src/tests/APIRetryTest.php @@ -91,7 +91,7 @@ public function testDoesNotRetryOnNonRetryableStatusCodes(): void $api->doGet('https://example.com/test', false); $this->fail("Expected exception for status $statusCode"); } catch (AuthException $e) { - $this->assertSame($statusCode, $e->getStatusCode()); + $this->assertStringContainsString((string) $statusCode, (string) $e); } } }