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