Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a7648fa
RELEASE
gaokevin1 Jul 6, 2023
8d86b29
Merge branch 'main' of github.com:descope/descope-php
gaokevin1 Jul 6, 2023
30d6711
Merge branch 'main' of github.com:descope/descope-php
gaokevin1 May 8, 2024
0b3da6d
Merge branch 'main' of github.com:descope/descope-php
gaokevin1 Jul 23, 2024
6704ea1
Merge branch 'main' of github.com:descope/descope-php
gaokevin1 Jul 25, 2024
cfa824d
Merge branch 'main' of github.com:descope/descope-php
gaokevin1 Aug 8, 2024
f749ced
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Sep 19, 2024
bed91db
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Sep 23, 2024
fcedca7
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Sep 30, 2024
187f446
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Nov 4, 2024
496e6c2
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Jan 7, 2025
d7de196
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Jan 8, 2025
819880f
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 May 9, 2025
f2a3a59
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 May 9, 2025
846e35a
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Sep 22, 2025
efb2fc9
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Nov 10, 2025
8f745f0
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Nov 29, 2025
acbd60e
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Mar 12, 2026
7cec186
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Jun 25, 2026
2afd1b0
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Jun 25, 2026
4f5d553
fix(deps): update guzzle to ^7.12.1 to resolve security advisories
gaokevin1 Jun 25, 2026
e43a0a7
Merge branch 'main' of https://github.com/descope/descope-php
gaokevin1 Jul 24, 2026
9db74b1
feat(http): add configurable request timeout with bounded defaults
gaokevin1 Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
]),
]);
```

Expand Down
1 change: 1 addition & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<file>src/tests/SDKConfigCacheTest.php</file>
<file>src/tests/APIExceptionMappingTest.php</file>
<file>src/tests/APIRetryTest.php</file>
<file>src/tests/APIHttpTimeoutTest.php</file>
<file>src/tests/StaticStateIsolationTest.php</file>
<file>src/tests/EndpointsTest.php</file>
<file>src/tests/Management/ManagementParityTest.php</file>
Expand Down
42 changes: 32 additions & 10 deletions src/SDK/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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;
Expand Down
19 changes: 16 additions & 3 deletions src/SDK/Configuration/SDKConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
45 changes: 43 additions & 2 deletions src/SDK/DescopeSDK.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Descope\SDK\Exception\ValidationException;

use Descope\SDK\Management\MgmtV1;
use GuzzleHttp\ClientInterface;

class DescopeSDK
{
Expand All @@ -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']);
Expand All @@ -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);
Expand All @@ -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.
*
Expand Down
122 changes: 122 additions & 0 deletions src/tests/APIHttpTimeoutTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace Descope\Tests;

use Descope\SDK\API;
use Descope\SDK\Configuration\SDKConfig;
use Descope\SDK\DescopeSDK;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

final class APIHttpTimeoutTest extends TestCase
{
private function httpClientOf(object $target): ClientInterface
{
$property = (new ReflectionClass($target))->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()]);
}
}