Skip to content
Closed
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
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,49 @@ use Descope\SDK\DescopeSDK;
$descopeSDK = new DescopeSDK([
'projectId' => $_ENV['DESCOPE_PROJECT_ID'],
'managementKey' => $_ENV['DESCOPE_MANAGEMENT_KEY'], // Optional, only used for Management functions
'debug' => false // Optional, enables verbose error logging (default: false)
'debug' => false, // Optional, enables verbose error logging (default: false)
'requestTimeout' => 60, // Optional, overall authentication request deadline in seconds
'managementRequestTimeout' => 60, // Optional, management request deadline; defaults to requestTimeout
'connectTimeout' => 10, // Optional, connection timeout per attempt in seconds
]);
```

### HTTP timeouts

All HTTP calls made by the SDK have bounded connection and request times. The
defaults are 10 seconds to connect and 60 seconds for the complete SDK request.
`requestTimeout` is an end-to-end deadline: the initial request, retry backoff,
and all retry attempts share the same budget.

Management operations can legitimately take longer than authentication
operations, so `managementRequestTimeout` can be configured independently. It
defaults to `requestTimeout` when omitted.

For example, an application can fail authentication requests quickly without
applying the same deadline to management operations:

```php
$descopeSDK = new DescopeSDK([
'projectId' => $_ENV['DESCOPE_PROJECT_ID'],
'managementKey' => $_ENV['DESCOPE_MANAGEMENT_KEY'],
'requestTimeout' => 8,
'managementRequestTimeout' => 60,
'connectTimeout' => 2,
]);
```

All timeout values are positive numbers of seconds and may be fractional. An
optional Guzzle-compatible client can also be supplied as `httpClient`; the SDK
still applies the configured timeout options to each request:

```php
use GuzzleHttp\Client;

