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