diff --git a/README.md b/README.md
index 229dc7d..ea2750f 100644
--- a/README.md
+++ b/README.md
@@ -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.
+ ]),
]);
```
diff --git a/phpunit.xml b/phpunit.xml
index ec98877..bc2e592 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -7,10 +7,11 @@
src/tests/SDKConfigCacheTest.php
src/tests/APIExceptionMappingTest.php
src/tests/APIRetryTest.php
+ src/tests/APIHttpTimeoutTest.php
src/tests/StaticStateIsolationTest.php
src/tests/EndpointsTest.php
src/tests/Management/ManagementParityTest.php
src/tests/Auth/AuthParityTest.php
-
\ No newline at end of file
+
diff --git a/src/SDK/API.php b/src/SDK/API.php
index 584d8da..47894d8 100644
--- a/src/SDK/API.php
+++ b/src/SDK/API.php
@@ -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;
@@ -22,6 +24,7 @@ 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];
@@ -29,16 +32,24 @@ class API
/**
* 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();
@@ -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) {
@@ -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')) {
@@ -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')) {
@@ -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')) {
@@ -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')) {
@@ -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;
}
/**
diff --git a/src/SDK/Configuration/HttpClientConfig.php b/src/SDK/Configuration/HttpClientConfig.php
new file mode 100644
index 0000000..adee1b4
--- /dev/null
+++ b/src/SDK/Configuration/HttpClientConfig.php
@@ -0,0 +1,81 @@
+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;
+ }
+}
diff --git a/src/SDK/Configuration/SDKConfig.php b/src/SDK/Configuration/SDKConfig.php
index e8e089d..da8ac7a 100644
--- a/src/SDK/Configuration/SDKConfig.php
+++ b/src/SDK/Configuration/SDKConfig.php
@@ -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;
@@ -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);
@@ -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);
diff --git a/src/SDK/DescopeSDK.php b/src/SDK/DescopeSDK.php
index 63c9ff5..d966583 100644
--- a/src/SDK/DescopeSDK.php
+++ b/src/SDK/DescopeSDK.php
@@ -6,6 +6,7 @@
use Descope\SDK\Token\Extractor;
use Descope\SDK\Token\Verifier;
use Descope\SDK\Configuration\SDKConfig;
+use Descope\SDK\Configuration\HttpClientConfig;
use Descope\SDK\Auth\Password;
use Descope\SDK\Auth\SSO;
use Descope\SDK\Auth\OAuth;
@@ -19,8 +20,8 @@
use Descope\SDK\Exception\AuthException;
use Descope\SDK\Exception\RateLimitException;
use Descope\SDK\Exception\ValidationException;
-
use Descope\SDK\Management\MgmtV1;
+use GuzzleHttp\ClientInterface;
class DescopeSDK
{
@@ -45,6 +46,12 @@ public function __construct(array $config)
throw new \InvalidArgumentException('Please add a Descope Project ID to your .ENV file.');
}
+ $httpClientConfig = HttpClientConfig::fromArray($config);
+ $httpClient = $config['httpClient'] ?? null;
+ if ($httpClient !== null && !$httpClient instanceof ClientInterface) {
+ throw new \InvalidArgumentException('httpClient must implement GuzzleHttp\ClientInterface.');
+ }
+
// Set baseUrl for all endpoint classes - use manual baseUrl if provided, otherwise derive from projectId
if (isset($config['baseUrl']) && !empty($config['baseUrl'])) {
EndpointsV1::setBaseUrlFromString($config['baseUrl']);
@@ -54,11 +61,18 @@ public function __construct(array $config)
EndpointsV2::setBaseUrl($config['projectId']);
}
- $this->config = new SDKConfig($config);
+ $this->config = new SDKConfig($config, null, $httpClientConfig, $httpClient);
// Determine debug flag from config or environment variable
$debug = $config['debug'] ?? null;
- $this->api = new API($config['projectId'], $config['managementKey'] ?? '', $debug, $config['baseUrl'] ?? null);
+ $this->api = new API(
+ $config['projectId'],
+ $config['managementKey'] ?? '',
+ $debug,
+ $config['baseUrl'] ?? null,
+ $httpClientConfig,
+ $httpClient
+ );
// If OPTIONAL management key was provided in $config
if (!empty($config['managementKey'])) {
$this->management = new Management($this->api);
diff --git a/src/tests/APIHttpTimeoutTest.php b/src/tests/APIHttpTimeoutTest.php
new file mode 100644
index 0000000..68e2851
--- /dev/null
+++ b/src/tests/APIHttpTimeoutTest.php
@@ -0,0 +1,202 @@
+assertSame(60.0, $config->requestTimeout());
+ $this->assertSame(60.0, $config->requestTimeout(true));
+ $this->assertSame(10.0, $config->connectTimeout());
+ }
+
+ public function testAuthenticationRequestUsesConfiguredTimeouts(): void
+ {
+ $requests = [];
+ $api = $this->apiWithResponses(
+ new HttpClientConfig(8.0, 30.0, 2.0),
+ [new Response(200, [], json_encode(['ok' => true]))],
+ $requests
+ );
+
+ $this->assertSame(['ok' => true], $api->doGet('/v1/test', false));
+ $this->assertEqualsWithDelta(8.0, $requests[0]['options']['timeout'], 0.01);
+ $this->assertSame(2.0, $requests[0]['options']['connect_timeout']);
+ }
+
+ public function testManagementRequestUsesConfiguredOverride(): void
+ {
+ $requests = [];
+ $api = $this->apiWithResponses(
+ new HttpClientConfig(8.0, 45.0, 2.0),
+ [new Response(200, [], json_encode(['ok' => true]))],
+ $requests
+ );
+
+ $this->assertSame(['ok' => true], $api->doGet('/v1/test', true));
+ $this->assertEqualsWithDelta(45.0, $requests[0]['options']['timeout'], 0.01);
+ $this->assertSame(2.0, $requests[0]['options']['connect_timeout']);
+ }
+
+ public function testRetryReceivesOnlyTheRemainingOverallBudget(): void
+ {
+ $requests = [];
+ $api = $this->apiWithResponses(
+ new HttpClientConfig(8.0, null, 2.0),
+ [
+ $this->retryableException(503),
+ new Response(200, [], json_encode(['ok' => true])),
+ ],
+ $requests
+ );
+ $this->setRetryDelays($api, [100000]);
+
+ $this->assertSame(['ok' => true], $api->doGet('/v1/test', false));
+ $this->assertEqualsWithDelta(8.0, $requests[0]['options']['timeout'], 0.01);
+ $this->assertEqualsWithDelta(7.9, $requests[1]['options']['timeout'], 0.02);
+ }
+
+ public function testRetryIsSkippedWhenBackoffWouldExhaustTheBudget(): void
+ {
+ $requests = [];
+ $api = $this->apiWithResponses(
+ new HttpClientConfig(0.05, null, 0.01),
+ [
+ $this->retryableException(503),
+ new Response(200, [], json_encode(['ok' => true])),
+ ],
+ $requests
+ );
+ $this->setRetryDelays($api, [100000]);
+
+ try {
+ $api->doGet('/v1/test', false);
+ $this->fail('Expected the retryable response to be surfaced when the retry budget is exhausted.');
+ } catch (AuthException $e) {
+ $this->assertCount(1, $requests);
+ }
+ }
+
+ public function testJwksRequestUsesTheAuthenticationTimeout(): void
+ {
+ $requests = [];
+ $handlerStack = HandlerStack::create(new MockHandler([
+ new Response(200, [], json_encode(['keys' => []])),
+ ]));
+ $handlerStack->push(Middleware::history($requests));
+ $client = new Client(['handler' => $handlerStack]);
+ $httpClientConfig = new HttpClientConfig(12.0, 60.0, 3.0);
+ $config = new SDKConfig(
+ ['projectId' => 'project'],
+ null,
+ $httpClientConfig,
+ $client
+ );
+
+ $this->assertSame(['keys' => []], $config->getJWKSets());
+ $this->assertSame(12.0, $requests[0]['options']['timeout']);
+ $this->assertSame(3.0, $requests[0]['options']['connect_timeout']);
+ }
+
+ public function testSdkPassesTimeoutsAndCustomClientToApi(): void
+ {
+ $requests = [];
+ $handlerStack = HandlerStack::create(new MockHandler([
+ new Response(200, [], json_encode(['ok' => true])),
+ ]));
+ $handlerStack->push(Middleware::history($requests));
+ $client = new Client(['handler' => $handlerStack]);
+ $sdk = new DescopeSDK([
+ 'projectId' => 'project',
+ 'requestTimeout' => 8,
+ 'managementRequestTimeout' => 45,
+ 'connectTimeout' => 2,
+ 'httpClient' => $client,
+ ]);
+
+ $this->assertSame(['ok' => true], $sdk->api->doGet('/v1/test', false));
+ $this->assertEqualsWithDelta(8.0, $requests[0]['options']['timeout'], 0.01);
+ $this->assertSame(2.0, $requests[0]['options']['connect_timeout']);
+ }
+
+ /**
+ * @dataProvider invalidTimeoutProvider
+ */
+ public function testInvalidTimeoutConfigurationIsRejected(array $config): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ HttpClientConfig::fromArray($config);
+ }
+
+ public static function invalidTimeoutProvider(): array
+ {
+ return [
+ [['requestTimeout' => 0]],
+ [['requestTimeout' => -1]],
+ [['requestTimeout' => 'eight']],
+ [['managementRequestTimeout' => INF]],
+ [['connectTimeout' => 0]],
+ ];
+ }
+
+ public function testNumericStringsAreAcceptedForEnvironmentBasedConfiguration(): void
+ {
+ $config = HttpClientConfig::fromArray([
+ 'requestTimeout' => '8.5',
+ 'managementRequestTimeout' => '45',
+ 'connectTimeout' => '2',
+ ]);
+
+ $this->assertSame(8.5, $config->requestTimeout());
+ $this->assertSame(45.0, $config->requestTimeout(true));
+ $this->assertSame(2.0, $config->connectTimeout());
+ }
+
+ private function apiWithResponses(
+ HttpClientConfig $httpClientConfig,
+ array $responses,
+ array &$requests
+ ): API {
+ $handlerStack = HandlerStack::create(new MockHandler($responses));
+ $handlerStack->push(Middleware::history($requests));
+ $client = new Client(['handler' => $handlerStack]);
+
+ return new API('project', 'management-key', false, null, $httpClientConfig, $client);
+ }
+
+ 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 setRetryDelays(API $api, array $retryDelaysUs): void
+ {
+ $reflection = new ReflectionClass(API::class);
+ $retryDelays = $reflection->getProperty('retryDelaysUs');
+ $retryDelays->setAccessible(true);
+ $retryDelays->setValue($api, $retryDelaysUs);
+ }
+}