$descopeSDK = new DescopeSDK([
'projectId' => $_ENV['DESCOPE_PROJECT_ID'],
'httpClient' => new Client([
// Custom handler, proxy, TLS, or other transport configuration.
]),
]);
```

Expand Down
3 changes: 2 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
<file>src/tests/SDKConfigCacheTest.php</file>
<file>src/tests/APIExceptionMappingTest.php</file>
<file>src/tests/APIRetryTest.php</file>
<file>src/tests/APIHttpTimeoutTest.php</file>
<file>src/tests/StaticStateIsolationTest.php</file>
<file>src/tests/EndpointsTest.php</file>
<file>src/tests/Management/ManagementParityTest.php</file>
<file>src/tests/Auth/AuthParityTest.php</file>
</testsuite>
</testsuites>
</phpunit>
</phpunit>
109 changes: 84 additions & 25 deletions src/SDK/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
namespace Descope\SDK;

use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Descope\SDK\Configuration\HttpClientConfig;
use Descope\SDK\Exception\AuthException;
use Descope\SDK\Exception\DescopeException;
use Descope\SDK\Exception\RateLimitException;
Expand All @@ -22,23 +24,32 @@ class API
private $managementKey;
private $baseUrl;
private $debug;
private $httpClientConfig;

/** @var int[] Delays between retries in microseconds: 100ms, 5s, 5s */
protected $retryDelaysUs = [100000, 5000000, 5000000];

/**
* Constructor for API class.
*
* @param string $projectId
* @param string|null $managementKey Management key for authentication.
* @param bool|null $debug Enable debug/verbose logging. If null, checks DESCOPE_DEBUG env var.
* @param string|null $baseUrl Optional explicit base URL override (cluster/region).
* @param string $projectId
* @param string|null $managementKey Management key for authentication.
* @param bool|null $debug Enable verbose logging. If null, checks DESCOPE_DEBUG.
* @param string|null $baseUrl Optional explicit base URL override (cluster/region).
* @param HttpClientConfig|null $httpClientConfig HTTP timeout configuration.
* @param ClientInterface|null $httpClient Optional custom Guzzle-compatible HTTP client.
*/
public function __construct(string $projectId, ?string $managementKey, ?bool $debug = null, ?string $baseUrl = null)
{
$this->httpClient = new Client();

if (!empty($_ENV['DESCOPE_LOG_PATH'])) {
public function __construct(
string $projectId,
?string $managementKey,
?bool $debug = null,
?string $baseUrl = null,
?HttpClientConfig $httpClientConfig = null,
?ClientInterface $httpClient = null
) {
if ($httpClient !== null) {
$this->httpClient = $httpClient;
} elseif (!empty($_ENV['DESCOPE_LOG_PATH'])) {
$log = new Logger('descope_guzzle_log');
$log->pushHandler(new StreamHandler($_ENV['DESCOPE_LOG_PATH'], Logger::DEBUG));
$stack = HandlerStack::create();
Expand All @@ -56,6 +67,7 @@ public function __construct(string $projectId, ?string $managementKey, ?bool $de
$this->projectId = $projectId;
$this->managementKey = $managementKey ?? '';
$this->baseUrl = EndpointsV1::resolveBaseUrl($projectId, $baseUrl);
$this->httpClientConfig = $httpClientConfig ?? new HttpClientConfig();

// Set debug flag from parameter, environment variable, or default to false
if ($debug !== null) {
Expand Down Expand Up @@ -120,9 +132,15 @@ public function doPost(string $uri, array $body, ?bool $useManagementKey = false
$jsonBody = empty($body) ? '{}' : json_encode($body);
try {
$headers = $this->getHeaders($authToken);
$response = $this->executeWithRetry(function () use ($uri, $jsonBody, $headers) {
return $this->httpClient->post($uri, ['headers' => $headers, 'body' => $jsonBody]);
});
$response = $this->executeWithRetry(
function (array $requestOptions) use ($uri, $jsonBody, $headers) {
return $this->httpClient->post(
$uri,
array_merge($requestOptions, ['headers' => $headers, 'body' => $jsonBody])
);
},
(bool) $useManagementKey
);

// 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 @@ -171,9 +189,15 @@ public function doPatch(string $uri, array $body, ?bool $useManagementKey = fals
$jsonBody = empty($body) ? '{}' : json_encode($body);
try {
$headers = $this->getHeaders($authToken);
$response = $this->executeWithRetry(function () use ($uri, $jsonBody, $headers) {
return $this->httpClient->patch($uri, ['headers' => $headers, 'body' => $jsonBody]);
});
$response = $this->executeWithRetry(
function (array $requestOptions) use ($uri, $jsonBody, $headers) {
return $this->httpClient->patch(
$uri,
array_merge($requestOptions, ['headers' => $headers, 'body' => $jsonBody])
);
},
(bool) $useManagementKey
);

// 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 @@ -219,9 +243,12 @@ public function doGet(string $uri, bool $useManagementKey, ?string $refreshToken

try {
$headers = $this->getHeaders($authToken);
$response = $this->executeWithRetry(function () use ($uri, $headers) {
return $this->httpClient->get($uri, ['headers' => $headers]);
});
$response = $this->executeWithRetry(
function (array $requestOptions) use ($uri, $headers) {
return $this->httpClient->get($uri, array_merge($requestOptions, ['headers' => $headers]));
},
$useManagementKey
);

// 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 @@ -259,9 +286,12 @@ public function doDelete(string $uri): array

try {
$headers = $this->getHeaders($authToken);
$response = $this->executeWithRetry(function () use ($uri, $headers) {
return $this->httpClient->delete($uri, ['headers' => $headers]);
});
$response = $this->executeWithRetry(
function (array $requestOptions) use ($uri, $headers) {
return $this->httpClient->delete($uri, array_merge($requestOptions, ['headers' => $headers]));
},
true
);

// 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 @@ -307,25 +337,54 @@ public function generateJwtResponse(array $responseBody, ?string $refreshToken =
* (503, 520, 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.
* The configured request timeout is an end-to-end deadline which includes
* the time spent waiting between attempts. Each attempt receives the
* remaining budget as its Guzzle timeout.
*
* @param callable $requestFn Callable that receives Guzzle request options.
* @param bool $managementRequest Whether to use the management request timeout.
* @return mixed Guzzle response on success.
* @throws RequestException On non-retryable errors or after all retries are exhausted.
*/
private function executeWithRetry(callable $requestFn)
private function executeWithRetry(callable $requestFn, bool $managementRequest = false)
{
$deadline = $this->currentTime() + $this->httpClientConfig->requestTimeout($managementRequest);
$lastException = null;

foreach ($this->retryDelaysUs as $delay) {
$remainingTime = $deadline - $this->currentTime();
if ($remainingTime <= 0 && $lastException !== null) {
throw $lastException;
}

try {
return $requestFn();
return $requestFn($this->httpClientConfig->requestOptions($remainingTime));
} catch (RequestException $e) {
$response = $e->getResponse();
$statusCode = $response ? $response->getStatusCode() : 0;
if (!in_array($statusCode, self::RETRYABLE_STATUS_CODES, true)) {
throw $e;
}
$lastException = $e;

if (($delay / 1000000) >= ($deadline - $this->currentTime())) {
throw $e;
}
usleep($delay);
}
}
return $requestFn();

$remainingTime = $deadline - $this->currentTime();
if ($remainingTime <= 0 && $lastException !== null) {
throw $lastException;
}

return $requestFn($this->httpClientConfig->requestOptions($remainingTime));
}

private function currentTime(): float
{
return hrtime(true) / 1000000000;
}

/**
Expand Down
81 changes: 81 additions & 0 deletions src/SDK/Configuration/HttpClientConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Descope\SDK\Configuration;

final class HttpClientConfig
{
public const DEFAULT_REQUEST_TIMEOUT_SECONDS = 60.0;
public const DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0;

private $requestTimeout;
private $managementRequestTimeout;
private $connectTimeout;

public function __construct(
float $requestTimeout = self::DEFAULT_REQUEST_TIMEOUT_SECONDS,
?float $managementRequestTimeout = null,
float $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT_SECONDS
) {
$this->requestTimeout = self::validateTimeout('requestTimeout', $requestTimeout);
$this->managementRequestTimeout = self::validateTimeout(
'managementRequestTimeout',
$managementRequestTimeout ?? $requestTimeout
);
$this->connectTimeout = self::validateTimeout('connectTimeout', $connectTimeout);
}

public static function fromArray(array $config): self
{
return new self(
self::configValue($config, 'requestTimeout', self::DEFAULT_REQUEST_TIMEOUT_SECONDS),
isset($config['managementRequestTimeout'])
? self::configValue($config, 'managementRequestTimeout')
: null,
self::configValue($config, 'connectTimeout', self::DEFAULT_CONNECT_TIMEOUT_SECONDS)
);
}

public function requestTimeout(bool $managementRequest = false): float
{
return $managementRequest ? $this->managementRequestTimeout : $this->requestTimeout;
}

public function connectTimeout(): float
{
return $this->connectTimeout;
}

public function requestOptions(float $remainingTime): array
{
$remainingTime = max(0.001, $remainingTime);

return [
'timeout' => $remainingTime,
'connect_timeout' => min($this->connectTimeout(), $remainingTime),
];
}

private static function configValue(array $config, string $key, ?float $default = null): float
{
$value = $config[$key] ?? $default;
if (is_string($value) && is_numeric($value)) {
$value = (float) $value;
}
if (!is_int($value) && !is_float($value)) {
throw new \InvalidArgumentException(sprintf('%s must be a positive number of seconds.', $key));
}

return (float) $value;
}

private static function validateTimeout(string $name, float $timeout): float
{
if (!is_finite($timeout) || $timeout <= 0) {
throw new \InvalidArgumentException(sprintf('%s must be a positive number of seconds.', $name));
}

return $timeout;
}
}
20 changes: 16 additions & 4 deletions src/SDK/Configuration/SDKConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Descope\SDK\Configuration;

use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
use Descope\SDK\EndpointsV1;
Expand All @@ -19,12 +20,18 @@ final class SDKConfig
public $baseUrl;
private $cache;
private $jwksCacheTTL;
private $httpClientConfig;
private const JWKS_CACHE_KEY_PREFIX = 'descope_jwks:v2:';
private const DEFAULT_JWKS_TTL = 600; // 10 minutes for faster key rotation discovery

public function __construct(array $config, ?CacheInterface $cache = null)
{
$this->client = new Client();
public function __construct(
array $config,
?CacheInterface $cache = null,
?HttpClientConfig $httpClientConfig = null,
?ClientInterface $httpClient = null
) {
$this->client = $httpClient ?? new Client();
$this->httpClientConfig = $httpClientConfig ?? HttpClientConfig::fromArray($config);
$this->projectId = $config['projectId'];
$this->managementKey = $config['managementKey'] ?? '';
$this->baseUrl = EndpointsV1::resolveBaseUrl($this->projectId, $config['baseUrl'] ?? null);
Expand Down Expand Up @@ -79,7 +86,12 @@ private function fetchJWKSets(): array
try {
$url = rtrim($this->baseUrl, '/') . '/v2/keys/' . rawurlencode($this->projectId);
$response = $this->client->request('GET', $url, [
'headers' => $this->getSDKHeaders()
'headers' => $this->getSDKHeaders(),
'timeout' => $this->httpClientConfig->requestTimeout(),
'connect_timeout' => min(
$this->httpClientConfig->connectTimeout(),
$this->httpClientConfig->requestTimeout()
),
]);
$jwkSets = json_decode($response->getBody(), true);

Expand Down
Loading