From a7648fa5277be520fc1d21a659b9847bf936e3ab Mon Sep 17 00:00:00 2001 From: Kevin Gao Date: Thu, 6 Jul 2023 10:23:18 -0700 Subject: [PATCH 1/3] RELEASE --- .github/workflows/main.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 92ed7c39..78459e61 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,8 +32,7 @@ jobs: minor-wording: "MINOR" env: GITHUB_TOKEN: ${{ steps.get_token.outputs.token }} - - name: cat package.json - run: cat ./package.json + - name: Output Step env: NEW_TAG: ${{ steps.version-bump.outputs.newTag }} From 4f5d55348da68f9f9e871479cd456542c726b564 Mon Sep 17 00:00:00 2001 From: Kevin J Gao <32936811+gaokevin1@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:49:15 -0700 Subject: [PATCH 2/3] fix(deps): update guzzle to ^7.12.1 to resolve security advisories From 9db74b1bde520ec9340e464ae2a24023de023faf Mon Sep 17 00:00:00 2001 From: Kevin J Gao <32936811+gaokevin1@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:32:59 -0700 Subject: [PATCH 3/3] feat(http): add configurable request timeout with bounded defaults The Guzzle clients in API and SDKConfig were constructed with no timeout options, so requests could hang indefinitely (bounded in practice only by PHP's default_socket_timeout). This adds a single `requestTimeout` config knob matching the Go and Python SDKs, applied as a default option at client construction alongside a built-in 10s connect timeout. Defaults: 60s request, 10s connect. An optional `httpClient` may also be supplied; when present the SDK uses it verbatim and does not override its transport configuration. Co-Authored-By: Claude Opus 4.8 --- README.md | 36 +++++++- phpunit.xml | 1 + src/SDK/API.php | 42 +++++++--- src/SDK/Configuration/SDKConfig.php | 19 ++++- src/SDK/DescopeSDK.php | 45 +++++++++- src/tests/APIHttpTimeoutTest.php | 122 ++++++++++++++++++++++++++++ 6 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 src/tests/APIHttpTimeoutTest.php diff --git a/README.md b/README.md index 229dc7da..04034b38 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 ec988774..59b32e5b 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 584d8daf..a7f469cf 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 e8e089d2..b5a093bd 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 63c9ff54..f4266649 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 00000000..10b4f303 --- /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()]); + } +}