Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<file>src/tests/DescopeSDKTest.php</file>
<file>src/tests/InMemoryCacheTest.php</file>
<file>src/tests/SDKConfigCacheTest.php</file>
<file>src/tests/APIExceptionMappingTest.php</file>
<file>src/tests/APIRetryTest.php</file>
</testsuite>
</testsuites>
</phpunit>
64 changes: 44 additions & 20 deletions src/SDK/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@

class API
{
private const RETRYABLE_STATUS_CODES = [503, 521, 522, 524, 530];
Comment thread
dorsha marked this conversation as resolved.

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.
*
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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.
Expand Down
143 changes: 143 additions & 0 deletions src/tests/APIRetryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace Descope\Tests;

use Descope\SDK\API;
use Descope\SDK\Exception\AuthException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

final class APIRetryTest extends TestCase
{
private function apiWithMockedClient(MockHandler $mockHandler): API
{
$handlerStack = HandlerStack::create($mockHandler);
$client = new Client(['handler' => $handlerStack]);
Comment thread
dorsha marked this conversation as resolved.

$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
Comment thread
dorsha marked this conversation as resolved.
{
$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);
}
}
Loading