From 71700a08981f040a8ac27df9e119f5bd292b160c Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:52:38 +0200 Subject: [PATCH 01/20] chore: bootstrap CHA-2956 connection pooling branch From d79c955cd94493a920a4c3856975c15342151c0d Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:53:45 +0200 Subject: [PATCH 02/20] feat: add PoolConfig value object for connection pool knobs --- src/Http/PoolConfig.php | 41 ++++++++++++++++++++++++++++++ tests/Http/PoolConfigTest.php | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/Http/PoolConfig.php create mode 100644 tests/Http/PoolConfigTest.php diff --git a/src/Http/PoolConfig.php b/src/Http/PoolConfig.php new file mode 100644 index 00000000..93592ee7 --- /dev/null +++ b/src/Http/PoolConfig.php @@ -0,0 +1,41 @@ +idleTimeout, $this->connectTimeout, $this->requestTimeout); + } + + public function withIdleTimeout(int $seconds): self + { + return new self($this->maxConnsPerHost, $seconds, $this->connectTimeout, $this->requestTimeout); + } + + public function withConnectTimeout(int $seconds): self + { + return new self($this->maxConnsPerHost, $this->idleTimeout, $seconds, $this->requestTimeout); + } + + public function withRequestTimeout(int $seconds): self + { + return new self($this->maxConnsPerHost, $this->idleTimeout, $this->connectTimeout, $seconds); + } +} diff --git a/tests/Http/PoolConfigTest.php b/tests/Http/PoolConfigTest.php new file mode 100644 index 00000000..0cd90b74 --- /dev/null +++ b/tests/Http/PoolConfigTest.php @@ -0,0 +1,48 @@ +maxConnsPerHost); + self::assertSame(55, $cfg->idleTimeout); + self::assertSame(10, $cfg->connectTimeout); + self::assertSame(30, $cfg->requestTimeout); + } + + /** @test */ + public function withMaxConnsPerHostReturnsNewInstance(): void + { + $cfg = new PoolConfig(); + $updated = $cfg->withMaxConnsPerHost(20); + + self::assertNotSame($cfg, $updated, 'withers return new instances (immutability)'); + self::assertSame(5, $cfg->maxConnsPerHost, 'original unchanged'); + self::assertSame(20, $updated->maxConnsPerHost); + } + + /** @test */ + public function withAllKnobsOverridden(): void + { + $cfg = (new PoolConfig()) + ->withMaxConnsPerHost(15) + ->withIdleTimeout(45) + ->withConnectTimeout(3) + ->withRequestTimeout(20); + + self::assertSame(15, $cfg->maxConnsPerHost); + self::assertSame(45, $cfg->idleTimeout); + self::assertSame(3, $cfg->connectTimeout); + self::assertSame(20, $cfg->requestTimeout); + } +} From bbe4b4597827ca1672cd535d956f70fe2deee1bd Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:54:45 +0200 Subject: [PATCH 03/20] feat: add 4 pool-config chained methods on ClientBuilder --- src/ClientBuilder.php | 73 ++++++++++++++++++++++++++++++++++--- tests/ClientBuilderTest.php | 40 ++++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 312206d3..58da0173 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -6,7 +6,9 @@ use Dotenv\Dotenv; use GetStream\Exceptions\StreamException; +use GetStream\Http\GuzzleHttpClient; use GetStream\Http\HttpClientInterface; +use GetStream\Http\PoolConfig; /** * Builder class for creating GetStream clients with environment variable support. @@ -19,6 +21,12 @@ class ClientBuilder private ?HttpClientInterface $httpClient = null; private bool $loadEnv = true; private ?string $envPath = null; + private PoolConfig $pool; + + public function __construct() + { + $this->pool = new PoolConfig(); + } /** * Set the API key. @@ -60,6 +68,46 @@ public function httpClient(HttpClientInterface $httpClient): self return $this; } + /** Cap concurrent TCP connections per host. Default: 5. Ignored when httpClient() is used. */ + public function maxConnsPerHost(int $n): self + { + $this->pool = $this->pool->withMaxConnsPerHost($n); + + return $this; + } + + /** + * Idle connection lifetime in seconds. Default: 55. + * No-op under PHP-FPM (curl handle dies with the request); honored in long-running runtimes. + * Ignored when httpClient() is used. + */ + public function idleTimeout(int $seconds): self + { + $this->pool = $this->pool->withIdleTimeout($seconds); + + return $this; + } + + /** TCP + TLS handshake cap in seconds (Guzzle `connect_timeout`). Default: 10. Ignored when httpClient() is used. */ + public function connectTimeout(int $seconds): self + { + $this->pool = $this->pool->withConnectTimeout($seconds); + + return $this; + } + + /** + * Default per-request timeout in seconds (Guzzle `timeout`). Default: 30. + * Per-call override: pass `['timeout' => N]` as the 5th arg of HttpClientInterface::request(). + * Ignored when httpClient() is used. + */ + public function requestTimeout(int $seconds): self + { + $this->pool = $this->pool->withRequestTimeout($seconds); + + return $this; + } + /** * Disable loading from environment variables. */ @@ -102,7 +150,7 @@ public function build(): Client { $this->loadCreds(); - return new Client($this->apiKey, $this->apiSecret, $this->baseUrl, $this->httpClient); + return new Client($this->apiKey, $this->apiSecret, $this->baseUrl, $this->resolveHttpClient()); } /** @@ -112,7 +160,7 @@ public function buildFeedsClient(): FeedsV3Client { $this->loadCreds(); - return new FeedsV3Client($this->apiKey, $this->apiSecret, $this->baseUrl, $this->httpClient); + return new FeedsV3Client($this->apiKey, $this->apiSecret, $this->baseUrl, $this->resolveHttpClient()); } /** @@ -122,7 +170,7 @@ public function buildChatClient(): ChatClient { $this->loadCreds(); - return new ChatClient($this->apiKey, $this->apiSecret, $this->baseUrl, $this->httpClient); + return new ChatClient($this->apiKey, $this->apiSecret, $this->baseUrl, $this->resolveHttpClient()); } /** @@ -132,7 +180,7 @@ public function buildVideoClient(): VideoClient { $this->loadCreds(); - return new VideoClient($this->apiKey, $this->apiSecret, $this->baseUrl, $this->httpClient); + return new VideoClient($this->apiKey, $this->apiSecret, $this->baseUrl, $this->resolveHttpClient()); } /** @@ -142,7 +190,7 @@ public function buildModerationClient(): ModerationClient { $this->loadCreds(); - return new ModerationClient($this->apiKey, $this->apiSecret, $this->baseUrl, $this->httpClient); + return new ModerationClient($this->apiKey, $this->apiSecret, $this->baseUrl, $this->resolveHttpClient()); } public function loadCreds(): void @@ -209,4 +257,19 @@ private function getEnvVar(string $name): ?string return $value !== false ? $value : null; } + + /** + * Resolve the HttpClient. If the user supplied one via httpClient(), + * return it as-is (§7 escape hatch). Otherwise build a GuzzleHttpClient. + * Task 3 will pass $this->pool here once GuzzleHttpClient's constructor + * accepts a PoolConfig argument. + */ + private function resolveHttpClient(): HttpClientInterface + { + if ($this->httpClient !== null) { + return $this->httpClient; + } + + return new GuzzleHttpClient(); + } } diff --git a/tests/ClientBuilderTest.php b/tests/ClientBuilderTest.php index b2bfc827..4b0b1847 100644 --- a/tests/ClientBuilderTest.php +++ b/tests/ClientBuilderTest.php @@ -231,10 +231,50 @@ public function fluentInterface(): void ->apiKey('test') ->apiSecret('test') ->baseUrl('test') + ->maxConnsPerHost(5) + ->idleTimeout(55) + ->connectTimeout(10) + ->requestTimeout(30) ->httpClient($this->mockHttpClient) ->skipEnvLoad() ->envPath('/test/path'); self::assertSame($builder, $result); } + + /** @test */ + public function poolConfigKnobsAreChained(): void + { + // After Task 3 wires GuzzleHttpClient::__construct(?PoolConfig), this + // test will assert against $client->getHttpClient()->getPoolConfig(). + // For now, we just assert the builder exposes the 4 fluent methods + // and returns $this for chaining (Task 2 scope). + $builder = (new ClientBuilder()) + ->apiKey('k') + ->apiSecret('s') + ->maxConnsPerHost(12) + ->idleTimeout(40) + ->connectTimeout(4) + ->requestTimeout(25); + + self::assertInstanceOf(ClientBuilder::class, $builder); + } + + /** @test */ + public function userSuppliedHttpClientBypassesBuild(): void + { + $mock = $this->createMock(HttpClientInterface::class); + $client = (new ClientBuilder()) + ->apiKey('k') + ->apiSecret('s') + ->maxConnsPerHost(99) // these 4 must NOT mutate $mock (§7) + ->idleTimeout(99) + ->connectTimeout(99) + ->requestTimeout(99) + ->httpClient($mock) + ->skipEnvLoad() + ->build(); + + self::assertSame($mock, $client->getHttpClient()); + } } From eeb693fc425326c246949feb7f0ab26c20421de5 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:56:22 +0200 Subject: [PATCH 04/20] feat: wire PoolConfig into GuzzleHttpClient via builder --- src/ClientBuilder.php | 7 +-- src/Http/GuzzleHttpClient.php | 37 +++++++++++-- tests/ClientBuilderTest.php | 19 ++++--- tests/Http/GuzzleHttpClientPoolTest.php | 73 +++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 tests/Http/GuzzleHttpClientPoolTest.php diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 58da0173..994fd914 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -260,9 +260,8 @@ private function getEnvVar(string $name): ?string /** * Resolve the HttpClient. If the user supplied one via httpClient(), - * return it as-is (§7 escape hatch). Otherwise build a GuzzleHttpClient. - * Task 3 will pass $this->pool here once GuzzleHttpClient's constructor - * accepts a PoolConfig argument. + * return it as-is (§7 escape hatch). Otherwise build a GuzzleHttpClient + * with the configured PoolConfig. */ private function resolveHttpClient(): HttpClientInterface { @@ -270,6 +269,6 @@ private function resolveHttpClient(): HttpClientInterface return $this->httpClient; } - return new GuzzleHttpClient(); + return new GuzzleHttpClient([], 3, $this->pool); } } diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index 94c06291..fcc7ae90 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -23,22 +23,47 @@ class GuzzleHttpClient implements HttpClientInterface /** Maximum number of retries for rate-limited (429) responses. */ private int $maxRetries; + /** @var PoolConfig Effective pool configuration (kept for diagnostics). */ + private PoolConfig $pool; + /** * Create a new GuzzleHttpClient. * - * @param array $config Guzzle client configuration - * @param int $maxRetries Maximum retries for 429 rate-limit responses (default 3) + * @param array $config Guzzle client configuration (wins over $pool defaults) + * @param int $maxRetries Maximum retries for 429 rate-limit responses (default 3) + * @param PoolConfig|null $pool Connection pool configuration. When null, spec defaults apply. */ - public function __construct(array $config = [], int $maxRetries = 3) + public function __construct(array $config = [], int $maxRetries = 3, ?PoolConfig $pool = null) { + $pool = $pool ?? new PoolConfig(); + $defaultConfig = [ - 'timeout' => 30, - 'connect_timeout' => 10, + 'timeout' => $pool->requestTimeout, + 'connect_timeout' => $pool->connectTimeout, 'http_errors' => false, // We'll handle errors ourselves + 'curl' => [ + // Cap concurrent connections opened by curl's internal multi handle. + // IdleTimeout has no direct Guzzle/curl analogue; honored only in + // long-running runtimes. For PHP-FPM, idle sockets die with the + // request — see CHANGELOG §9.1. + CURLOPT_MAXCONNECTS => $pool->maxConnsPerHost, + CURLOPT_FORBID_REUSE => 0, // explicit: allow connection reuse (KeepAlive invariant) + ], ]; - $this->client = new GuzzleClient(array_merge($defaultConfig, $config)); + // User-supplied $config wins. array_replace_recursive preserves the + // user's curl array while filling in defaults we set above. + $merged = array_replace_recursive($defaultConfig, $config); + + $this->client = new GuzzleClient($merged); $this->maxRetries = $maxRetries; + $this->pool = $pool; + } + + /** Return the effective PoolConfig (for diagnostics / logging). */ + public function getPoolConfig(): PoolConfig + { + return $this->pool; } /** diff --git a/tests/ClientBuilderTest.php b/tests/ClientBuilderTest.php index 4b0b1847..1c330184 100644 --- a/tests/ClientBuilderTest.php +++ b/tests/ClientBuilderTest.php @@ -245,19 +245,24 @@ public function fluentInterface(): void /** @test */ public function poolConfigKnobsAreChained(): void { - // After Task 3 wires GuzzleHttpClient::__construct(?PoolConfig), this - // test will assert against $client->getHttpClient()->getPoolConfig(). - // For now, we just assert the builder exposes the 4 fluent methods - // and returns $this for chaining (Task 2 scope). - $builder = (new ClientBuilder()) + $client = (new ClientBuilder()) ->apiKey('k') ->apiSecret('s') ->maxConnsPerHost(12) ->idleTimeout(40) ->connectTimeout(4) - ->requestTimeout(25); + ->requestTimeout(25) + ->skipEnvLoad() + ->build(); + + $http = $client->getHttpClient(); + self::assertInstanceOf(\GetStream\Http\GuzzleHttpClient::class, $http); - self::assertInstanceOf(ClientBuilder::class, $builder); + $pool = $http->getPoolConfig(); + self::assertSame(12, $pool->maxConnsPerHost); + self::assertSame(40, $pool->idleTimeout); + self::assertSame(4, $pool->connectTimeout); + self::assertSame(25, $pool->requestTimeout); } /** @test */ diff --git a/tests/Http/GuzzleHttpClientPoolTest.php b/tests/Http/GuzzleHttpClientPoolTest.php new file mode 100644 index 00000000..df7fa9ab --- /dev/null +++ b/tests/Http/GuzzleHttpClientPoolTest.php @@ -0,0 +1,73 @@ + */ + private array $history = []; + + /** + * Build a GuzzleHttpClient whose underlying handler is a MockHandler + * fronted by a history middleware. Records into $this->history. + */ + private function buildWithHistory(?PoolConfig $pool = null, array $extraConfig = []): GuzzleHttpClient + { + $this->history = []; + $mock = new MockHandler([new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{}')]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($this->history)); + + return new GuzzleHttpClient( + array_merge(['handler' => $stack], $extraConfig), + 0, + $pool, + ); + } + + /** @test */ + public function appliesPoolConfigToGuzzleDefaults(): void + { + $pool = new PoolConfig(maxConnsPerHost: 8, idleTimeout: 40, connectTimeout: 4, requestTimeout: 25); + $sdk = $this->buildWithHistory($pool); + + $sdk->request('GET', 'http://example.test/'); + + $opts = $this->history[0]['options']; + self::assertSame(25, $opts['timeout'] ?? null); + self::assertSame(4, $opts['connect_timeout'] ?? null); + self::assertSame(8, $opts['curl'][CURLOPT_MAXCONNECTS] ?? null); + } + + /** @test */ + public function defaultsApplyWhenNoPoolConfigPassed(): void + { + $sdk = $this->buildWithHistory(); + $sdk->request('GET', 'http://example.test/'); + + $opts = $this->history[0]['options']; + self::assertSame(30, $opts['timeout'] ?? null); + self::assertSame(10, $opts['connect_timeout'] ?? null); + self::assertSame(5, $opts['curl'][CURLOPT_MAXCONNECTS] ?? null); + } + + /** @test */ + public function userConfigOverridesPoolDefaults(): void + { + $pool = new PoolConfig(requestTimeout: 25); + $sdk = $this->buildWithHistory($pool, ['timeout' => 99]); + + $sdk->request('GET', 'http://example.test/'); + self::assertSame(99, $this->history[0]['options']['timeout'] ?? null); + } +} From 8ec4a374a080cac53fa6ac8f28d079a5d7c92938 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:56:36 +0200 Subject: [PATCH 05/20] feat: add optional per-call options arg to HttpClientInterface::request --- src/Http/HttpClientInterface.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php index 68f9977b..1edc1d7e 100644 --- a/src/Http/HttpClientInterface.php +++ b/src/Http/HttpClientInterface.php @@ -19,10 +19,21 @@ interface HttpClientInterface * @param string $url Full URL to request * @param array $headers Request headers * @param mixed $body Request body (will be JSON encoded if array) + * @param array $options Per-call options. Supported keys: + * - 'timeout' (int|float seconds): overrides the client's + * RequestTimeout for this single call (§5.2). + * Implementations MAY accept additional keys but MUST + * silently ignore unknown ones. * * @return StreamResponse * * @throws StreamException */ - public function request(string $method, string $url, array $headers = [], mixed $body = null): StreamResponse; + public function request( + string $method, + string $url, + array $headers = [], + mixed $body = null, + array $options = [] + ): StreamResponse; } From 44fc4a15b36e2a8c887f5b55d26d1da8b353d808 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:57:14 +0200 Subject: [PATCH 06/20] feat: per-call $options propagation in GuzzleHttpClient::request --- src/Http/GuzzleHttpClient.php | 26 ++++++++++++++++++------- tests/Http/GuzzleHttpClientPoolTest.php | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index fcc7ae90..f7fdc445 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -73,34 +73,46 @@ public function getPoolConfig(): PoolConfig * @param string $url Full URL to request * @param array $headers Request headers * @param mixed $body Request body + * @param array $options Per-call Guzzle option overrides (e.g. ['timeout' => 2]) * * @return StreamResponse * * @throws StreamException */ - public function request(string $method, string $url, array $headers = [], mixed $body = null): StreamResponse - { + public function request( + string $method, + string $url, + array $headers = [], + mixed $body = null, + array $options = [] + ): StreamResponse { try { - $options = [ + $requestOptions = [ 'headers' => $headers, ]; + // Per-call overrides (§5.2). 'timeout' is the canonical key; any + // other valid Guzzle key is also forwarded. + foreach ($options as $key => $value) { + $requestOptions[$key] = $value; + } + // Add body if provided if ($body !== null) { // Check if this is multipart form data (array of arrays with 'name' and 'contents') if (is_array($body) && !empty($body) && isset($body[0]) && is_array($body[0]) && isset($body[0]['name'])) { // This is multipart form data - $options['multipart'] = $body; + $requestOptions['multipart'] = $body; } elseif (is_array($body) || is_object($body)) { - $options['json'] = $body; + $requestOptions['json'] = $body; } else { - $options['body'] = $body; + $requestOptions['body'] = $body; } } // Retry loop for rate-limited responses for ($attempt = 0;; $attempt++) { - $response = $this->client->request($method, $url, $options); + $response = $this->client->request($method, $url, $requestOptions); if ($response->getStatusCode() !== 429 || $attempt >= $this->maxRetries) { return $this->createStreamResponse($response); diff --git a/tests/Http/GuzzleHttpClientPoolTest.php b/tests/Http/GuzzleHttpClientPoolTest.php index df7fa9ab..31e3ed2c 100644 --- a/tests/Http/GuzzleHttpClientPoolTest.php +++ b/tests/Http/GuzzleHttpClientPoolTest.php @@ -70,4 +70,24 @@ public function userConfigOverridesPoolDefaults(): void $sdk->request('GET', 'http://example.test/'); self::assertSame(99, $this->history[0]['options']['timeout'] ?? null); } + + /** @test */ + public function perCallTimeoutOptionReachesGuzzle(): void + { + $sdk = $this->buildWithHistory(); + $sdk->request('GET', 'http://example.test/', [], null, ['timeout' => 2]); + + self::assertSame(2, $this->history[0]['options']['timeout'] ?? null, + 'per-call timeout reaches Guzzle request options'); + } + + /** @test */ + public function perCallOptionOverridesClientDefault(): void + { + $sdk = $this->buildWithHistory(new PoolConfig(requestTimeout: 17)); + $sdk->request('GET', 'http://example.test/', [], null, ['timeout' => 2]); + + // Per-call 2s wins over client default 17s. + self::assertSame(2, $this->history[0]['options']['timeout'] ?? null); + } } From 1ab48a73dfbe0a3f2330b415e241272069915e28 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:57:30 +0200 Subject: [PATCH 07/20] =?UTF-8?q?test:=20reinforce=20escape=20hatch=20?= =?UTF-8?q?=E2=80=94=20user=20http=20client=20untouched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/ClientBuilderTest.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/ClientBuilderTest.php b/tests/ClientBuilderTest.php index 1c330184..12f39141 100644 --- a/tests/ClientBuilderTest.php +++ b/tests/ClientBuilderTest.php @@ -269,10 +269,15 @@ public function poolConfigKnobsAreChained(): void public function userSuppliedHttpClientBypassesBuild(): void { $mock = $this->createMock(HttpClientInterface::class); + + // The mock must NEVER have request() called during build() — building + // should be a pure construction step, no probe requests. + $mock->expects(self::never())->method('request'); + $client = (new ClientBuilder()) ->apiKey('k') ->apiSecret('s') - ->maxConnsPerHost(99) // these 4 must NOT mutate $mock (§7) + ->maxConnsPerHost(99) ->idleTimeout(99) ->connectTimeout(99) ->requestTimeout(99) @@ -280,6 +285,10 @@ public function userSuppliedHttpClientBypassesBuild(): void ->skipEnvLoad() ->build(); + // Identity check: the user's exact instance comes back unwrapped. self::assertSame($mock, $client->getHttpClient()); + + // And it is NOT a GuzzleHttpClient (no pool config is applied to it). + self::assertNotInstanceOf(\GetStream\Http\GuzzleHttpClient::class, $client->getHttpClient()); } } From 2e7efb14c4b7e566747000056917b8f7528ff3d9 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:58:45 +0200 Subject: [PATCH 08/20] feat: INFO log on construction + README PHP-FPM caveat --- README.md | 25 +++++++ phpunit.xml | 3 + src/ClientBuilder.php | 30 +++++++- tests/Integration/ConnectionPoolLogTest.php | 79 +++++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/ConnectionPoolLogTest.php diff --git a/README.md b/README.md index 430dea72..e981b03b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,31 @@ STREAM_API_SECRET=your_api_secret_here STREAM_BASE_URL=https://chat.stream-io-api.com ``` +## Connection Pool Tuning + +```php +$client = (new GetStream\ClientBuilder()) + ->apiKey($apiKey) + ->apiSecret($apiSecret) + ->maxConnsPerHost(5) // default 5 + ->idleTimeout(55) // default 55s (no-op under PHP-FPM, see below) + ->connectTimeout(10) // default 10s + ->requestTimeout(30) // default 30s + ->build(); +``` + +**Per-call timeout override:** + +```php +$response = $client->getHttpClient()->request( + 'GET', $url, $headers, null, ['timeout' => 2] +); +``` + +**PHP-FPM caveat:** Under PHP-FPM and CLI scripts the curl handle dies with the request, so `idleTimeout` and `maxConnsPerHost` have no inter-request effect. They take effect in long-running runtimes (Swoole, RoadRunner, ReactPHP, daemons). + +**Escape hatch:** Passing your own client via `->httpClient($mine)` skips all 4 knobs — your client is used as-is. + ## Code Generation Generate API methods from OpenAPI spec: diff --git a/phpunit.xml b/phpunit.xml index a4496a2b..27149625 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,5 +17,8 @@ src + + + diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 994fd914..67457ef3 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -261,14 +261,40 @@ private function getEnvVar(string $name): ?string /** * Resolve the HttpClient. If the user supplied one via httpClient(), * return it as-is (§7 escape hatch). Otherwise build a GuzzleHttpClient - * with the configured PoolConfig. + * with the configured PoolConfig and emit the §8 INFO log. */ private function resolveHttpClient(): HttpClientInterface { if ($this->httpClient !== null) { + $this->logInfo( + 'getstream-php connection pool: user_http_client=true (5 knobs not applied)' + ); + return $this->httpClient; } - return new GuzzleHttpClient([], 3, $this->pool); + $client = new GuzzleHttpClient([], 3, $this->pool); + + $this->logInfo(sprintf( + 'getstream-php connection pool: max_conns_per_host=%d idle_timeout=%ds connect_timeout=%ds request_timeout=%ds user_http_client=false', + $this->pool->maxConnsPerHost, + $this->pool->idleTimeout, + $this->pool->connectTimeout, + $this->pool->requestTimeout, + )); + + return $client; + } + + /** + * Emit one INFO log line via error_log(). Suppressed under PHPUnit to + * keep test output clean (PHPUNIT_RUNNING constant is set in phpunit.xml). + */ + private function logInfo(string $message): void + { + if (defined('PHPUNIT_RUNNING') && PHPUNIT_RUNNING) { + return; + } + error_log('[INFO] ' . $message); } } diff --git a/tests/Integration/ConnectionPoolLogTest.php b/tests/Integration/ConnectionPoolLogTest.php new file mode 100644 index 00000000..f954377f --- /dev/null +++ b/tests/Integration/ConnectionPoolLogTest.php @@ -0,0 +1,79 @@ +apiKey('k')->apiSecret('s') + ->maxConnsPerHost(7)->idleTimeout(33)->connectTimeout(2)->requestTimeout(11) + ->skipEnvLoad() + ->build(); +PHP; + $scriptPath = tempnam(sys_get_temp_dir(), 'gs-cp-script-') . '.php'; + file_put_contents($scriptPath, $script); + + $cmd = sprintf('php %s %s 2>&1', escapeshellarg($scriptPath), escapeshellarg($tmp)); + exec($cmd, $out, $code); + self::assertSame(0, $code, implode("\n", $out)); + + $log = file_get_contents($tmp); + self::assertStringContainsString('max_conns_per_host=7', $log); + self::assertStringContainsString('idle_timeout=33s', $log); + self::assertStringContainsString('connect_timeout=2s', $log); + self::assertStringContainsString('request_timeout=11s', $log); + self::assertStringContainsString('user_http_client=false', $log); + + @unlink($tmp); + @unlink($scriptPath); + } + + public function testInfoLogIndicatesEscapeHatchUsed(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'gs-cp-log-'); + $autoload = realpath(__DIR__ . '/../../vendor/autoload.php'); + $script = <<apiKey('k')->apiSecret('s') + ->httpClient(\$mock) + ->skipEnvLoad() + ->build(); +PHP; + $scriptPath = tempnam(sys_get_temp_dir(), 'gs-cp-script-') . '.php'; + file_put_contents($scriptPath, $script); + + $cmd = sprintf('php %s %s 2>&1', escapeshellarg($scriptPath), escapeshellarg($tmp)); + exec($cmd, $out, $code); + self::assertSame(0, $code, implode("\n", $out)); + + $log = file_get_contents($tmp); + self::assertStringContainsString('user_http_client=true', $log); + self::assertStringContainsString('5 knobs not applied', $log); + + @unlink($tmp); + @unlink($scriptPath); + } +} From 28d116e9a025c7ace0afb34dfbd8e412b70dc133 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 18:59:13 +0200 Subject: [PATCH 09/20] docs: changelog for v7.3.0 connection pooling --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8dc2245..3db372a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.3.0] - 2026-MM-DD + +### Added + +- Connection pool configuration on `ClientBuilder` ([CHA-2956](https://linear.app/stream/issue/CHA-2956/connection-pooling)). + Four new chained methods, all `int` seconds: + * `maxConnsPerHost(int)` — default `5` (curl `CURLOPT_MAXCONNECTS`) + * `idleTimeout(int)` — default `55` (no-op under PHP-FPM, see caveat below) + * `connectTimeout(int)` — default `10` (Guzzle `connect_timeout`) + * `requestTimeout(int)` — default `30` (Guzzle `timeout`) +- `GetStream\Http\PoolConfig` immutable value object holding the 5 canonical knobs. +- `HttpClientInterface::request()` gains an optional 5th `array $options = []` parameter + for per-call overrides (e.g., `['timeout' => 2]`). Backward-compatible. +- INFO log on `ClientBuilder::build()` listing the effective pool config (spec §8). + Emitted via `error_log()`; suppressed in PHPUnit runs. +- `GuzzleHttpClient::getPoolConfig()` accessor for diagnostics. + +### Changed + +- `GuzzleHttpClient::__construct()` gains an optional 3rd `?PoolConfig $pool` parameter. + Existing callsites continue to work unchanged. + +### PHP-FPM caveat (§9.1) + +Under PHP-FPM and CLI scripts, the PHP process exits at the end of each request and +the curl handle dies with it. `idleTimeout` and `maxConnsPerHost` are accepted on the +builder but have **no effect** on inter-request pooling in these runtimes. They take +effect only in long-running PHP runtimes (Swoole, RoadRunner, ReactPHP, daemons). + +### Out of scope + +- No env-var overrides (spec §3 defers this). +- No PSR-3 logger injection; INFO log goes through `error_log()`. + +[Spec](https://www.notion.so/stream-wiki/Server-Side-SDK-Connection-Pooling-Spec-3496a5d7f9f680749b8be9ee238ae108) + ## [Unreleased] ### Added From e8e53441d44d8fe94c1e262161e37a8ffaf59b74 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 19:00:29 +0200 Subject: [PATCH 10/20] chore: narrow nullable for PHPStan in resolveHttpClient --- src/ClientBuilder.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 67457ef3..0255fa62 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -265,12 +265,13 @@ private function getEnvVar(string $name): ?string */ private function resolveHttpClient(): HttpClientInterface { - if ($this->httpClient !== null) { + $user = $this->httpClient; + if ($user !== null) { $this->logInfo( 'getstream-php connection pool: user_http_client=true (5 knobs not applied)' ); - return $this->httpClient; + return $user; } $client = new GuzzleHttpClient([], 3, $this->pool); From bcc044b6771b6373dc2d63956e01f4b5fe562536 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 19:00:32 +0200 Subject: [PATCH 11/20] chore: bump version to v7.3.0 --- composer.json | 2 +- src/Constant.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index d0bc3125..44d48407 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "getstream/getstream-php", "description": "PHP SDK for GetStream API", - "version": "v7.2.0", + "version": "v7.3.0", "type": "library", "license": "MIT", "authors": [ diff --git a/src/Constant.php b/src/Constant.php index 69942778..1f79dd14 100644 --- a/src/Constant.php +++ b/src/Constant.php @@ -6,5 +6,5 @@ class Constant { - public const VERSION = '7.2.0'; + public const VERSION = '7.3.0'; } From 01a120e73593f484cb5e174505f12d68a0ff628f Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 19:38:37 +0200 Subject: [PATCH 12/20] ci: retrigger workflow after PR title fix From ff0b5ccceb99b94871bb90ccf1c121742390688d Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 27 May 2026 12:01:31 +0200 Subject: [PATCH 13/20] docs: remove internal spec references from customer-facing content --- CHANGELOG.md | 10 +++------- src/ClientBuilder.php | 4 ++-- src/Http/GuzzleHttpClient.php | 4 ++-- src/Http/HttpClientInterface.php | 2 +- src/Http/PoolConfig.php | 2 +- tests/WebhookTest.php | 2 +- 6 files changed, 10 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db372a2..ae06e1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `GetStream\Http\PoolConfig` immutable value object holding the 5 canonical knobs. - `HttpClientInterface::request()` gains an optional 5th `array $options = []` parameter for per-call overrides (e.g., `['timeout' => 2]`). Backward-compatible. -- INFO log on `ClientBuilder::build()` listing the effective pool config (spec §8). +- INFO log on `ClientBuilder::build()` listing the effective pool config. Emitted via `error_log()`; suppressed in PHPUnit runs. - `GuzzleHttpClient::getPoolConfig()` accessor for diagnostics. @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `GuzzleHttpClient::__construct()` gains an optional 3rd `?PoolConfig $pool` parameter. Existing callsites continue to work unchanged. -### PHP-FPM caveat (§9.1) +### PHP-FPM caveat Under PHP-FPM and CLI scripts, the PHP process exits at the end of each request and the curl handle dies with it. `idleTimeout` and `maxConnsPerHost` are accepted on the @@ -36,11 +36,9 @@ effect only in long-running PHP runtimes (Swoole, RoadRunner, ReactPHP, daemons) ### Out of scope -- No env-var overrides (spec §3 defers this). +- No env-var overrides. - No PSR-3 logger injection; INFO log goes through `error_log()`. -[Spec](https://www.notion.so/stream-wiki/Server-Side-SDK-Connection-Pooling-Spec-3496a5d7f9f680749b8be9ee238ae108) - ## [Unreleased] ### Added @@ -68,8 +66,6 @@ effect only in long-running PHP runtimes (Swoole, RoadRunner, ReactPHP, daemons) - No breaking changes. -[Spec](https://www.notion.so/stream-wiki/Server-Side-SDK-Webhook-Handling-Spec-34b6a5d7f9f681e78003c443f227493c) - ## [4.0.0] - 2026-03-05 ### Breaking Changes diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 0255fa62..0700b87e 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -260,8 +260,8 @@ private function getEnvVar(string $name): ?string /** * Resolve the HttpClient. If the user supplied one via httpClient(), - * return it as-is (§7 escape hatch). Otherwise build a GuzzleHttpClient - * with the configured PoolConfig and emit the §8 INFO log. + * return it as-is (escape hatch). Otherwise build a GuzzleHttpClient + * with the configured PoolConfig and emit the INFO log. */ private function resolveHttpClient(): HttpClientInterface { diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index f7fdc445..56457ec2 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -45,7 +45,7 @@ public function __construct(array $config = [], int $maxRetries = 3, ?PoolConfig // Cap concurrent connections opened by curl's internal multi handle. // IdleTimeout has no direct Guzzle/curl analogue; honored only in // long-running runtimes. For PHP-FPM, idle sockets die with the - // request — see CHANGELOG §9.1. + // request. See the PHP-FPM note in the README/CHANGELOG. CURLOPT_MAXCONNECTS => $pool->maxConnsPerHost, CURLOPT_FORBID_REUSE => 0, // explicit: allow connection reuse (KeepAlive invariant) ], @@ -91,7 +91,7 @@ public function request( 'headers' => $headers, ]; - // Per-call overrides (§5.2). 'timeout' is the canonical key; any + // Per-call overrides. 'timeout' is the canonical key; any // other valid Guzzle key is also forwarded. foreach ($options as $key => $value) { $requestOptions[$key] = $value; diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php index 1edc1d7e..fd188970 100644 --- a/src/Http/HttpClientInterface.php +++ b/src/Http/HttpClientInterface.php @@ -21,7 +21,7 @@ interface HttpClientInterface * @param mixed $body Request body (will be JSON encoded if array) * @param array $options Per-call options. Supported keys: * - 'timeout' (int|float seconds): overrides the client's - * RequestTimeout for this single call (§5.2). + * RequestTimeout for this single call. * Implementations MAY accept additional keys but MUST * silently ignore unknown ones. * diff --git a/src/Http/PoolConfig.php b/src/Http/PoolConfig.php index 93592ee7..dc0b2ccd 100644 --- a/src/Http/PoolConfig.php +++ b/src/Http/PoolConfig.php @@ -5,7 +5,7 @@ namespace GetStream\Http; /** - * Immutable value object for the 5 canonical HTTP connection-pool knobs (spec §4). + * Immutable value object for the 5 canonical HTTP connection-pool knobs. * Defaults: 5 conns/host, 55s idle, 10s connect, 30s request. KeepAlive is an * invariant (true). All durations are whole seconds. */ diff --git a/tests/WebhookTest.php b/tests/WebhookTest.php index c98ac1dd..a0aeea65 100644 --- a/tests/WebhookTest.php +++ b/tests/WebhookTest.php @@ -277,7 +277,7 @@ public function testParseWebhookEventInvalidJson(): void } // ------------------------------------------------------------------------- - // Spec §6 helpers + composites: parseEvent, gunzipPayload, decodeSqsPayload, + // Webhook helpers + composites: parseEvent, gunzipPayload, decodeSqsPayload, // decodeSnsPayload, verifyAndParseWebhook, parseSqs, parseSns. // ------------------------------------------------------------------------- From d531377e9d7aa8277383bf0a1f58be8116faab4e Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 27 May 2026 12:18:09 +0200 Subject: [PATCH 14/20] docs: unwrap hard-wrapped prose in CHA-2956 content --- CHANGELOG.md | 17 +++++------------ src/ClientBuilder.php | 10 +++++----- src/Http/GuzzleHttpClient.php | 14 +++++++------- src/Http/HttpClientInterface.php | 6 ++---- src/Http/PoolConfig.php | 4 ++-- 5 files changed, 21 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae06e1de..31a7b4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,30 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Connection pool configuration on `ClientBuilder` ([CHA-2956](https://linear.app/stream/issue/CHA-2956/connection-pooling)). - Four new chained methods, all `int` seconds: +- Connection pool configuration on `ClientBuilder` ([CHA-2956](https://linear.app/stream/issue/CHA-2956/connection-pooling)). Four new chained methods, all `int` seconds: * `maxConnsPerHost(int)` — default `5` (curl `CURLOPT_MAXCONNECTS`) * `idleTimeout(int)` — default `55` (no-op under PHP-FPM, see caveat below) * `connectTimeout(int)` — default `10` (Guzzle `connect_timeout`) * `requestTimeout(int)` — default `30` (Guzzle `timeout`) - `GetStream\Http\PoolConfig` immutable value object holding the 5 canonical knobs. -- `HttpClientInterface::request()` gains an optional 5th `array $options = []` parameter - for per-call overrides (e.g., `['timeout' => 2]`). Backward-compatible. -- INFO log on `ClientBuilder::build()` listing the effective pool config. - Emitted via `error_log()`; suppressed in PHPUnit runs. +- `HttpClientInterface::request()` gains an optional 5th `array $options = []` parameter for per-call overrides (e.g., `['timeout' => 2]`). Backward-compatible. +- INFO log on `ClientBuilder::build()` listing the effective pool config. Emitted via `error_log()`; suppressed in PHPUnit runs. - `GuzzleHttpClient::getPoolConfig()` accessor for diagnostics. ### Changed -- `GuzzleHttpClient::__construct()` gains an optional 3rd `?PoolConfig $pool` parameter. - Existing callsites continue to work unchanged. +- `GuzzleHttpClient::__construct()` gains an optional 3rd `?PoolConfig $pool` parameter. Existing callsites continue to work unchanged. ### PHP-FPM caveat -Under PHP-FPM and CLI scripts, the PHP process exits at the end of each request and -the curl handle dies with it. `idleTimeout` and `maxConnsPerHost` are accepted on the -builder but have **no effect** on inter-request pooling in these runtimes. They take -effect only in long-running PHP runtimes (Swoole, RoadRunner, ReactPHP, daemons). +Under PHP-FPM and CLI scripts, the PHP process exits at the end of each request and the curl handle dies with it. `idleTimeout` and `maxConnsPerHost` are accepted on the builder but have **no effect** on inter-request pooling in these runtimes. They take effect only in long-running PHP runtimes (Swoole, RoadRunner, ReactPHP, daemons). ### Out of scope diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 0700b87e..a9c735f6 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -259,9 +259,9 @@ private function getEnvVar(string $name): ?string } /** - * Resolve the HttpClient. If the user supplied one via httpClient(), - * return it as-is (escape hatch). Otherwise build a GuzzleHttpClient - * with the configured PoolConfig and emit the INFO log. + * Resolve the HttpClient. + * If the user supplied one via httpClient(), return it as-is (escape hatch). + * Otherwise build a GuzzleHttpClient with the configured PoolConfig and emit the INFO log. */ private function resolveHttpClient(): HttpClientInterface { @@ -288,8 +288,8 @@ private function resolveHttpClient(): HttpClientInterface } /** - * Emit one INFO log line via error_log(). Suppressed under PHPUnit to - * keep test output clean (PHPUNIT_RUNNING constant is set in phpunit.xml). + * Emit one INFO log line via error_log(). + * Suppressed under PHPUnit to keep test output clean (PHPUNIT_RUNNING constant is set in phpunit.xml). */ private function logInfo(string $message): void { diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index 56457ec2..33bb0db9 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -43,16 +43,16 @@ public function __construct(array $config = [], int $maxRetries = 3, ?PoolConfig 'http_errors' => false, // We'll handle errors ourselves 'curl' => [ // Cap concurrent connections opened by curl's internal multi handle. - // IdleTimeout has no direct Guzzle/curl analogue; honored only in - // long-running runtimes. For PHP-FPM, idle sockets die with the - // request. See the PHP-FPM note in the README/CHANGELOG. + // IdleTimeout has no direct Guzzle/curl analogue; honored only in long-running runtimes. + // For PHP-FPM, idle sockets die with the request. + // See the PHP-FPM note in the README/CHANGELOG. CURLOPT_MAXCONNECTS => $pool->maxConnsPerHost, CURLOPT_FORBID_REUSE => 0, // explicit: allow connection reuse (KeepAlive invariant) ], ]; - // User-supplied $config wins. array_replace_recursive preserves the - // user's curl array while filling in defaults we set above. + // User-supplied $config wins. + // array_replace_recursive preserves the user's curl array while filling in defaults we set above. $merged = array_replace_recursive($defaultConfig, $config); $this->client = new GuzzleClient($merged); @@ -91,8 +91,8 @@ public function request( 'headers' => $headers, ]; - // Per-call overrides. 'timeout' is the canonical key; any - // other valid Guzzle key is also forwarded. + // Per-call overrides. + // 'timeout' is the canonical key; any other valid Guzzle key is also forwarded. foreach ($options as $key => $value) { $requestOptions[$key] = $value; } diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php index fd188970..bd449245 100644 --- a/src/Http/HttpClientInterface.php +++ b/src/Http/HttpClientInterface.php @@ -20,10 +20,8 @@ interface HttpClientInterface * @param array $headers Request headers * @param mixed $body Request body (will be JSON encoded if array) * @param array $options Per-call options. Supported keys: - * - 'timeout' (int|float seconds): overrides the client's - * RequestTimeout for this single call. - * Implementations MAY accept additional keys but MUST - * silently ignore unknown ones. + * - 'timeout' (int|float seconds): overrides the client's RequestTimeout for this single call. + * Implementations MAY accept additional keys but MUST silently ignore unknown ones. * * @return StreamResponse * diff --git a/src/Http/PoolConfig.php b/src/Http/PoolConfig.php index dc0b2ccd..cf85181c 100644 --- a/src/Http/PoolConfig.php +++ b/src/Http/PoolConfig.php @@ -6,8 +6,8 @@ /** * Immutable value object for the 5 canonical HTTP connection-pool knobs. - * Defaults: 5 conns/host, 55s idle, 10s connect, 30s request. KeepAlive is an - * invariant (true). All durations are whole seconds. + * Defaults: 5 conns/host, 55s idle, 10s connect, 30s request. + * KeepAlive is an invariant (true). All durations are whole seconds. */ final class PoolConfig { From 4b829ec35e2383a4c4f4ad069d868164fcc644df Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 27 May 2026 12:24:47 +0200 Subject: [PATCH 15/20] docs: replace em dashes with plain ASCII in CHA-2956 content --- CHANGELOG.md | 8 ++++---- README.md | 2 +- tests/ClientBuilderTest.php | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a7b4b4..9ff24541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Connection pool configuration on `ClientBuilder` ([CHA-2956](https://linear.app/stream/issue/CHA-2956/connection-pooling)). Four new chained methods, all `int` seconds: - * `maxConnsPerHost(int)` — default `5` (curl `CURLOPT_MAXCONNECTS`) - * `idleTimeout(int)` — default `55` (no-op under PHP-FPM, see caveat below) - * `connectTimeout(int)` — default `10` (Guzzle `connect_timeout`) - * `requestTimeout(int)` — default `30` (Guzzle `timeout`) + * `maxConnsPerHost(int)` - default `5` (curl `CURLOPT_MAXCONNECTS`) + * `idleTimeout(int)` - default `55` (no-op under PHP-FPM, see caveat below) + * `connectTimeout(int)` - default `10` (Guzzle `connect_timeout`) + * `requestTimeout(int)` - default `30` (Guzzle `timeout`) - `GetStream\Http\PoolConfig` immutable value object holding the 5 canonical knobs. - `HttpClientInterface::request()` gains an optional 5th `array $options = []` parameter for per-call overrides (e.g., `['timeout' => 2]`). Backward-compatible. - INFO log on `ClientBuilder::build()` listing the effective pool config. Emitted via `error_log()`; suppressed in PHPUnit runs. diff --git a/README.md b/README.md index e981b03b..34653049 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ $response = $client->getHttpClient()->request( **PHP-FPM caveat:** Under PHP-FPM and CLI scripts the curl handle dies with the request, so `idleTimeout` and `maxConnsPerHost` have no inter-request effect. They take effect in long-running runtimes (Swoole, RoadRunner, ReactPHP, daemons). -**Escape hatch:** Passing your own client via `->httpClient($mine)` skips all 4 knobs — your client is used as-is. +**Escape hatch:** Passing your own client via `->httpClient($mine)` skips all 4 knobs; your client is used as-is. ## Code Generation diff --git a/tests/ClientBuilderTest.php b/tests/ClientBuilderTest.php index 12f39141..80d1f242 100644 --- a/tests/ClientBuilderTest.php +++ b/tests/ClientBuilderTest.php @@ -270,7 +270,7 @@ public function userSuppliedHttpClientBypassesBuild(): void { $mock = $this->createMock(HttpClientInterface::class); - // The mock must NEVER have request() called during build() — building + // The mock must NEVER have request() called during build(); building // should be a pure construction step, no probe requests. $mock->expects(self::never())->method('request'); From 9f7e319aa8d989168e117827df8ef0574332d5e1 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 27 May 2026 13:07:23 +0200 Subject: [PATCH 16/20] docs: replace em dashes with natural punctuation --- CHANGELOG.md | 8 ++++---- tests/WebhookTest.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff24541..94256c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,17 +39,17 @@ Under PHP-FPM and CLI scripts, the PHP process exits at the end of each request - Webhook handling spec helpers (CHA-2961): `UnknownEvent` class for forward-compat; `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload` primitives; `verifyAndParseWebhook` HTTP composite; `parseSqs` / `parseSns` - queue composites (no signature — backend emits no HMAC for queue messages today; + queue composites (no signature: backend emits no HMAC for queue messages today; trust is established via AWS IAM controls on the SQS queue / SNS topic). Transparent gzip via magic-byte detection. - New `GetStream\Webhook` namespace alias (preferred); `GetStream\Generated\Webhook` retained as backward-compat alias. PSR-4 shim (`src/Webhook.php`) ensures the canonical name resolves on first touch. -- New exception class: `GetStream\Exceptions\InvalidWebhookException` (unified — - covers signature mismatches, parse failures, decompression errors, etc.). +- New exception class: `GetStream\Exceptions\InvalidWebhookException` (unified, + covering signature mismatches, parse failures, decompression errors, etc.). - New `GetStream\Models\UnknownEvent` class. - New instance methods on `GetStream\Client`: `verifySignature($body, $signature)` - and `verifyAndParseWebhook($body, $signature)` — drop the api_secret parameter + and `verifyAndParseWebhook($body, $signature)` that drop the api_secret parameter in favor of the client's stored secret. Dual API: static methods remain available. - New instance methods on `GetStream\Client`: `parseSqs(string $messageBody)`, `parseSns(string $notificationBody)` (no signature; AWS IAM). diff --git a/tests/WebhookTest.php b/tests/WebhookTest.php index a0aeea65..f574f13f 100644 --- a/tests/WebhookTest.php +++ b/tests/WebhookTest.php @@ -573,7 +573,7 @@ public function testWebhookConformanceBadBase64(): void // Per CHA-3071 wire format: decodeSqsPayload falls back to raw bytes when // base64 decoding fails (uncompressed wire format). For input that is // neither valid base64 nor valid JSON nor gzip-prefixed, parseSqs still - // throws InvalidWebhookException — just down the chain at JSON parsing. + // throws InvalidWebhookException, just down the chain at JSON parsing. $dir = __DIR__ . '/fixtures/webhooks/_invalid/bad_base64'; if (!\is_dir($dir)) { $this->markTestSkipped('fixtures not present'); From aec18bb9c3f2d2b0d4e22f370bd528c7afbbce99 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 27 May 2026 14:02:58 +0200 Subject: [PATCH 17/20] docs: use colon separator in CHANGELOG option list --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94256c25..0fb3b7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Connection pool configuration on `ClientBuilder` ([CHA-2956](https://linear.app/stream/issue/CHA-2956/connection-pooling)). Four new chained methods, all `int` seconds: - * `maxConnsPerHost(int)` - default `5` (curl `CURLOPT_MAXCONNECTS`) - * `idleTimeout(int)` - default `55` (no-op under PHP-FPM, see caveat below) - * `connectTimeout(int)` - default `10` (Guzzle `connect_timeout`) - * `requestTimeout(int)` - default `30` (Guzzle `timeout`) + * `maxConnsPerHost(int)`: default `5` (curl `CURLOPT_MAXCONNECTS`) + * `idleTimeout(int)`: default `55` (no-op under PHP-FPM, see caveat below) + * `connectTimeout(int)`: default `10` (Guzzle `connect_timeout`) + * `requestTimeout(int)`: default `30` (Guzzle `timeout`) - `GetStream\Http\PoolConfig` immutable value object holding the 5 canonical knobs. - `HttpClientInterface::request()` gains an optional 5th `array $options = []` parameter for per-call overrides (e.g., `['timeout' => 2]`). Backward-compatible. - INFO log on `ClientBuilder::build()` listing the effective pool config. Emitted via `error_log()`; suppressed in PHPUnit runs. From 7b7700fbb5c6bb7b9656848b1f5e225d6e0ef5d2 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 27 May 2026 15:22:12 +0200 Subject: [PATCH 18/20] docs: clarify maxConnsPerHost is advisory under default Guzzle handler CURLOPT_MAXCONNECTS only sizes a single curl handle's connection cache; it does not enforce a hard per-host concurrency cap under Guzzle's default CurlHandler in any runtime. A real cap requires a shared CurlMultiHandler with CURLMOPT_MAX_HOST_CONNECTIONS, which this SDK does not wire. Reword CHANGELOG, README, and the related doc comments accordingly; keep the accurate idleTimeout PHP-FPM caveat. Also note per-call curl overrides replace (not merge) client curl options. --- CHANGELOG.md | 8 +++++--- README.md | 8 ++++++-- src/ClientBuilder.php | 6 +++++- src/Http/GuzzleHttpClient.php | 11 +++++++---- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb3b7d7..c856a366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Connection pool configuration on `ClientBuilder` ([CHA-2956](https://linear.app/stream/issue/CHA-2956/connection-pooling)). Four new chained methods, all `int` seconds: - * `maxConnsPerHost(int)`: default `5` (curl `CURLOPT_MAXCONNECTS`) + * `maxConnsPerHost(int)`: default `5` (advisory; sets curl `CURLOPT_MAXCONNECTS`, see caveat below) * `idleTimeout(int)`: default `55` (no-op under PHP-FPM, see caveat below) * `connectTimeout(int)`: default `10` (Guzzle `connect_timeout`) * `requestTimeout(int)`: default `30` (Guzzle `timeout`) @@ -23,9 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `GuzzleHttpClient::__construct()` gains an optional 3rd `?PoolConfig $pool` parameter. Existing callsites continue to work unchanged. -### PHP-FPM caveat +### Pooling caveats -Under PHP-FPM and CLI scripts, the PHP process exits at the end of each request and the curl handle dies with it. `idleTimeout` and `maxConnsPerHost` are accepted on the builder but have **no effect** on inter-request pooling in these runtimes. They take effect only in long-running PHP runtimes (Swoole, RoadRunner, ReactPHP, daemons). +`idleTimeout` (PHP-FPM): under PHP-FPM and CLI scripts, the PHP process exits at the end of each request and the curl handle dies with it. `idleTimeout` is accepted on the builder but has **no effect** on inter-request pooling in these runtimes. It takes effect only in long-running PHP runtimes (Swoole, RoadRunner, ReactPHP, daemons). + +`maxConnsPerHost` (advisory, all runtimes): this sets curl `CURLOPT_MAXCONNECTS`, which only sizes a single curl handle's own connection cache. Under Guzzle's default `CurlHandler` it does **not** enforce a hard per-host concurrency cap, in any runtime. A real cap requires the consumer to supply a shared `GuzzleHttp\Handler\CurlMultiHandler` configured with `CURLMOPT_MAX_HOST_CONNECTIONS` (via `->httpClient(...)`); the SDK does not wire one by default. ### Out of scope diff --git a/README.md b/README.md index 34653049..35370582 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ STREAM_BASE_URL=https://chat.stream-io-api.com $client = (new GetStream\ClientBuilder()) ->apiKey($apiKey) ->apiSecret($apiSecret) - ->maxConnsPerHost(5) // default 5 + ->maxConnsPerHost(5) // default 5 (advisory, see below) ->idleTimeout(55) // default 55s (no-op under PHP-FPM, see below) ->connectTimeout(10) // default 10s ->requestTimeout(30) // default 30s @@ -51,7 +51,11 @@ $response = $client->getHttpClient()->request( ); ``` -**PHP-FPM caveat:** Under PHP-FPM and CLI scripts the curl handle dies with the request, so `idleTimeout` and `maxConnsPerHost` have no inter-request effect. They take effect in long-running runtimes (Swoole, RoadRunner, ReactPHP, daemons). +Per-call `curl` overrides replace (do not merge with) the client-level `curl` options, since Guzzle unions options shallowly. Only `['timeout' => N]` is documented for per-call use. + +**`idleTimeout` (PHP-FPM caveat):** under PHP-FPM and CLI scripts the curl handle dies with the request, so `idleTimeout` has no inter-request effect. It takes effect in long-running runtimes (Swoole, RoadRunner, ReactPHP, daemons). + +**`maxConnsPerHost` (advisory):** this sets curl `CURLOPT_MAXCONNECTS`, which only sizes a single curl handle's own connection cache. Under Guzzle's default `CurlHandler` it does not enforce a hard per-host concurrency cap, in any runtime. For a real cap, supply your own client via `->httpClient(...)` backed by a shared `GuzzleHttp\Handler\CurlMultiHandler` configured with `CURLMOPT_MAX_HOST_CONNECTIONS`. **Escape hatch:** Passing your own client via `->httpClient($mine)` skips all 4 knobs; your client is used as-is. diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index a9c735f6..647fbe73 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -68,7 +68,11 @@ public function httpClient(HttpClientInterface $httpClient): self return $this; } - /** Cap concurrent TCP connections per host. Default: 5. Ignored when httpClient() is used. */ + /** + * Advisory hint for per-host connections. Default: 5. Ignored when httpClient() is used. + * Sets curl CURLOPT_MAXCONNECTS (a single curl handle's connection-cache size); under Guzzle's + * default CurlHandler this does not enforce a hard per-host concurrency cap in any runtime. + */ public function maxConnsPerHost(int $n): self { $this->pool = $this->pool->withMaxConnsPerHost($n); diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index 33bb0db9..d822d1a7 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -42,10 +42,13 @@ public function __construct(array $config = [], int $maxRetries = 3, ?PoolConfig 'connect_timeout' => $pool->connectTimeout, 'http_errors' => false, // We'll handle errors ourselves 'curl' => [ - // Cap concurrent connections opened by curl's internal multi handle. - // IdleTimeout has no direct Guzzle/curl analogue; honored only in long-running runtimes. - // For PHP-FPM, idle sockets die with the request. - // See the PHP-FPM note in the README/CHANGELOG. + // Advisory only: CURLOPT_MAXCONNECTS sizes this single curl handle's own + // connection cache. Under Guzzle's default CurlHandler it does NOT enforce a + // hard per-host concurrency cap (in any runtime); a real cap needs a shared + // CurlMultiHandler with CURLMOPT_MAX_HOST_CONNECTIONS, which we do not wire. + // idleTimeout has no direct Guzzle/curl analogue; honored only in long-running + // runtimes. Under PHP-FPM idle sockets die with the request. + // See the pooling caveats in the README/CHANGELOG. CURLOPT_MAXCONNECTS => $pool->maxConnsPerHost, CURLOPT_FORBID_REUSE => 0, // explicit: allow connection reuse (KeepAlive invariant) ], From b6cc0b533e16e3953832d08dccfe3e97f788f4e6 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 27 May 2026 16:22:31 +0200 Subject: [PATCH 19/20] fix: remove em dashes from webhook helper comments (CHA-2956) --- src/Generated/Webhook.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Generated/Webhook.php b/src/Generated/Webhook.php index a7ea75ea..1bfe3d34 100644 --- a/src/Generated/Webhook.php +++ b/src/Generated/Webhook.php @@ -639,7 +639,7 @@ public static function gunzipPayload(string $body): string * magic-byte detection decides whether to decompress. * * {@see self::parseSqs()} sits on top of this and works transparently for - * both wire formats — no caller code change, no flag, no header. + * both wire formats: no caller code change, no flag, no header. * * @throws InvalidWebhookException if gzip decompression fails (only when input has gzip magic prefix) */ @@ -647,7 +647,7 @@ public static function decodeSqsPayload(string $messageBody): string { $decoded = \base64_decode($messageBody, true); if ($decoded === false) { - // Not base64 — treat input as raw bytes (uncompressed wire format). + // Not base64, so treat input as raw bytes (uncompressed wire format). $decoded = $messageBody; } return self::gunzipPayload($decoded); @@ -659,7 +659,7 @@ public static function decodeSqsPayload(string $messageBody): string * string flows through. * * Heuristic: try to JSON-parse the input. If it yields an array with a - * string 'Message' field, that's the envelope shape — return the Message. + * string 'Message' field, that's the envelope shape; return the Message. * Otherwise the input is presumed to BE the pre-extracted Message * (base64-encoded bytes are not valid JSON, so this falls through cleanly). */ From b793a3208ef30dd42e58ddf8347601a23f842a5c Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Thu, 28 May 2026 11:30:16 +0200 Subject: [PATCH 20/20] feat: enforce maxConnsPerHost and idleTimeout via persistent CurlMultiHandler Switches the default Guzzle handler to a persistent CurlMultiHandler with CURLMOPT_MAX_HOST_CONNECTIONS, so maxConnsPerHost is a real per-host concurrency cap (not advisory). idleTimeout uses CURLOPT_MAXLIFETIME_CONN (libcurl 7.80.0+) to cycle pooled connections ahead of upstream LB idle close. Effective in long-running PHP runtimes when the SDK client is reused across requests; PHP-FPM still has no cross-request pool because the process exits per request, but per-call timeouts continue to apply. --- CHANGELOG.md | 10 +++-- README.md | 8 ++-- src/Http/GuzzleHttpClient.php | 50 ++++++++++++++++++------- tests/Http/GuzzleHttpClientPoolTest.php | 34 ++++++++++++++++- 4 files changed, 77 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c856a366..1d8e3b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Connection pool configuration on `ClientBuilder` ([CHA-2956](https://linear.app/stream/issue/CHA-2956/connection-pooling)). Four new chained methods, all `int` seconds: - * `maxConnsPerHost(int)`: default `5` (advisory; sets curl `CURLOPT_MAXCONNECTS`, see caveat below) - * `idleTimeout(int)`: default `55` (no-op under PHP-FPM, see caveat below) + * `maxConnsPerHost(int)`: default `5` (per-host concurrency cap via curl `CURLMOPT_MAX_HOST_CONNECTIONS` on a persistent multi handle; effective in long-running PHP runtimes) + * `idleTimeout(int)`: default `55` (per-connection lifetime cap via `CURLOPT_MAXLIFETIME_CONN`; effective in long-running PHP runtimes; requires libcurl 7.80.0 or later) * `connectTimeout(int)`: default `10` (Guzzle `connect_timeout`) * `requestTimeout(int)`: default `30` (Guzzle `timeout`) - `GetStream\Http\PoolConfig` immutable value object holding the 5 canonical knobs. @@ -25,9 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Pooling caveats -`idleTimeout` (PHP-FPM): under PHP-FPM and CLI scripts, the PHP process exits at the end of each request and the curl handle dies with it. `idleTimeout` is accepted on the builder but has **no effect** on inter-request pooling in these runtimes. It takes effect only in long-running PHP runtimes (Swoole, RoadRunner, ReactPHP, daemons). +`maxConnsPerHost` and `idleTimeout` are enforced via libcurl's multi-handle pool (`CURLMOPT_MAX_HOST_CONNECTIONS`) and connection lifetime cap (`CURLOPT_MAXLIFETIME_CONN`). They take effect only when the SDK client is reused across requests within a single PHP process: long-running runtimes such as Swoole, RoadRunner, ReactPHP, and CLI daemons. Instantiate the SDK client once and reuse it. -`maxConnsPerHost` (advisory, all runtimes): this sets curl `CURLOPT_MAXCONNECTS`, which only sizes a single curl handle's own connection cache. Under Guzzle's default `CurlHandler` it does **not** enforce a hard per-host concurrency cap, in any runtime. A real cap requires the consumer to supply a shared `GuzzleHttp\Handler\CurlMultiHandler` configured with `CURLMOPT_MAX_HOST_CONNECTIONS` (via `->httpClient(...)`); the SDK does not wire one by default. +Under PHP-FPM (and one-shot CLI scripts) the PHP process exits at the end of each request, taking the multi handle and any pooled connections with it. The per-call request and connect timeouts still apply; pool sizing and idle cycling have no cross-request effect because there is nothing to keep alive between requests. + +`CURLOPT_MAXLIFETIME_CONN` requires libcurl 7.80.0 (Nov 2021) or later. If your PHP build links against an older libcurl, pooling still works without active lifetime cycling. ### Out of scope diff --git a/README.md b/README.md index 35370582..28593f35 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ STREAM_BASE_URL=https://chat.stream-io-api.com $client = (new GetStream\ClientBuilder()) ->apiKey($apiKey) ->apiSecret($apiSecret) - ->maxConnsPerHost(5) // default 5 (advisory, see below) - ->idleTimeout(55) // default 55s (no-op under PHP-FPM, see below) + ->maxConnsPerHost(5) // default 5 (per-host concurrency cap, see runtime caveats) + ->idleTimeout(55) // default 55s (per-connection lifetime cap, see runtime caveats) ->connectTimeout(10) // default 10s ->requestTimeout(30) // default 30s ->build(); @@ -53,9 +53,7 @@ $response = $client->getHttpClient()->request( Per-call `curl` overrides replace (do not merge with) the client-level `curl` options, since Guzzle unions options shallowly. Only `['timeout' => N]` is documented for per-call use. -**`idleTimeout` (PHP-FPM caveat):** under PHP-FPM and CLI scripts the curl handle dies with the request, so `idleTimeout` has no inter-request effect. It takes effect in long-running runtimes (Swoole, RoadRunner, ReactPHP, daemons). - -**`maxConnsPerHost` (advisory):** this sets curl `CURLOPT_MAXCONNECTS`, which only sizes a single curl handle's own connection cache. Under Guzzle's default `CurlHandler` it does not enforce a hard per-host concurrency cap, in any runtime. For a real cap, supply your own client via `->httpClient(...)` backed by a shared `GuzzleHttp\Handler\CurlMultiHandler` configured with `CURLMOPT_MAX_HOST_CONNECTIONS`. +**Runtime caveats.** `maxConnsPerHost` and `idleTimeout` are enforced via libcurl's persistent multi-handle pool (`CURLMOPT_MAX_HOST_CONNECTIONS` and `CURLOPT_MAXLIFETIME_CONN`). They take effect only when the SDK client is reused across requests within a single PHP process: long-running runtimes such as Swoole, RoadRunner, ReactPHP, and CLI daemons. Instantiate the SDK client once and reuse it. Under PHP-FPM (and one-shot CLI scripts) the PHP process exits at the end of each request, so there is no cross-request pool to size; the per-call request and connect timeouts still apply. `idleTimeout` requires libcurl 7.80.0 (Nov 2021) or later; pooling still works without it on older builds, just without active lifetime cycling. **Escape hatch:** Passing your own client via `->httpClient($mine)` skips all 4 knobs; your client is used as-is. diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index d822d1a7..8d5fa205 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -11,6 +11,8 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\ServerException; +use GuzzleHttp\Handler\CurlMultiHandler; +use GuzzleHttp\HandlerStack; use Psr\Http\Message\ResponseInterface; /** @@ -37,25 +39,45 @@ public function __construct(array $config = [], int $maxRetries = 3, ?PoolConfig { $pool = $pool ?? new PoolConfig(); + // Persistent multi handle: routes every request (sync and async) through one + // libcurl multi handle that holds a connection pool for the lifetime of this + // GuzzleHttpClient instance. CURLMOPT_MAX_HOST_CONNECTIONS is a real per-host + // concurrency cap inside the multi handle; CURLMOPT_MAXCONNECTS sizes the + // multi handle's overall connection cache. Pooling takes effect only when this + // instance is reused across requests (long-running runtimes: Swoole, RoadRunner, + // ReactPHP, CLI daemons). Under PHP-FPM the PHP process exits per request and + // the multi handle dies with it; the per-call request/connect timeouts still + // apply but there is no cross-request connection reuse. + $multi = new CurlMultiHandler([ + 'options' => [ + CURLMOPT_MAX_HOST_CONNECTIONS => $pool->maxConnsPerHost, + CURLMOPT_MAXCONNECTS => $pool->maxConnsPerHost, + ], + ]); + $defaultHandler = HandlerStack::create($multi); + + $curlOptions = [ + CURLOPT_FORBID_REUSE => 0, // KeepAlive invariant: allow connection reuse. + ]; + // CURLOPT_MAXLIFETIME_CONN (libcurl >= 7.80.0) caps how long a pooled connection + // can be reused since it was created. We use it to recycle connections ahead of + // the upstream load balancer's idle close window. If the constant is not present + // on this PHP build, pooling still works without active lifetime capping. + if (defined('CURLOPT_MAXLIFETIME_CONN')) { + $curlOptions[\constant('CURLOPT_MAXLIFETIME_CONN')] = $pool->idleTimeout; + } + $defaultConfig = [ 'timeout' => $pool->requestTimeout, 'connect_timeout' => $pool->connectTimeout, - 'http_errors' => false, // We'll handle errors ourselves - 'curl' => [ - // Advisory only: CURLOPT_MAXCONNECTS sizes this single curl handle's own - // connection cache. Under Guzzle's default CurlHandler it does NOT enforce a - // hard per-host concurrency cap (in any runtime); a real cap needs a shared - // CurlMultiHandler with CURLMOPT_MAX_HOST_CONNECTIONS, which we do not wire. - // idleTimeout has no direct Guzzle/curl analogue; honored only in long-running - // runtimes. Under PHP-FPM idle sockets die with the request. - // See the pooling caveats in the README/CHANGELOG. - CURLOPT_MAXCONNECTS => $pool->maxConnsPerHost, - CURLOPT_FORBID_REUSE => 0, // explicit: allow connection reuse (KeepAlive invariant) - ], + 'http_errors' => false, // We handle errors ourselves. + 'handler' => $defaultHandler, + 'curl' => $curlOptions, ]; - // User-supplied $config wins. - // array_replace_recursive preserves the user's curl array while filling in defaults we set above. + // User-supplied $config wins. array_replace_recursive lets callers override the + // handler (used by tests with MockHandler) or extend the curl options without + // wiping our defaults. $merged = array_replace_recursive($defaultConfig, $config); $this->client = new GuzzleClient($merged); diff --git a/tests/Http/GuzzleHttpClientPoolTest.php b/tests/Http/GuzzleHttpClientPoolTest.php index 31e3ed2c..6354f082 100644 --- a/tests/Http/GuzzleHttpClientPoolTest.php +++ b/tests/Http/GuzzleHttpClientPoolTest.php @@ -46,7 +46,11 @@ public function appliesPoolConfigToGuzzleDefaults(): void $opts = $this->history[0]['options']; self::assertSame(25, $opts['timeout'] ?? null); self::assertSame(4, $opts['connect_timeout'] ?? null); - self::assertSame(8, $opts['curl'][CURLOPT_MAXCONNECTS] ?? null); + // idleTimeout is enforced via libcurl's per-connection lifetime cap (CURLOPT_MAXLIFETIME_CONN). + // Available since libcurl 7.80.0; if the constant is unavailable, pooling still works without it. + if (defined('CURLOPT_MAXLIFETIME_CONN')) { + self::assertSame(40, $opts['curl'][\constant('CURLOPT_MAXLIFETIME_CONN')] ?? null); + } } /** @test */ @@ -58,7 +62,33 @@ public function defaultsApplyWhenNoPoolConfigPassed(): void $opts = $this->history[0]['options']; self::assertSame(30, $opts['timeout'] ?? null); self::assertSame(10, $opts['connect_timeout'] ?? null); - self::assertSame(5, $opts['curl'][CURLOPT_MAXCONNECTS] ?? null); + if (defined('CURLOPT_MAXLIFETIME_CONN')) { + self::assertSame(55, $opts['curl'][\constant('CURLOPT_MAXLIFETIME_CONN')] ?? null); + } + } + + /** @test */ + public function defaultHandlerIsPersistentCurlMultiHandler(): void + { + // When no handler is supplied, the default stack must wrap a CurlMultiHandler + // so the per-host connection cap (CURLMOPT_MAX_HOST_CONNECTIONS) is real + // across requests in long-running runtimes. + $sdk = new GuzzleHttpClient([], 0, new PoolConfig()); + + $clientProp = (new \ReflectionObject($sdk))->getProperty('client'); + $clientProp->setAccessible(true); + $guzzle = $clientProp->getValue($sdk); + + $configProp = (new \ReflectionObject($guzzle))->getProperty('config'); + $configProp->setAccessible(true); + $config = $configProp->getValue($guzzle); + + self::assertInstanceOf(HandlerStack::class, $config['handler']); + + $stack = $config['handler']; + $handlerProp = (new \ReflectionObject($stack))->getProperty('handler'); + $handlerProp->setAccessible(true); + self::assertInstanceOf(\GuzzleHttp\Handler\CurlMultiHandler::class, $handlerProp->getValue($stack)); } /** @test */