[HttpClient] Add support of the persistent cURL handles

This commit is contained in:
Kostiantyn Miakshyn
2025-12-12 01:26:27 +01:00
committed by Nicolas Grekas
parent bf7a2a5fc6
commit bd9f6c7e36
6 changed files with 116 additions and 50 deletions

View File

@@ -29,6 +29,8 @@
<errorLevel type="suppress">
<!-- These classes have been added in PHP 8.4 -->
<referencedClass name="BcMath\Number"/>
<!-- These classes have been added in PHP 8.5 -->
<referencedClass name="CurlSharePersistentHandle"/>
</errorLevel>
</UndefinedClass>
<UnusedClass>
@@ -49,6 +51,12 @@
<directory name="src/Symfony" />
</errorLevel>
</UnusedConstructor>
<UndefinedFunction>
<errorLevel type="suppress">
<!-- These functions have been added in PHP 8.5 -->
<referencedFunction name="curl_share_init_persistent"/>
</errorLevel>
</UndefinedFunction>
</issueHandlers>
<forbiddenFunctions>

View File

@@ -5,6 +5,7 @@ CHANGELOG
---
* Add support for the `max_connect_duration` option
* Add option `extra.use_persistent_connections` to `CurlHttpClient` to control the use of persistent connections introduced in PHP 8.5
8.0
---

View File

@@ -41,10 +41,13 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface,
];
private array $defaultOptions = self::OPTIONS_DEFAULTS + [
'auth_ntlm' => null, // array|string - an array containing the username as first value, and optionally the
// password as the second one; or string like username:password - enabling NTLM auth
// array|string - an array containing the username as first value, and optionally the
// password as the second one; or string like username:password - enabling NTLM auth
'auth_ntlm' => null,
'extra' => [
'curl' => [], // A list of extra curl options indexed by their corresponding CURLOPT_*
'use_persistent_connections' => false,
// A list of extra curl options indexed by their corresponding CURLOPT_*
'curl' => [],
],
];
private static array $emptyDefaults = self::OPTIONS_DEFAULTS + ['auth_ntlm' => null];
@@ -313,7 +316,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface,
if (!$pushedResponse) {
$ch = curl_init();
$this->logger?->info(\sprintf('Request: "%s %s"', $method, $url));
$curlopts += [\CURLOPT_SHARE => $this->multi->share];
$curlopts += [\CURLOPT_SHARE => ($options['extra']['use_persistent_connections'] ?? false) ? $this->multi->share : $this->multi->persistentShare];
}
foreach ($curlopts as $opt => $value) {

View File

@@ -25,6 +25,7 @@ final class CurlClientState extends ClientState
{
public ?\CurlMultiHandle $handle;
public ?\CurlShareHandle $share;
public \CurlShareHandle|\CurlSharePersistentHandle|null $persistentShare;
public bool $performing = false;
/** @var PushedResponse[] */
@@ -44,8 +45,8 @@ final class CurlClientState extends ClientState
self::$curlVersion ??= curl_version();
$this->dnsCache = new DnsCache();
// handle and share are initialized lazily in __get()
unset($this->handle, $this->share);
// handle, share and persistentShare are initialized lazily in __get()
unset($this->handle, $this->share, $this->persistentShare);
}
public function reset(): void
@@ -64,15 +65,23 @@ final class CurlClientState extends ClientState
public function __get(string $name): mixed
{
if ('persistentShare' === $name) {
if (\PHP_VERSION_ID < 80500) {
return $this->persistentShare = $this->share;
}
return $this->persistentShare = curl_share_init_persistent([
\CURL_LOCK_DATA_DNS,
\CURL_LOCK_DATA_SSL_SESSION,
\CURL_LOCK_DATA_CONNECT,
]);
}
if ('share' === $name) {
$this->share = curl_share_init();
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS);
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION);
if (\defined('CURL_LOCK_DATA_CONNECT')) {
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT);
}
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT);
return $this->share;
}
@@ -98,6 +107,7 @@ final class CurlClientState extends ClientState
$multi = clone $this;
$multi->handle = null;
$multi->share = null;
$multi->persistentShare = null;
$multi->pushedResponses = &$this->pushedResponses;
$multi->logger = &$this->logger;
$multi->handlesActivity = &$this->handlesActivity;

