diff --git a/README.md b/README.md
index 229dc7d..04034b3 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,41 @@ 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, HTTP request timeout in seconds (default: 60)
+]);
+```
+
+### HTTP Timeouts
+
+Every HTTP call the SDK makes is bounded so a slow or unresponsive network peer
+cannot hold a PHP worker indefinitely. By default each request times out after
+**60 seconds**, with a **10 second** connection-establishment timeout. Set
+`requestTimeout` (a positive number of seconds, may be fractional) to change the
+overall per-request deadline:
+
+```php
+$descopeSDK = new DescopeSDK([
+ 'projectId' => $_ENV['DESCOPE_PROJECT_ID'],
+ 'requestTimeout' => 10, // Fail requests that take longer than 10 seconds
+]);
+```
+
+For full control over the transport (proxies, TLS, custom handlers, or your own
+timeout strategy) you can supply a pre-configured Guzzle-compatible client as
+`httpClient`. When you do, the SDK uses it as-is and does **not** apply its own
+timeout settings, so configure timeouts on the client itself:
+
+```php
+use GuzzleHttp\Client;
+
+$descopeSDK = new DescopeSDK([
+ 'projectId' => $_ENV['DESCOPE_PROJECT_ID'],
+ 'httpClient' => new Client([
+ 'timeout' => 10,
+ 'connect_timeout' => 5,
+ // Custom handler, proxy, or other transport configuration.
+ ]),
]);
```
diff --git a/phpunit.xml b/phpunit.xml
index ec98877..59b32e5 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -7,6 +7,7 @@
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
diff --git a/src/SDK/API.php b/src/SDK/API.php
index 584d8da..a7f469c 100644
--- a/src/SDK/API.php
+++ b/src/SDK/API.php
@@ -5,6 +5,7 @@
namespace Descope\SDK;
use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Descope\SDK\Exception\AuthException;
@@ -17,6 +18,12 @@ class API
{
private const RETRYABLE_STATUS_CODES = [503, 520, 521, 522, 524, 530];
+ /** Overall request timeout in seconds. Matches the Go and Python SDK defaults. */
+ public const DEFAULT_REQUEST_TIMEOUT_SECONDS = 60.0;
+
+ /** Connection-establishment timeout in seconds, applied to every request. */
+ public const DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0;
+
private $httpClient;
private $projectId;
private $managementKey;
@@ -29,16 +36,31 @@ 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 debug/verbose logging. If null, checks DESCOPE_DEBUG env var.
+ * @param string|null $baseUrl Optional explicit base URL override (cluster/region).
+ * @param float|null $requestTimeout Overall request timeout in seconds. Defaults to 60.
+ * @param ClientInterface|null $httpClient Optional pre-configured Guzzle client. When supplied its own
+ * transport options (including timeouts) are respected as-is.
*/
- public function __construct(string $projectId, ?string $managementKey, ?bool $debug = null, ?string $baseUrl = null)
- {
- $this->httpClient = new Client();
+ public function __construct(
+ string $projectId,
+ ?string $managementKey,
+ ?bool $debug = null,
+ ?string $baseUrl = null,
+ ?float $requestTimeout = null,
+ ?ClientInterface $httpClient = null
+ ) {
+ $clientOptions = [
+ 'timeout' => $requestTimeout ?? self::DEFAULT_REQUEST_TIMEOUT_SECONDS,
+ 'connect_timeout' => self::DEFAULT_CONNECT_TIMEOUT_SECONDS,
+ ];
- if (!empty($_ENV['DESCOPE_LOG_PATH'])) {
+ if ($httpClient !== null) {
+ // Respect a caller-supplied client and its own transport configuration.
+ $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();
@@ -48,9 +70,9 @@ public function __construct(string $projectId, ?string $managementKey, ?bool $de
new MessageFormatter(MessageFormatter::DEBUG)
)
);
- $this->httpClient = new Client(['handler' => $stack]);
+ $this->httpClient = new Client($clientOptions + ['handler' => $stack]);
} else {
- $this->httpClient = new Client();
+ $this->httpClient = new Client($clientOptions);
}
$this->projectId = $projectId;
diff --git a/src/SDK/Configuration/SDKConfig.php b/src/SDK/Configuration/SDKConfig.php
index e8e089d..b5a093b 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;
@@ -22,9 +23,21 @@ final class SDKConfig
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,
+ ?float $requestTimeout = null,
+ ?ClientInterface $httpClient = null
+ ) {
+ if ($httpClient !== null) {
+ // Respect a caller-supplied client and its own transport configuration.
+ $this->client = $httpClient;
+ } else {
+ $this->client = new Client([
+ 'timeout' => $requestTimeout ?? API::DEFAULT_REQUEST_TIMEOUT_SECONDS,
+ 'connect_timeout' => API::DEFAULT_CONNECT_TIMEOUT_SECONDS,
+ ]);
+ }
$this->projectId = $config['projectId'];
$this->managementKey = $config['managementKey'] ?? '';
$this->baseUrl = EndpointsV1::resolveBaseUrl($this->projectId, $config['baseUrl'] ?? null);
diff --git a/src/SDK/DescopeSDK.php b/src/SDK/DescopeSDK.php
index 63c9ff5..f426664 100644
--- a/src/SDK/DescopeSDK.php
+++ b/src/SDK/DescopeSDK.php
@@ -21,6 +21,7 @@
use Descope\SDK\Exception\ValidationException;
use Descope\SDK\Management\MgmtV1;
+use GuzzleHttp\ClientInterface;
class DescopeSDK
{
@@ -45,6 +46,13 @@ public function __construct(array $config)
throw new \InvalidArgumentException('Please add a Descope Project ID to your .ENV file.');
}
+ $requestTimeout = $this->resolveRequestTimeout($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 +62,18 @@ public function __construct(array $config)
EndpointsV2::setBaseUrl($config['projectId']);
}
- $this->config = new SDKConfig($config);
+ $this->config = new SDKConfig($config, null, $requestTimeout, $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,
+ $requestTimeout,
+ $httpClient
+ );
// If OPTIONAL management key was provided in $config
if (!empty($config['managementKey'])) {
$this->management = new Management($this->api);
@@ -78,6 +93,32 @@ public function __construct(array $config)
$this->magicLink = new MagicLink($this->api);
}
+ /**
+ * Resolves the optional 'requestTimeout' config value into a positive number
+ * of seconds, falling back to the SDK default when it is not supplied.
+ *
+ * @param array $config The SDK configuration array.
+ * @return float The request timeout in seconds.
+ * @throws \InvalidArgumentException If provided but not a positive number.
+ */
+ private function resolveRequestTimeout(array $config): float
+ {
+ if (!isset($config['requestTimeout'])) {
+ return API::DEFAULT_REQUEST_TIMEOUT_SECONDS;
+ }
+
+ $value = $config['requestTimeout'];
+ if (is_string($value) && is_numeric($value)) {
+ $value = (float) $value;
+ }
+
+ if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value <= 0) {
+ throw new \InvalidArgumentException('requestTimeout must be a positive number of seconds.');
+ }
+
+ return (float) $value;
+ }
+
/**
* Verify if the JWT is valid and not expired.
*
diff --git a/src/tests/APIHttpTimeoutTest.php b/src/tests/APIHttpTimeoutTest.php
new file mode 100644
index 0000000..10b4f30
--- /dev/null
+++ b/src/tests/APIHttpTimeoutTest.php
@@ -0,0 +1,122 @@
+getProperty(
+ $target instanceof API ? 'httpClient' : 'client'
+ );
+ $property->setAccessible(true);
+ return $property->getValue($target);
+ }
+
+ public function testApiAppliesDefaultTimeoutsToItsClient(): void
+ {
+ $client = $this->httpClientOf(new API('project', null, false));
+
+ $this->assertSame(60.0, $client->getConfig('timeout'));
+ $this->assertSame(10.0, $client->getConfig('connect_timeout'));
+ }
+
+ public function testApiAppliesConfiguredRequestTimeout(): void
+ {
+ $client = $this->httpClientOf(new API('project', null, false, null, 8.0));
+
+ $this->assertSame(8.0, $client->getConfig('timeout'));
+ $this->assertSame(10.0, $client->getConfig('connect_timeout'));
+ }
+
+ public function testSdkConfigAppliesConfiguredRequestTimeout(): void
+ {
+ $client = $this->httpClientOf(new SDKConfig(['projectId' => 'project'], null, 8.0));
+
+ $this->assertSame(8.0, $client->getConfig('timeout'));
+ $this->assertSame(10.0, $client->getConfig('connect_timeout'));
+ }
+
+ public function testInjectedClientIsUsedVerbatimAndNotOverridden(): void
+ {
+ // A caller-supplied client keeps its own transport configuration.
+ $injected = new Client(['timeout' => 3.0]);
+
+ $api = new API('project', null, false, null, 8.0, $injected);
+ $this->assertSame($injected, $this->httpClientOf($api));
+ $this->assertSame(3.0, $this->httpClientOf($api)->getConfig('timeout'));
+
+ $sdkConfig = new SDKConfig(['projectId' => 'project'], null, 8.0, $injected);
+ $this->assertSame($injected, $this->httpClientOf($sdkConfig));
+ }
+
+ public function testInjectedClientStillPerformsRequests(): void
+ {
+ $mock = new MockHandler([new Response(200, [], json_encode(['ok' => true]))]);
+ $injected = new Client(['handler' => HandlerStack::create($mock)]);
+
+ $api = new API('project', null, false, null, null, $injected);
+
+ $this->assertSame(['ok' => true], $api->doGet('/v1/test', false));
+ }
+
+ public function testDescopeSdkPassesRequestTimeoutThroughToApi(): void
+ {
+ $sdk = new DescopeSDK(['projectId' => 'project', 'requestTimeout' => 12.5]);
+
+ $apiProp = (new ReflectionClass(DescopeSDK::class))->getProperty('api');
+ $apiProp->setAccessible(true);
+ $api = $apiProp->getValue($sdk);
+
+ $this->assertSame(12.5, $this->httpClientOf($api)->getConfig('timeout'));
+ }
+
+ public function testDescopeSdkAcceptsNumericStringTimeout(): void
+ {
+ $sdk = new DescopeSDK(['projectId' => 'project', 'requestTimeout' => '15']);
+
+ $apiProp = (new ReflectionClass(DescopeSDK::class))->getProperty('api');
+ $apiProp->setAccessible(true);
+ $api = $apiProp->getValue($sdk);
+
+ $this->assertSame(15.0, $this->httpClientOf($api)->getConfig('timeout'));
+ }
+
+ /**
+ * @dataProvider invalidTimeoutProvider
+ */
+ public function testDescopeSdkRejectsInvalidRequestTimeout($value): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ new DescopeSDK(['projectId' => 'project', 'requestTimeout' => $value]);
+ }
+
+ public static function invalidTimeoutProvider(): array
+ {
+ return [
+ 'zero' => [0],
+ 'negative' => [-5],
+ 'non-numeric string' => ['soon'],
+ 'boolean' => [true],
+ ];
+ }
+
+ public function testDescopeSdkRejectsNonClientHttpClient(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ new DescopeSDK(['projectId' => 'project', 'httpClient' => new \stdClass()]);
+ }
+}