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..8f616b4 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 $retryDelaysUs = [100000, 5000000, 5000000]; + /** * Constructor for API class. * @@ -109,14 +114,11 @@ 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( - $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')) { throw new AuthException(500, 'internal error', 'Invalid response from API'); @@ -158,12 +160,10 @@ public function doGet(string $uri, bool $useManagementKey, ?string $refreshToken } try { - $response = $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')) { @@ -198,12 +198,10 @@ public function doDelete(string $uri): array $authToken = $this->getAuthToken(true); try { - $response = $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')) { @@ -244,6 +242,32 @@ 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) + { + foreach ($this->retryDelaysUs as $delay) { + try { + return $requestFn(); + } catch (RequestException $e) { + $response = $e->getResponse(); + $statusCode = $response ? $response->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..ef367e0 --- /dev/null +++ b/src/tests/APIRetryTest.php @@ -0,0 +1,143 @@ + $handlerStack]); + + $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; + } + + 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), + ])); + + $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); + + $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->assertStringContainsString((string) $statusCode, (string) $e); + } + } + } + + 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 + { + $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); + } +}