View File

@@ -22,15 +22,16 @@ class CurlHttpClientTest extends HttpClientTestCase
{
protected function getHttpClient(string $testCase): CurlHttpClient
{
$usePersistentConnections = str_contains($testCase, 'Persistent');
if (!str_contains($testCase, 'Push')) {
return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false]);
return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false, 'extra' => ['use_persistent_connections' => $usePersistentConnections]]);
}
if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073D00 > ($v = curl_version())['version_number'] || !(\CURL_VERSION_HTTP2 & $v['features'])) {
$this->markTestSkipped('curl <7.61 is used or it is not compiled with support for HTTP/2 PUSH');
}
return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false], 6, 50);
return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false, 'extra' => ['use_persistent_connections' => $usePersistentConnections]], 6, 50);
}
public function testTimeoutIsNotAFatalError()
@@ -82,6 +83,24 @@ class CurlHttpClientTest extends HttpClientTestCase
self::assertInstanceOf(\CurlShareHandle::class, $state->share);
}
public function testCurlClientPersistentStateInitializesHandlesLazily()
{
$client = $this->getHttpClient(__FUNCTION__);
$r = new \ReflectionProperty($client, 'multi');
$state = $r->getValue($client);
self::assertFalse(isset($state->handle));
self::assertFalse(isset($state->share));
self::assertFalse(isset($state->persistentShare));
$client->request('GET', 'http://127.0.0.1:8057/json')->getStatusCode();
self::assertInstanceOf(\CurlMultiHandle::class, $state->handle);
self::assertInstanceOf(\CurlShareHandle::class, $state->share);
self::assertInstanceOf(\PHP_VERSION_ID >= 80500 ? \CurlSharePersistentHandle::class : \CurlShareHandle::class, $state->persistentShare);
}
public function testProcessAfterReset()
{
$client = $this->getHttpClient(__FUNCTION__);

View File

@@ -24,41 +24,61 @@ use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;
interface HttpClientInterface
{
public const OPTIONS_DEFAULTS = [
'auth_basic' => null, // array|string - an array containing the username as first value, and optionally the
// password as the second one; or string like username:password - enabling HTTP Basic
// authentication (RFC 7617)
'auth_bearer' => null, // string - a token enabling HTTP Bearer authorization (RFC 6750)
'query' => [], // string[] - associative array of query string values to merge with the request's URL
'headers' => [], // iterable|string[]|string[][] - headers names provided as keys or as part of values
'body' => '', // array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string
// smaller than the amount requested as argument; the empty string signals EOF; if
// an array is passed, it is meant as a form payload of field names and values
'json' => null, // mixed - if set, implementations MUST set the "body" option to the JSON-encoded
// value and set the "content-type" header to a JSON-compatible value if it is not
// explicitly defined in the headers option - typically "application/json"
'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that
// MUST be available via $response->getInfo('user_data') - not used internally
'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower than or equal to 0
// means redirects should not be followed; "Authorization" and "Cookie" headers MUST
// NOT follow except for the initial host name
'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0
'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2
'buffer' => true, // bool|resource|\Closure - whether the content of the response should be buffered or not,
// or a stream resource where the response body should be written,
// or a closure telling if/where the response should be buffered based on its headers
'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort the
// request; it MUST be called on connection, on headers and on completion; it SHOULD be
// called on upload/download of data and at least 1/s
'resolve' => [], // string[] - a map of host to IP address that SHOULD replace DNS resolution
'proxy' => null, // string - by default, the proxy-related env vars handled by curl SHOULD be honored
'no_proxy' => null, // string - a comma separated list of hosts that do not require a proxy to be reached
'timeout' => null, // float - the idle timeout (in seconds) - defaults to ini_get('default_socket_timeout')
'max_duration' => 0, // float - the maximum execution time (in seconds) for the request+response as a whole;
// a value lower than or equal to 0 means it is unlimited
'max_connect_duration' => 0, // float - the maximum duration (in seconds) allowed for DNS + TCP + TLS connection;
// a value lower than or equal to 0 means unlimited, as long as option timeout is respected
'bindto' => '0', // string - the interface or the local socket to bind to
'verify_peer' => true, // see https://php.net/context.ssl for the following options
// array|string - an array containing the username as first value, and optionally the
// password as the second one; or string like username:password - enabling HTTP Basic
// authentication (RFC 7617)
'auth_basic' => null,
// string - a token enabling HTTP Bearer authorization (RFC 6750)
'auth_bearer' => null,
// string[] - associative array of query string values to merge with the request's URL
'query' => [],
// iterable|string[]|string[][] - headers names provided as keys or as part of values
'headers' => [],
// array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string
// smaller than the amount requested as argument; the empty string signals EOF; if
// an array is passed, it is meant as a form payload of field names and values
'body' => '',
// mixed - if set, implementations MUST set the "body" option to the JSON-encoded
// value and set the "content-type" header to a JSON-compatible value if it is not
// explicitly defined in the headers option - typically "application/json"
'json' => null,
// mixed - any extra data to attach to the request (scalar, callable, object...) that
// MUST be available via $response->getInfo('user_data') - not used internally
'user_data' => null,
// int - the maximum number of redirects to follow; a value lower than or equal to 0
// means redirects should not be followed; "Authorization" and "Cookie" headers MUST
// NOT follow except for the initial host name
'max_redirects' => 20,
// string - defaults to the best supported version, typically 1.1 or 2.0
'http_version' => null,
// string - the URI to resolve relative URLs, following rules in RFC 3986, section 2
'base_uri' => null,
// bool|resource|\Closure - whether the content of the response should be buffered or not,
// or a stream resource where the response body should be written,
// or a closure telling if/where the response should be buffered based on its headers
'buffer' => true,
// callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort the
// request; it MUST be called on connection, on headers and on completion; it SHOULD be
// called on upload/download of data and at least 1/s
'on_progress' => null,
// string[] - a map of host to IP address that SHOULD replace DNS resolution
'resolve' => [],
// string - by default, the proxy-related env vars handled by curl SHOULD be honored
'proxy' => null,
// string - a comma separated list of hosts that do not require a proxy to be reached
'no_proxy' => null,
// float - the idle timeout (in seconds) - defaults to ini_get('default_socket_timeout')
'timeout' => null,
// float - the maximum execution time (in seconds) for the request+response as a whole;
// a value lower than or equal to 0 means it is unlimited
'max_duration' => 0,
// float - the maximum duration (in seconds) allowed for DNS + TCP + TLS connection;
// a value lower than or equal to 0 means unlimited, as long as option timeout is respected
'max_connect_duration' => 0,
// string - the interface or the local socket to bind to
'bindto' => '0',
// see https://php.net/context.ssl for the following options
'verify_peer' => true,
'verify_host' => true,
'cafile' => null,
'capath' => null,
@@ -68,8 +88,13 @@ interface HttpClientInterface
'ciphers' => null,
'peer_fingerprint' => null,
'capture_peer_cert_chain' => false,
'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, // STREAM_CRYPTO_METHOD_TLSv*_CLIENT - minimum TLS version
'extra' => [], // array - additional options that can be ignored if unsupported, unlike regular options
// STREAM_CRYPTO_METHOD_TLSv*_CLIENT - minimum TLS version
'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
// array - additional options that can be ignored if unsupported, unlike regular options
'extra' => [
// bool - whether to use persistent connections where supported
'use_persistent_connections' => false,
],
];
/**