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()]); + } +}