diff --git a/composer.json b/composer.json
index d064c57e2dc..165f3890ca3 100644
--- a/composer.json
+++ b/composer.json
@@ -138,7 +138,7 @@
"doctrine/orm": "^3.4",
"dragonmantank/cron-expression": "^3.1",
"egulias/email-validator": "^2.1.10|^3.1|^4",
- "guzzlehttp/promises": "^1.4|^2.0",
+ "guzzlehttp/guzzle": "^7.10",
"jolicode/jolinotif": "^2.7.2|^3.0",
"jsonpath-standard/jsonpath-compliance-test-suite": "*",
"league/html-to-markdown": "^5.0",
diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md
index 99985dbb7f9..cc866ef010d 100644
--- a/src/Symfony/Component/HttpClient/CHANGELOG.md
+++ b/src/Symfony/Component/HttpClient/CHANGELOG.md
@@ -6,6 +6,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
+ * Add `GuzzleHttpHandler` that allows using Symfony HttpClient as a Guzzle handler
8.0
---
diff --git a/src/Symfony/Component/HttpClient/GuzzleHttpHandler.php b/src/Symfony/Component/HttpClient/GuzzleHttpHandler.php
new file mode 100644
index 00000000000..5de9176d10b
--- /dev/null
+++ b/src/Symfony/Component/HttpClient/GuzzleHttpHandler.php
@@ -0,0 +1,639 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient;
+
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Promise\Promise;
+use GuzzleHttp\Promise\PromiseInterface;
+use GuzzleHttp\Promise\Utils as PromiseUtils;
+use GuzzleHttp\Psr7\Response as GuzzleResponse;
+use GuzzleHttp\Psr7\Utils as Psr7Utils;
+use GuzzleHttp\TransferStats;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyResponseInterface;
+
+/**
+ * A Guzzle handler that uses Symfony's HttpClientInterface as its transport.
+ *
+ * This lets SDKs tightly coupled to Guzzle benefit from Symfony HttpClient's
+ * features (e.g. retry logic, tracing, scoping, mocking) by plugging this
+ * handler into a Guzzle client:
+ *
+ * $handler = new GuzzleHttpHandler(HttpClient::create());
+ * $guzzle = new \GuzzleHttp\Client(['handler' => $handler]);
+ *
+ * The handler is truly asynchronous: __invoke() returns a *pending* Promise
+ * immediately without performing any I/O. The actual work is driven by
+ * Symfony's HttpClientInterface::stream(), which multiplexes all in-flight
+ * requests together - the same approach CurlMultiHandler takes with
+ * curl_multi_*. Waiting on any single promise drives the whole pool so
+ * concurrent requests benefit from parallelism automatically.
+ *
+ * Guzzle request options are mapped to their Symfony equivalents as faithfully
+ * as possible; unsupported options are silently ignored so that existing SDK
+ * option sets do not cause errors.
+ *
+ * @author Nicolas Grekas
+ */
+final class GuzzleHttpHandler
+{
+ private readonly HttpClientInterface $client;
+
+ /**
+ * Maps each Symfony response (key) to a 3-tuple:
+ * [Psr7 RequestInterface, Guzzle options array, Guzzle Promise]
+ *
+ * @var \SplObjectStorage
+ */
+ private readonly \SplObjectStorage $pending;
+
+ /**
+ * PSR-7 response created eagerly on the first chunk so that the same
+ * instance is passed to on_headers and later resolved by the promise.
+ *
+ * @var \SplObjectStorage
+ */
+ private readonly \SplObjectStorage $psr7Responses;
+
+ private readonly bool $autoUpgradeHttpVersion;
+
+ public function __construct(?HttpClientInterface $client = null, bool $autoUpgradeHttpVersion = true)
+ {
+ $this->client = $client ?? HttpClient::create();
+ $this->autoUpgradeHttpVersion = $autoUpgradeHttpVersion;
+ $this->pending = new \SplObjectStorage();
+ $this->psr7Responses = new \SplObjectStorage();
+ }
+
+ /**
+ * Returns a *pending* Promise - no I/O is performed here.
+ *
+ * The wait function passed to the Promise drives Symfony's stream() loop,
+ * which resolves all currently queued requests concurrently.
+ */
+ public function __invoke(RequestInterface $request, array $options): PromiseInterface
+ {
+ $symfonyOptions = $this->buildSymfonyOptions($request, $options);
+
+ try {
+ $symfonyResponse = $this->client->request($request->getMethod(), (string) $request->getUri(), $symfonyOptions);
+ } catch (\Exception $e) {
+ // Option validation errors surface here synchronously.
+ $p = new Promise();
+ $p->reject($e);
+
+ return $p;
+ }
+
+ $promise = new Promise(
+ function () use ($symfonyResponse): void {
+ $this->streamPending(null, $symfonyResponse);
+ },
+ function () use ($symfonyResponse): void {
+ unset($this->pending[$symfonyResponse], $this->psr7Responses[$symfonyResponse]);
+ $symfonyResponse->cancel();
+ },
+ );
+
+ $this->pending[$symfonyResponse] = [$request, $options, $promise];
+
+ if (isset($options['delay'])) {
+ $pause = $symfonyResponse->getInfo('pause_handler');
+ if (\is_callable($pause)) {
+ $pause($options['delay'] / 1000.0);
+ } else {
+ usleep((int) ($options['delay'] * 1000));
+ }
+ }
+
+ return $promise;
+ }
+
+ /**
+ * Ticks the event loop: processes available I/O and runs queued tasks.
+ *
+ * @param float $timeout Maximum time in seconds to wait for network activity (0 = non-blocking)
+ */
+ public function tick(float $timeout = 1.0): void
+ {
+ $queue = PromiseUtils::queue();
+
+ // Push streaming work onto the Guzzle task queue so that .then()
+ // callbacks and other queued tasks get cooperative scheduling.
+ $queue->add(fn () => $this->streamPending($timeout, true));
+
+ $queue->run();
+ }
+
+ /**
+ * Runs until all outstanding connections have completed.
+ */
+ public function execute(): void
+ {
+ while ($this->pending->count()) {
+ $this->streamPending(null, false);
+ }
+ }
+
+ /**
+ * Performs one pass of streaming I/O over all pending responses.
+ *
+ * @param float|null $timeout Idle timeout passed to stream(); 0 for non-blocking, null for default
+ */
+ private function streamPending(?float $timeout, bool|SymfonyResponseInterface $breakAfter): void
+ {
+ if (!$this->pending->count()) {
+ return;
+ }
+
+ $queue = PromiseUtils::queue();
+
+ $responses = [];
+ foreach ($this->pending as $r) {
+ $responses[] = $r;
+ }
+
+ foreach ($this->client->stream($responses, $timeout) as $response => $chunk) {
+ try {
+ if ($chunk->isTimeout()) {
+ continue;
+ }
+
+ if ($chunk->isFirst()) {
+ // Deactivate 4xx/5xx exception throwing for this response;
+ // Guzzle's http_errors middleware handles that layer.
+ $response->getStatusCode();
+
+ [, $guzzleOpts] = $this->pending[$response] ?? [null, []];
+ $sink = $guzzleOpts['sink'] ?? null;
+ $body = Psr7Utils::streamFor(\is_string($sink) ? fopen($sink, 'w+') : ($sink ?? fopen('php://temp', 'r+')));
+
+ if (600 <= $response->getStatusCode()) {
+ $psrResponse = new GuzzleResponse(567, $response->getHeaders(false), $body);
+ (new \ReflectionProperty($psrResponse, 'statusCode'))->setValue($psrResponse, $response->getStatusCode());
+ } else {
+ $psrResponse = new GuzzleResponse($response->getStatusCode(), $response->getHeaders(false), $body);
+ }
+ $this->psr7Responses[$response] = $psrResponse;
+
+ if (isset($guzzleOpts['on_headers'])) {
+ try {
+ ($guzzleOpts['on_headers'])($psrResponse);
+ } catch (\Throwable $e) {
+ [$guzzleRequest, , $promise] = $this->pending[$response];
+ unset($this->pending[$response], $this->psr7Responses[$response]);
+ $this->fireOnStats($guzzleOpts, $guzzleRequest, $psrResponse, $e, $response);
+ $promise->reject(new RequestException($e->getMessage(), $guzzleRequest, $psrResponse, $e));
+
+ $response->cancel();
+ }
+ }
+ }
+
+ $content = $chunk->getContent();
+ if ('' !== $content && isset($this->psr7Responses[$response])) {
+ $this->psr7Responses[$response]->getBody()->write($content);
+ }
+
+ if (!$chunk->isLast()) {
+ if (true === $breakAfter) {
+ break;
+ }
+ continue;
+ }
+ if (!isset($this->pending[$response])) {
+ unset($this->psr7Responses[$response]);
+ } else {
+ $this->resolveResponse($response);
+ }
+ if (\in_array($breakAfter, [true, $response], true)) {
+ break;
+ }
+ } catch (TransportExceptionInterface $e) {
+ if (isset($this->pending[$response])) {
+ $this->rejectResponse($response, $e);
+ } else {
+ unset($this->psr7Responses[$response]);
+ }
+ if (\in_array($breakAfter, [true, $response], true)) {
+ break;
+ }
+ } finally {
+ // Run .then() callbacks; they may add new entries to $this->pending.
+ $queue->run();
+ }
+ }
+ }
+
+ private function resolveResponse(SymfonyResponseInterface $response): void
+ {
+ [$guzzleRequest, $options, $promise] = $this->pending[$response];
+ $psrResponse = $this->psr7Responses[$response];
+ unset($this->pending[$response], $this->psr7Responses[$response]);
+
+ $body = $psrResponse->getBody();
+ if ($body->isSeekable()) {
+ try {
+ $body->seek(0);
+ } catch (\RuntimeException) {
+ // ignore
+ }
+ }
+
+ $this->fireOnStats($options, $guzzleRequest, $psrResponse, null, $response);
+ $promise->resolve($psrResponse);
+ }
+
+ private function rejectResponse(SymfonyResponseInterface $response, TransportExceptionInterface $e): void
+ {
+ [$guzzleRequest, $options, $promise] = $this->pending[$response];
+ $psrResponse = $this->psr7Responses[$response] ?? null;
+ unset($this->pending[$response], $this->psr7Responses[$response]);
+
+ if ($body = $psrResponse?->getBody()) {
+ // Headers were already received: use RequestException so Guzzle middleware (e.g. retry)
+ // can distinguish a mid-stream failure from a connection-level one.
+ if ($body->isSeekable()) {
+ try {
+ $body->seek(0);
+ } catch (\RuntimeException) {
+ // ignore
+ }
+ }
+
+ $this->fireOnStats($options, $guzzleRequest, $psrResponse, $e, $response);
+ $promise->reject(new RequestException($e->getMessage(), $guzzleRequest, $psrResponse, $e));
+ } else {
+ // No headers received: connection-level failure.
+ $this->fireOnStats($options, $guzzleRequest, null, $e, $response);
+ $promise->reject(new ConnectException($e->getMessage(), $guzzleRequest, null, [], $e));
+ }
+ }
+
+ private function fireOnStats(array $options, RequestInterface $request, ?ResponseInterface $psrResponse, ?\Throwable $error, SymfonyResponseInterface $symfonyResponse): void
+ {
+ if (!isset($options['on_stats'])) {
+ return;
+ }
+
+ $handlerStats = $symfonyResponse->getInfo();
+ ($options['on_stats'])(new TransferStats($request, $psrResponse, $handlerStats['total_time'] ?? 0.0, $error, $handlerStats));
+ }
+
+ private function buildSymfonyOptions(RequestInterface $request, array $guzzleOptions): array
+ {
+ $options = [];
+
+ $options['headers'] = $this->extractHeaders($request, $guzzleOptions);
+
+ $this->applyBody($request, $options);
+ $this->applyAuth($guzzleOptions, $options);
+ $this->applyTimeouts($guzzleOptions, $options);
+ $this->applySsl($guzzleOptions, $options);
+ $this->applyProxy($request, $guzzleOptions, $options);
+ $this->applyRedirects($guzzleOptions, $options);
+ $this->applyMisc($request, $guzzleOptions, $options);
+ $this->applyDecodeContent($guzzleOptions, $options);
+ if (\extension_loaded('curl') && isset($guzzleOptions['curl'])) {
+ $this->applyCurlOptions($guzzleOptions['curl'], $options);
+ }
+
+ return $options;
+ }
+
+ /**
+ * Merges headers from the PSR-7 request with any headers supplied via the
+ * Guzzle 'headers' option (Guzzle option takes precedence).
+ *
+ * @return array
+ */
+ private function extractHeaders(RequestInterface $request, array $guzzleOptions): array
+ {
+ $headers = $request->getHeaders();
+
+ foreach ($guzzleOptions['headers'] ?? [] as $name => $value) {
+ $headers[$name] = (array) $value;
+ }
+
+ return $headers;
+ }
+
+ private function applyBody(RequestInterface $request, array &$options): void
+ {
+ $key = 'content-length';
+ $body = $request->getBody();
+ if (!$size = $options['headers'][$key][0] ?? $options['headers'][$key = 'Content-Length'][0] ?? $body->getSize() ?? -1) {
+ return;
+ }
+
+ if ($size < 0 || 1 << 21 < $size) {
+ $options['body'] = static function (int $size) use ($body) {
+ if ($body->isSeekable()) {
+ try {
+ $body->seek(0);
+ } catch (\RuntimeException) {
+ // ignore
+ }
+ }
+
+ while (!$body->eof()) {
+ yield $body->read($size);
+ }
+ };
+ } else {
+ if ($body->isSeekable()) {
+ try {
+ $body->seek(0);
+ } catch (\RuntimeException) {
+ // ignore
+ }
+ }
+ $options['body'] = $body->getContents();
+ }
+
+ if (0 < $size) {
+ $options['headers'][$key] = [$size];
+ }
+ }
+
+ /**
+ * Maps Guzzle's 'auth' option.
+ *
+ * Supported forms:
+ * ['user', 'pass'] -> auth_basic
+ * ['user', 'pass', 'basic'] -> auth_basic
+ * ['token', '', 'bearer'] -> auth_bearer
+ * ['token', '', 'token'] -> auth_bearer (alias)
+ */
+ private function applyAuth(array $guzzleOptions, array &$options): void
+ {
+ if (!isset($guzzleOptions['auth'])) {
+ return;
+ }
+
+ $auth = $guzzleOptions['auth'];
+ $type = strtolower($auth[2] ?? 'basic');
+
+ if ('bearer' === $type || 'token' === $type) {
+ $options['auth_bearer'] = $auth[0];
+ } elseif ('ntlm' === $type) {
+ array_pop($auth);
+ $options['auth_ntlm'] = $auth;
+ } else {
+ $options['auth_basic'] = [$auth[0], $auth[1] ?? ''];
+ }
+ }
+
+ private function applyTimeouts(array $guzzleOptions, array &$options): void
+ {
+ if (0 < ($guzzleOptions['timeout'] ?? 0)) {
+ $options['max_duration'] = (float) $guzzleOptions['timeout'];
+ }
+
+ if (0 < ($guzzleOptions['read_timeout'] ?? 0)) {
+ $options['timeout'] = (float) $guzzleOptions['read_timeout'];
+ }
+
+ if (0 < ($guzzleOptions['connect_timeout'] ?? 0)) {
+ $options['max_connect_duration'] = (float) $guzzleOptions['connect_timeout'];
+ }
+ }
+
+ /**
+ * Maps SSL/TLS related options.
+ *
+ * Guzzle 'verify' (bool|string) -> Symfony verify_peer / verify_host / cafile / capath
+ * Guzzle 'cert' (string|array) -> Symfony local_cert [+ passphrase]
+ * Guzzle 'ssl_key'(string|array) -> Symfony local_pk [+ passphrase]
+ * Guzzle 'crypto_method' -> Symfony crypto_method (same PHP stream constants)
+ */
+ private function applySsl(array $guzzleOptions, array &$options): void
+ {
+ if (isset($guzzleOptions['verify'])) {
+ if (false === $guzzleOptions['verify']) {
+ $options['verify_peer'] = false;
+ $options['verify_host'] = false;
+ } elseif (\is_string($guzzleOptions['verify'])) {
+ if (is_dir($guzzleOptions['verify'])) {
+ $options['capath'] = $guzzleOptions['verify'];
+ } else {
+ $options['cafile'] = $guzzleOptions['verify'];
+ }
+ }
+ }
+
+ if (isset($guzzleOptions['cert'])) {
+ $cert = $guzzleOptions['cert'];
+ if (\is_array($cert)) {
+ [$certPath, $certPass] = $cert;
+ $options['local_cert'] = $certPath;
+ $options['passphrase'] = $certPass;
+ } else {
+ $options['local_cert'] = $cert;
+ }
+ }
+
+ if (isset($guzzleOptions['ssl_key'])) {
+ $key = $guzzleOptions['ssl_key'];
+ if (\is_array($key)) {
+ [$keyPath, $keyPass] = $key;
+ $options['local_pk'] = $keyPath;
+ // Do not clobber a passphrase already set by 'cert'.
+ $options['passphrase'] ??= $keyPass;
+ } else {
+ $options['local_pk'] = $key;
+ }
+ }
+
+ if (isset($guzzleOptions['crypto_method'])) {
+ $options['crypto_method'] = $guzzleOptions['crypto_method'];
+ }
+ }
+
+ /**
+ * Maps Guzzle's 'proxy' option.
+ *
+ * String form -> proxy
+ * Array form -> selects proxy by URI scheme; 'no' key maps to no_proxy
+ */
+ private function applyProxy(RequestInterface $request, array $guzzleOptions, array &$options): void
+ {
+ if (!isset($guzzleOptions['proxy'])) {
+ return;
+ }
+
+ if (\is_string($proxy = $guzzleOptions['proxy'])) {
+ $options['proxy'] = $proxy;
+
+ return;
+ }
+
+ $scheme = $request->getUri()->getScheme();
+ if (isset($proxy[$scheme])) {
+ $options['proxy'] = $proxy[$scheme];
+ }
+
+ if (isset($proxy['no'])) {
+ $options['no_proxy'] = implode(',', (array) $proxy['no']);
+ }
+ }
+
+ /**
+ * Maps Guzzle's 'allow_redirects' to Symfony's 'max_redirects'.
+ *
+ * false -> 0 (disable redirects)
+ * true -> (no override; Symfony defaults apply)
+ * ['max' => N, ...] -> N
+ */
+ private function applyRedirects(array $guzzleOptions, array &$options): void
+ {
+ if (!isset($guzzleOptions['allow_redirects'])) {
+ return;
+ }
+
+ if (!$ar = $guzzleOptions['allow_redirects']) {
+ $options['max_redirects'] = 0;
+ } elseif (\is_array($ar)) {
+ // 5 matches Guzzle's own default for the 'max' sub-key.
+ $options['max_redirects'] = $ar['max'] ?? 5;
+ }
+ }
+
+ /**
+ * Miscellaneous options that do not fit a dedicated category.
+ */
+ private function applyMisc(RequestInterface $request, array $guzzleOptions, array &$options): void
+ {
+ // We always drive I/O via stream(), so tell Symfony not to build its
+ // own internal buffer - chunks are written directly to the PSR-7 response body stream.
+ $options['buffer'] = false;
+
+ if (!$this->autoUpgradeHttpVersion || '1.0' === $request->getProtocolVersion()) {
+ $options['http_version'] = $request->getProtocolVersion();
+ }
+
+ // progress callback: (dlTotal, dlNow, ulTotal, ulNow) in Guzzle
+ // on_progress: (dlNow, dlTotal, info) in Symfony
+ if (isset($guzzleOptions['progress'])) {
+ $guzzleProgress = $guzzleOptions['progress'];
+ $options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use ($guzzleProgress): void {
+ $guzzleProgress($dlSize, $dlNow, max(0, (int) ($info['upload_content_length'] ?? 0)), (int) ($info['size_upload'] ?? 0));
+ };
+ }
+ }
+
+ /**
+ * Maps Guzzle's 'decode_content' option.
+ *
+ * true/string -> remove any explicit Accept-Encoding the caller set, so
+ * Symfony's HttpClient manages the header and auto-decodes
+ * false -> ensure an Accept-Encoding header is sent to disable
+ * Symfony's auto-decode behavior
+ */
+ private function applyDecodeContent(array $guzzleOptions, array &$options): void
+ {
+ if ($guzzleOptions['decode_content'] ?? true) {
+ unset($options['headers']['Accept-Encoding'], $options['headers']['accept-encoding']);
+ } elseif (!isset($options['headers']['Accept-Encoding']) && !isset($options['headers']['accept-encoding'])) {
+ $options['headers']['Accept-Encoding'] = ['identity'];
+ }
+ }
+
+ /**
+ * Maps raw cURL options from Guzzle's 'curl' option bag to Symfony options.
+ *
+ * Constants that have a direct named Symfony equivalent are translated;
+ * everything else is forwarded verbatim via CurlHttpClient's 'extra.curl'
+ * pass-through so that no option is silently dropped when the underlying
+ * transport happens to be CurlHttpClient.
+ *
+ * Options managed internally by CurlHttpClient (or Symfony's other
+ * mechanisms) are silently dropped to avoid the "Cannot set X with
+ * extra.curl" exception that CurlHttpClient::validateExtraCurlOptions()
+ * throws for those constants.
+ */
+ private function applyCurlOptions(array $curlOptions, array &$options): void
+ {
+ // Build a set of constants that CurlHttpClient rejects in extra.curl
+ // together with options whose Symfony equivalents are already applied
+ // via the PSR-7 request or other Guzzle option mappings.
+ static $blocked;
+ $blocked ??= array_flip(array_filter([
+ // Auth - handled by applyAuth() / requires NTLM-specific logic.
+ \CURLOPT_HTTPAUTH, \CURLOPT_USERPWD,
+ // Body - set from the PSR-7 request body by applyBody().
+ \CURLOPT_READDATA, \CURLOPT_READFUNCTION, \CURLOPT_INFILESIZE,
+ \CURLOPT_POSTFIELDS, \CURLOPT_UPLOAD,
+ // HTTP method - taken from the PSR-7 request.
+ \CURLOPT_POST, \CURLOPT_PUT, \CURLOPT_CUSTOMREQUEST,
+ \CURLOPT_HTTPGET, \CURLOPT_NOBODY,
+ // Headers - merged by extractHeaders().
+ \CURLOPT_HTTPHEADER,
+ // Internal curl signal / redirect-type flags with no Symfony equiv.
+ \CURLOPT_NOSIGNAL, \CURLOPT_POSTREDIR,
+ // Progress - handled by applyMisc() via Guzzle's 'progress' option.
+ \CURLOPT_NOPROGRESS, \CURLOPT_PROGRESSFUNCTION,
+ // Blocked by CurlHttpClient::validateExtraCurlOptions().
+ \CURLOPT_PRIVATE, \CURLOPT_HEADERFUNCTION, \CURLOPT_WRITEFUNCTION,
+ \CURLOPT_VERBOSE, \CURLOPT_STDERR, \CURLOPT_RETURNTRANSFER,
+ \CURLOPT_URL, \CURLOPT_FOLLOWLOCATION, \CURLOPT_HEADER,
+ \CURLOPT_HTTP_VERSION, \CURLOPT_PORT, \CURLOPT_DNS_USE_GLOBAL_CACHE,
+ \CURLOPT_PROTOCOLS, \CURLOPT_REDIR_PROTOCOLS, \CURLOPT_COOKIEFILE,
+ \CURLINFO_REDIRECT_COUNT,
+ \defined('CURLOPT_HTTP09_ALLOWED') ? \CURLOPT_HTTP09_ALLOWED : null,
+ \defined('CURLOPT_HEADEROPT') ? \CURLOPT_HEADEROPT : null,
+ // Pinned public key: curl uses "sha256//base64" which is
+ // incompatible with Symfony's peer_fingerprint array format.
+ \defined('CURLOPT_PINNEDPUBLICKEY') ? \CURLOPT_PINNEDPUBLICKEY : null,
+ ]));
+
+ foreach ($curlOptions as $opt => $value) {
+ if (isset($blocked[$opt])) {
+ continue;
+ }
+
+ // CURLOPT_UNIX_SOCKET_PATH is conditionally defined; maps to bindto.
+ if (\defined('CURLOPT_UNIX_SOCKET_PATH') && \CURLOPT_UNIX_SOCKET_PATH === $opt) {
+ $options['bindto'] = $value;
+ continue;
+ }
+
+ match ($opt) {
+ \CURLOPT_CAINFO => $options['cafile'] = $value,
+ \CURLOPT_CAPATH => $options['capath'] = $value,
+ \CURLOPT_SSLCERT => $options['local_cert'] = $value,
+ \CURLOPT_SSLKEY => $options['local_pk'] = $value,
+ \CURLOPT_SSLCERTPASSWD,
+ \CURLOPT_SSLKEYPASSWD => $options['passphrase'] = $value,
+ \CURLOPT_SSL_CIPHER_LIST => $options['ciphers'] = $value,
+ \CURLOPT_CERTINFO => $options['capture_peer_cert_chain'] = (bool) $value,
+ \CURLOPT_PROXY => $options['proxy'] = $value,
+ \CURLOPT_NOPROXY => $options['no_proxy'] = $value,
+ \CURLOPT_USERAGENT => $options['headers']['User-Agent'] = [$value],
+ \CURLOPT_REFERER => $options['headers']['Referer'] = [$value],
+ \CURLOPT_INTERFACE => $options['bindto'] = $value,
+ \CURLOPT_SSL_VERIFYPEER => $options['verify_peer'] = (bool) $value,
+ \CURLOPT_SSL_VERIFYHOST => $options['verify_host'] = $value > 0,
+ \CURLOPT_MAXREDIRS => $options['max_redirects'] = $value,
+ \CURLOPT_TIMEOUT => $options['max_duration'] = (float) $value,
+ \CURLOPT_TIMEOUT_MS => $options['max_duration'] = $value / 1000.0,
+ \CURLOPT_CONNECTTIMEOUT => $options['max_connect_duration'] = (float) $value,
+ \CURLOPT_CONNECTTIMEOUT_MS => $options['max_connect_duration'] = $value / 1000.0,
+ default => $options['extra']['curl'][$opt] = $value,
+ };
+ }
+ }
+}
diff --git a/src/Symfony/Component/HttpClient/Tests/GuzzleHttpHandlerTest.php b/src/Symfony/Component/HttpClient/Tests/GuzzleHttpHandlerTest.php
new file mode 100644
index 00000000000..fc5bb819d6b
--- /dev/null
+++ b/src/Symfony/Component/HttpClient/Tests/GuzzleHttpHandlerTest.php
@@ -0,0 +1,925 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\Tests;
+
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Promise\PromiseInterface;
+use GuzzleHttp\Psr7\Request;
+use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+use PHPUnit\Framework\TestCase;
+use Psr\Http\Message\ResponseInterface;
+use Symfony\Component\HttpClient\GuzzleHttpHandler;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+
+class GuzzleHttpHandlerTest extends TestCase
+{
+ public function testBasicGetRequest()
+ {
+ [$handler] = $this->makeHandler();
+ $request = new Request('GET', 'https://example.com/foo');
+
+ $promise = $handler($request, []);
+
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+ $response = $promise->wait();
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertSame(200, $response->getStatusCode());
+ }
+
+ public function testMethodAndUrlAreForwarded()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('DELETE', 'https://example.com/resource/42'), [])->wait();
+
+ $this->assertSame('DELETE', $spy->method);
+ $this->assertSame('https://example.com/resource/42', $spy->url);
+ }
+
+ public function testRequestHeadersAreForwarded()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/', ['X-Custom-Header' => 'my-value']), [])->wait();
+
+ // MockHttpClient normalises headers into normalized_headers as "Name: value" strings.
+ $this->assertContains('X-Custom-Header: my-value', $spy->options['normalized_headers']['x-custom-header'] ?? []);
+ }
+
+ public function testGuzzleHeadersOptionMergesWithRequestHeaders()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(
+ new Request('GET', 'https://example.com/', ['X-From-Request' => 'req']),
+ ['headers' => ['X-From-Options' => 'opt']],
+ )->wait();
+
+ $this->assertContains('X-From-Request: req', $spy->options['normalized_headers']['x-from-request'] ?? []);
+ $this->assertContains('X-From-Options: opt', $spy->options['normalized_headers']['x-from-options'] ?? []);
+ }
+
+ public function testBodyIsForwarded()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('POST', 'https://example.com/', ['Content-Type' => 'text/plain'], 'hello body'), [])->wait();
+
+ $this->assertSame('hello body', $spy->options['body']);
+ }
+
+ public function testEmptyBodyIsNotSent()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('POST', 'https://example.com/', [], ''), [])->wait();
+
+ $this->assertArrayNotHasKey('body', $spy->options ?? []);
+ }
+
+ public function testKnownSizeBodySetsContentLengthHeader()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('POST', 'https://example.com/', [], 'hello body'), [])->wait();
+
+ $this->assertContains('Content-Length: 10', $spy->options['normalized_headers']['content-length'] ?? []);
+ }
+
+ public function testBodyWithUnknownSizeIsStreamedAsGenerator()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ // PumpStream has no known size (getSize() === null), triggering the streaming path.
+ $body = new \GuzzleHttp\Psr7\PumpStream(static function (): string|false {
+ static $content = 'streamed content';
+ $chunk = $content;
+ $content = '';
+
+ return $chunk ?: false;
+ });
+
+ $handler(new Request('POST', 'https://example.com/', [], $body), [])->wait();
+
+ $this->assertIsCallable($spy->options['body']);
+ }
+
+ public function testAuthBasicOption()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['auth' => ['user', 'pass']])->wait();
+
+ // auth_basic is merged into the Authorization header by prepareRequest().
+ $expected = 'Authorization: Basic '.base64_encode('user:pass');
+ $this->assertContains($expected, $spy->options['normalized_headers']['authorization'] ?? []);
+ }
+
+ public function testAuthBearerMappedWhenTypeIsBearer()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['auth' => ['mytoken', '', 'bearer']])->wait();
+
+ $this->assertContains('Authorization: Bearer mytoken', $spy->options['normalized_headers']['authorization'] ?? []);
+ }
+
+ public function testAuthNtlmMapsToSymfonyOption()
+ {
+ $handler = new GuzzleHttpHandler();
+ $buildOptions = new \ReflectionMethod($handler, 'buildSymfonyOptions');
+
+ $options = $buildOptions->invoke($handler, new Request('GET', 'https://example.com/'), ['auth' => ['user', 'pass', 'ntlm']]);
+
+ $this->assertSame(['user', 'pass'], $options['auth_ntlm']);
+ }
+
+ public function testVerifyFalseMapsToVerifyPeerAndHost()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['verify' => false])->wait();
+
+ $this->assertFalse($spy->options['verify_peer']);
+ $this->assertFalse($spy->options['verify_host']);
+ }
+
+ public function testVerifyStringCaFileMapsToSymfony()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $caBundle = tempnam(sys_get_temp_dir(), 'ca');
+ file_put_contents($caBundle, 'fake-cert');
+
+ try {
+ $handler(new Request('GET', 'https://example.com/'), ['verify' => $caBundle])->wait();
+ $this->assertSame($caBundle, $spy->options['cafile']);
+ } finally {
+ unlink($caBundle);
+ }
+ }
+
+ public function testTimeoutMapsToMaxDuration()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['timeout' => 30.5])->wait();
+
+ $this->assertSame(30.5, $spy->options['max_duration']);
+ }
+
+ public function testConnectTimeoutMapsToMaxConnectDuration()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['connect_timeout' => 5.0])->wait();
+
+ $this->assertSame(5.0, $spy->options['max_connect_duration']);
+ }
+
+ public function testProxyStringIsForwarded()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['proxy' => 'http://proxy.example.com:8080'])->wait();
+
+ $this->assertSame('http://proxy.example.com:8080', $spy->options['proxy']);
+ }
+
+ public function testProxyArraySelectsSchemeSpecificProxy()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [
+ 'proxy' => ['https' => 'http://secure-proxy:8080', 'http' => 'http://plain-proxy:8080'],
+ ])->wait();
+
+ $this->assertSame('http://secure-proxy:8080', $spy->options['proxy']);
+ }
+
+ public function testProxyArrayWithNoProxyList()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [
+ 'proxy' => ['https' => 'http://proxy:8080', 'no' => ['no-proxy.com', 'internal.net']],
+ ])->wait();
+
+ $this->assertSame('no-proxy.com,internal.net', $spy->options['no_proxy']);
+ }
+
+ public function testAllowRedirectsFalseSetsMaxRedirectsZero()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['allow_redirects' => false])->wait();
+
+ $this->assertSame(0, $spy->options['max_redirects']);
+ }
+
+ public function testAllowRedirectsArrayMapsMaxField()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['allow_redirects' => ['max' => 3]])->wait();
+
+ $this->assertSame(3, $spy->options['max_redirects']);
+ }
+
+ public function testAllowRedirectsTrueLeavesSymfonyDefault()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['allow_redirects' => true])->wait();
+
+ // We must not pass max_redirects when allow_redirects=true so Symfony keeps its own default.
+ $this->assertArrayNotHasKey('max_redirects', $spy->options);
+ }
+
+ public function testBufferIsAlwaysFalse()
+ {
+ // The handler always drives I/O via stream(), so buffer is always false
+ // regardless of the Guzzle 'stream' option.
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [])->wait();
+
+ $this->assertFalse($spy->options['buffer']);
+ }
+
+ public function testHttp10IsPreservedEvenWithAutoUpgrade()
+ {
+ [$handler, $spy] = $this->makeHandler();
+ $handler(new Request('GET', 'https://example.com/', [], null, '1.0'), [])->wait();
+
+ // 1.0 must never be silently upgraded — lock it regardless of autoUpgradeHttpVersion.
+ $this->assertSame('1.0', $spy->options['http_version']);
+ }
+
+ public function testHttp20NotForcedByDefault()
+ {
+ [$handler, $spy] = $this->makeHandler();
+ $handler(new Request('GET', 'https://example.com/', [], null, '2.0'), [])->wait();
+
+ // With autoUpgradeHttpVersion=true, Symfony picks the best version; don't override it.
+ $this->assertNull($spy->options['http_version'] ?? null);
+ }
+
+ public function testHttpVersionForwardedWhenAutoUpgradeIsDisabled()
+ {
+ $spy = new \stdClass();
+ $spy->options = null;
+ $client = new MockHttpClient(static function (string $m, string $u, array $opts) use ($spy) {
+ $spy->options = $opts;
+
+ return new MockResponse('');
+ });
+ $handler = new GuzzleHttpHandler($client, autoUpgradeHttpVersion: false);
+
+ $handler(new Request('GET', 'https://example.com/', [], null, '2.0'), [])->wait();
+
+ $this->assertSame('2.0', $spy->options['http_version']);
+ }
+
+ public function testDefaultHttpVersionNotOverridden()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [])->wait();
+
+ // Protocol 1.1 should not force a version; prepareRequest() normalises '' -> null.
+ $this->assertNull($spy->options['http_version']);
+ }
+
+ public function testCryptoMethodIsForwarded()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT])->wait();
+
+ $this->assertSame(\STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, $spy->options['crypto_method']);
+ }
+
+ public function testCertOptionWithPassword()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['cert' => ['/path/to/cert.pem', 'secret']])->wait();
+
+ $this->assertSame('/path/to/cert.pem', $spy->options['local_cert']);
+ $this->assertSame('secret', $spy->options['passphrase']);
+ }
+
+ public function testSslKeyOptionWithPassword()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['ssl_key' => ['/path/to/key.pem', 'keypass']])->wait();
+
+ $this->assertSame('/path/to/key.pem', $spy->options['local_pk']);
+ $this->assertSame('keypass', $spy->options['passphrase']);
+ }
+
+ public function testProgressCallbackIsWrapped()
+ {
+ [$handler, $spy] = $this->makeHandler();
+ $capturedArgs = null;
+ $progress = static function (int $dlTotal, int $dlNow, int $ulTotal, int $ulNow) use (&$capturedArgs): void {
+ $capturedArgs = [$dlTotal, $dlNow, $ulTotal, $ulNow];
+ };
+
+ $handler(new Request('GET', 'https://example.com/'), ['progress' => $progress])->wait();
+
+ $this->assertIsCallable($spy->options['on_progress']);
+ // Invoke on_progress with known values and verify the argument mapping:
+ // Symfony: (dlNow, dlTotal, info) → Guzzle: (dlTotal, dlNow, ulTotal, ulNow)
+ ($spy->options['on_progress'])(42, 100, ['upload_content_length' => 200.0, 'size_upload' => 30.0]);
+ $this->assertSame([100, 42, 200, 30], $capturedArgs);
+ }
+
+ public function testOnStatsIsCalledAfterSuccessfulResponse()
+ {
+ [$handler] = $this->makeHandler(static fn () => new MockResponse('body', ['http_code' => 201]));
+
+ $statsCalled = false;
+ $capturedStats = null;
+
+ $response = $handler(new Request('GET', 'https://example.com/'), [
+ 'on_stats' => static function (\GuzzleHttp\TransferStats $stats) use (&$statsCalled, &$capturedStats) {
+ $statsCalled = true;
+ $capturedStats = $stats;
+ },
+ ])->wait();
+
+ $this->assertTrue($statsCalled);
+ $this->assertSame(201, $response->getStatusCode());
+ $this->assertSame(201, $capturedStats?->getResponse()->getStatusCode());
+ // Transfer time and handler stats are populated from Symfony's on_progress info.
+ $this->assertIsFloat($capturedStats?->getTransferTime());
+ $this->assertArrayHasKey('total_time', $capturedStats?->getHandlerStats());
+ }
+
+ public function testOnStatsIsCalledOnTransportError()
+ {
+ $client = new MockHttpClient(static fn () => new MockResponse('', ['error' => 'Connection refused']));
+ $handler = new GuzzleHttpHandler($client);
+
+ $statsCalled = false;
+ $capturedStats = null;
+
+ $promise = $handler(new Request('GET', 'https://example.com/'), [
+ 'on_stats' => static function (\GuzzleHttp\TransferStats $stats) use (&$statsCalled, &$capturedStats) {
+ $statsCalled = true;
+ $capturedStats = $stats;
+ },
+ ]);
+
+ try {
+ $promise->wait();
+ $this->fail('Expected ConnectException');
+ } catch (ConnectException) {
+ }
+
+ $this->assertTrue($statsCalled);
+ $this->assertNull($capturedStats?->getResponse());
+ $this->assertIsFloat($capturedStats?->getTransferTime());
+ }
+
+ public function testTransportExceptionBecomesConnectException()
+ {
+ $client = new MockHttpClient(static fn () => new MockResponse('', ['error' => 'Connection refused']));
+ $handler = new GuzzleHttpHandler($client);
+
+ $this->expectException(ConnectException::class);
+ $handler(new Request('GET', 'https://example.com/'), [])->wait();
+ }
+
+ public function testWaitOnRejectedPromiseDoesNotBlockOtherResponses()
+ {
+ $client = new MockHttpClient([
+ new MockResponse('', ['error' => 'Connection refused']),
+ new MockResponse('ok', ['http_code' => 200]),
+ ]);
+ $handler = new GuzzleHttpHandler($client);
+
+ $p1 = $handler(new Request('GET', 'https://example.com/fail'), []);
+ $p2 = $handler(new Request('GET', 'https://example.com/ok'), []);
+
+ try {
+ $p1->wait();
+ $this->fail('Expected ConnectException');
+ } catch (ConnectException) {
+ }
+
+ // p2 must still resolve successfully after p1 was rejected.
+ $r2 = $p2->wait();
+ $this->assertSame(200, $r2->getStatusCode());
+ $this->assertSame('ok', (string) $r2->getBody());
+ }
+
+ public function testDelayOptionPausesResponse()
+ {
+ [$handler] = $this->makeHandler();
+
+ $start = microtime(true);
+ $handler(new Request('GET', 'https://example.com/'), ['delay' => 50])->wait();
+
+ $elapsed = (microtime(true) - $start) * 1000;
+ $this->assertGreaterThanOrEqual(40, $elapsed);
+ }
+
+ public function testResponseBodyAndStatusAreReturned()
+ {
+ [$handler] = $this->makeHandler(
+ static fn () => new MockResponse('{"result":"ok"}', ['http_code' => 202, 'response_headers' => ['content-type: application/json']]),
+ );
+
+ $response = $handler(new Request('GET', 'https://example.com/'), [])->wait();
+
+ $this->assertSame(202, $response->getStatusCode());
+ $this->assertSame('{"result":"ok"}', (string) $response->getBody());
+ $this->assertSame('application/json', $response->getHeaderLine('content-type'));
+ }
+
+ public function testOnHeadersCallbackIsInvoked()
+ {
+ [$handler] = $this->makeHandler(
+ static fn () => new MockResponse('body', ['http_code' => 200, 'response_headers' => ['x-foo: bar']]),
+ );
+
+ $onHeadersCalled = false;
+ $capturedResponse = null;
+
+ $handler(new Request('GET', 'https://example.com/'), [
+ 'on_headers' => static function (ResponseInterface $response) use (&$onHeadersCalled, &$capturedResponse) {
+ $onHeadersCalled = true;
+ $capturedResponse = $response;
+ },
+ ])->wait();
+
+ $this->assertTrue($onHeadersCalled);
+ $this->assertSame(200, $capturedResponse?->getStatusCode());
+ }
+
+ public function testOnHeadersReceivesSameResponseInstanceAsResolvedPromise()
+ {
+ [$handler] = $this->makeHandler(
+ static fn () => new MockResponse('body', ['http_code' => 200, 'response_headers' => ['x-foo: bar']]),
+ );
+
+ $onHeadersResponse = null;
+
+ $resolvedResponse = $handler(new Request('GET', 'https://example.com/'), [
+ 'on_headers' => static function (ResponseInterface $response) use (&$onHeadersResponse) {
+ $onHeadersResponse = $response;
+ },
+ ])->wait();
+
+ $this->assertSame($onHeadersResponse, $resolvedResponse);
+ $this->assertSame('body', (string) $resolvedResponse->getBody());
+ }
+
+ public function testOnHeadersExceptionRejectsWithRequestException()
+ {
+ [$handler] = $this->makeHandler(
+ static fn () => new MockResponse('body', ['http_code' => 200]),
+ );
+
+ $promise = $handler(new Request('GET', 'https://example.com/'), [
+ 'on_headers' => static function () { throw new \RuntimeException('Abort!'); },
+ ]);
+
+ try {
+ $promise->wait();
+ $this->fail('Expected RequestException');
+ } catch (RequestException $e) {
+ $this->assertSame('Abort!', $e->getMessage());
+ $this->assertNotNull($e->getResponse());
+ $this->assertSame(200, $e->getResponse()->getStatusCode());
+ }
+ }
+
+ // -- Async / concurrency --------------------------------------------------
+
+ public function testInvokeReturnsPendingPromise()
+ {
+ [$handler] = $this->makeHandler();
+
+ $promise = $handler(new Request('GET', 'https://example.com/'), []);
+
+ // Before wait() is called the promise must be PENDING, not already resolved.
+ $this->assertSame(PromiseInterface::PENDING, $promise->getState());
+ }
+
+ public function testConcurrentRequestsAreAllResolved()
+ {
+ $client = new MockHttpClient([
+ new MockResponse('one', ['http_code' => 200]),
+ new MockResponse('two', ['http_code' => 201]),
+ new MockResponse('three', ['http_code' => 202]),
+ ]);
+ $handler = new GuzzleHttpHandler($client);
+
+ $p1 = $handler(new Request('GET', 'https://example.com/1'), []);
+ $p2 = $handler(new Request('GET', 'https://example.com/2'), []);
+ $p3 = $handler(new Request('GET', 'https://example.com/3'), []);
+
+ // All three are pending before any wait().
+ $this->assertSame(PromiseInterface::PENDING, $p1->getState());
+ $this->assertSame(PromiseInterface::PENDING, $p2->getState());
+ $this->assertSame(PromiseInterface::PENDING, $p3->getState());
+
+ // Waiting on p1 should drive the shared event loop and resolve all queued promises.
+ $r1 = $p1->wait();
+
+ $this->assertSame(PromiseInterface::FULFILLED, $p1->getState());
+ // p2 and p3 may be resolved as a side-effect of driving the loop for p1.
+ // At minimum they must resolve when individually waited on.
+ $r2 = $p2->wait();
+ $r3 = $p3->wait();
+
+ $this->assertSame(200, $r1->getStatusCode());
+ $this->assertSame('one', (string) $r1->getBody());
+ $this->assertSame(201, $r2->getStatusCode());
+ $this->assertSame('two', (string) $r2->getBody());
+ $this->assertSame(202, $r3->getStatusCode());
+ $this->assertSame('three', (string) $r3->getBody());
+ }
+
+ public function testPromiseThenChainIsExecutedAsynchronously()
+ {
+ [$handler] = $this->makeHandler(static fn () => new MockResponse('hello', ['http_code' => 200]));
+
+ $thenCalled = false;
+ $capturedBody = null;
+
+ $promise = $handler(new Request('GET', 'https://example.com/'), [])
+ ->then(static function (ResponseInterface $response) use (&$thenCalled, &$capturedBody) {
+ $thenCalled = true;
+ $capturedBody = (string) $response->getBody();
+
+ return $response;
+ });
+
+ // The ->then() callback hasn't run yet.
+ $this->assertFalse($thenCalled);
+
+ $promise->wait();
+
+ $this->assertTrue($thenCalled);
+ $this->assertSame('hello', $capturedBody);
+ }
+
+ public function testCancelRemovesPendingRequest()
+ {
+ $client = new MockHttpClient([
+ new MockResponse('first', ['http_code' => 200]),
+ new MockResponse('second', ['http_code' => 200]),
+ ]);
+ $handler = new GuzzleHttpHandler($client);
+
+ $p1 = $handler(new Request('GET', 'https://example.com/1'), []);
+ $p2 = $handler(new Request('GET', 'https://example.com/2'), []);
+
+ $p1->cancel();
+
+ $this->assertSame(PromiseInterface::REJECTED, $p1->getState());
+
+ // p2 must still be resolvable after p1 is cancelled.
+ $r2 = $p2->wait();
+ $this->assertSame(PromiseInterface::FULFILLED, $p2->getState());
+ $this->assertSame(200, $r2->getStatusCode());
+ $this->assertSame('second', (string) $r2->getBody());
+ }
+
+ // -- extra.curl pass-through ---
+
+ #[RequiresPhpExtension('curl')]
+ public function testUnmappedCurlOptIsForwardedToExtraCurl()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [
+ 'curl' => [
+ \CURLOPT_BUFFERSIZE => 65536,
+ ],
+ ])->wait();
+
+ $this->assertSame(65536, $spy->options['extra']['curl'][\CURLOPT_BUFFERSIZE] ?? null);
+ }
+
+ #[RequiresPhpExtension('curl')]
+ public function testKnownCurlOptIsNotDuplicatedInExtraCurl()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [
+ 'curl' => [
+ \CURLOPT_CAINFO => '/path/to/ca.pem', // mapped -> cafile
+ \CURLOPT_BUFFERSIZE => 65536, // unmapped -> extra.curl
+ ],
+ ])->wait();
+
+ // Known opt: translated to the named Symfony option.
+ $this->assertSame('/path/to/ca.pem', $spy->options['cafile']);
+ // Known opt: must NOT appear in extra.curl (would be redundant / contradictory).
+ $this->assertArrayNotHasKey(\CURLOPT_CAINFO, $spy->options['extra']['curl'] ?? []);
+ // Unknown opt: forwarded.
+ $this->assertSame(65536, $spy->options['extra']['curl'][\CURLOPT_BUFFERSIZE] ?? null);
+ }
+
+ #[RequiresPhpExtension('curl')]
+ public function testKnownCurlOptWithFalsyValueIsNotForwardedToExtraCurl()
+ {
+ // CURLOPT_SSL_VERIFYPEER => false maps to verify_peer=false.
+ // The mapping return value is (bool)false - must NOT be mistaken for
+ // "unmapped" and end up in extra.curl as well.
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [
+ 'curl' => [\CURLOPT_SSL_VERIFYPEER => false],
+ ])->wait();
+
+ $this->assertFalse($spy->options['verify_peer']);
+ $this->assertArrayNotHasKey(\CURLOPT_SSL_VERIFYPEER, $spy->options['extra']['curl'] ?? []);
+ }
+
+ #[RequiresPhpExtension('curl')]
+ public function testExtraCurlIsMergedWithExistingExtras()
+ {
+ // Multiple unmapped opts all end up in the same sub-array.
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [
+ 'curl' => [
+ \CURLOPT_LOW_SPEED_LIMIT => 1024,
+ \CURLOPT_BUFFERSIZE => 32768,
+ ],
+ ])->wait();
+
+ $this->assertSame(1024, $spy->options['extra']['curl'][\CURLOPT_LOW_SPEED_LIMIT]);
+ $this->assertSame(32768, $spy->options['extra']['curl'][\CURLOPT_BUFFERSIZE]);
+ }
+
+ #[RequiresPhpExtension('curl')]
+ public function testBlockedCurlOptIsDroppedSilently()
+ {
+ // Options that CurlHttpClient rejects in extra.curl must be silently
+ // dropped rather than forwarded, to avoid a runtime exception.
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), [
+ 'curl' => [
+ \CURLOPT_VERBOSE => true, // in $curloptsToCheck -> blocked
+ \CURLOPT_BUFFERSIZE => 65536, // safe unmapped -> forwarded
+ ],
+ ])->wait();
+
+ $this->assertArrayNotHasKey(\CURLOPT_VERBOSE, $spy->options['extra']['curl'] ?? []);
+ $this->assertSame(65536, $spy->options['extra']['curl'][\CURLOPT_BUFFERSIZE] ?? null);
+ }
+
+ #[RequiresPhpExtension('curl')]
+ public function testCurlOptMaxDirsIsMapped()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['curl' => [\CURLOPT_MAXREDIRS => 3]])->wait();
+
+ $this->assertSame(3, $spy->options['max_redirects']);
+ }
+
+ #[RequiresPhpExtension('curl')]
+ public function testCurlOptSslCipherListIsMapped()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['curl' => [\CURLOPT_SSL_CIPHER_LIST => 'ECDHE-RSA-AES256-GCM-SHA384']])->wait();
+
+ $this->assertSame('ECDHE-RSA-AES256-GCM-SHA384', $spy->options['ciphers']);
+ }
+
+ #[RequiresPhpExtension('curl')]
+ public function testCurlOptRefererIsMapped()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['curl' => [\CURLOPT_REFERER => 'https://referrer.example.com/']])->wait();
+
+ $this->assertContains('Referer: https://referrer.example.com/', $spy->options['normalized_headers']['referer'] ?? []);
+ }
+
+ #[RequiresPhpExtension('curl')]
+ public function testCurlOptCertInfoIsMapped()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['curl' => [\CURLOPT_CERTINFO => 1]])->wait();
+
+ $this->assertTrue($spy->options['capture_peer_cert_chain']);
+ }
+
+ // --- sink ---
+
+ public function testSinkAsFilePathWritesBodyToFile()
+ {
+ $path = tempnam(sys_get_temp_dir(), 'guzzle_sink_');
+ $handler = new GuzzleHttpHandler(new MockHttpClient(new MockResponse('hello sink')));
+
+ $handler(new Request('GET', 'https://example.com/'), ['sink' => $path])->wait();
+
+ $this->assertSame('hello sink', file_get_contents($path));
+ unlink($path);
+ }
+
+ public function testSinkAsResourceWritesBodyToResource()
+ {
+ $resource = fopen('php://temp', 'rw+');
+ $handler = new GuzzleHttpHandler(new MockHttpClient(new MockResponse('hello resource')));
+
+ $response = $handler(new Request('GET', 'https://example.com/'), ['sink' => $resource])->wait();
+
+ rewind($resource);
+ $this->assertSame('hello resource', stream_get_contents($resource));
+ $this->assertSame('hello resource', (string) $response->getBody());
+ }
+
+ public function testSinkAsStreamInterfaceWritesBody()
+ {
+ $stream = \GuzzleHttp\Psr7\Utils::streamFor(fopen('php://temp', 'rw+'));
+ $handler = new GuzzleHttpHandler(new MockHttpClient(new MockResponse('hello stream')));
+
+ $handler(new Request('GET', 'https://example.com/'), ['sink' => $stream])->wait();
+
+ $stream->rewind();
+ $this->assertSame('hello stream', (string) $stream);
+ }
+
+ // --- read_timeout ---
+
+ public function testReadTimeoutIsMappedToTimeout()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['read_timeout' => 4.5])->wait();
+
+ $this->assertSame(4.5, $spy->options['timeout']);
+ }
+
+ // --- decode_content ---
+
+ public function testDecodeContentFalsePreservesExplicitAcceptEncoding()
+ {
+ // Caller already chose an encoding: we must not override it.
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/', ['Accept-Encoding' => 'br']), ['decode_content' => false])->wait();
+
+ $this->assertContains('Accept-Encoding: br', $spy->options['normalized_headers']['accept-encoding'] ?? []);
+ }
+
+ public function testDecodeContentFalseInjectsIdentityEncoding()
+ {
+ // Without an explicit Accept-Encoding, Symfony would auto-decompress.
+ // Injecting 'identity' tells the server not to compress, which in turn
+ // prevents Symfony from decoding the body.
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/'), ['decode_content' => false])->wait();
+
+ $this->assertContains('Accept-Encoding: identity', $spy->options['normalized_headers']['accept-encoding'] ?? []);
+ }
+
+ public function testDecodeContentTrueRemovesExplicitAcceptEncoding()
+ {
+ [$handler, $spy] = $this->makeHandler();
+
+ // Caller explicitly set Accept-Encoding; decode_content=true means Symfony
+ // should manage it, so we strip the caller's value.
+ $handler(new Request('GET', 'https://example.com/', ['Accept-Encoding' => 'gzip']), ['decode_content' => true])->wait();
+
+ $this->assertArrayNotHasKey('accept-encoding', $spy->options['normalized_headers'] ?? []);
+ }
+
+ public function testDecodeContentStringRemovesExplicitAcceptEncoding()
+ {
+ // A string value (e.g. 'gzip') means "request this encoding and decode".
+ // We let Symfony manage Accept-Encoding by stripping any explicit value.
+ [$handler, $spy] = $this->makeHandler();
+
+ $handler(new Request('GET', 'https://example.com/', ['Accept-Encoding' => 'br']), ['decode_content' => 'gzip'])->wait();
+
+ $this->assertArrayNotHasKey('accept-encoding', $spy->options['normalized_headers'] ?? []);
+ }
+
+ // --- tick() ---
+
+ public function testTickProgressesResponsesWithoutBlocking()
+ {
+ $handler = new GuzzleHttpHandler(new MockHttpClient(new MockResponse('tick body')));
+
+ $promise = $handler(new Request('GET', 'https://example.com/'), []);
+
+ // tick() should make non-blocking progress and eventually resolve the response.
+ $handler->tick();
+
+ $response = $promise->wait();
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame('tick body', (string) $response->getBody());
+ }
+
+ public function testTickProcessesMultipleConcurrentRequests()
+ {
+ $handler = new GuzzleHttpHandler(new MockHttpClient([
+ new MockResponse('first'),
+ new MockResponse('second'),
+ ]));
+
+ $p1 = $handler(new Request('GET', 'https://example.com/1'), []);
+ $p2 = $handler(new Request('GET', 'https://example.com/2'), []);
+
+ // Drive all pending requests via tick + execute.
+ $handler->execute();
+
+ $this->assertSame('first', (string) $p1->wait()->getBody());
+ $this->assertSame('second', (string) $p2->wait()->getBody());
+ }
+
+ public function testTickBreaksAfterProcessingAvailableChunks()
+ {
+ $handler = new GuzzleHttpHandler(new MockHttpClient([
+ new MockResponse('one'),
+ new MockResponse('two'),
+ ]));
+
+ $p1 = $handler(new Request('GET', 'https://example.com/1'), []);
+ $p2 = $handler(new Request('GET', 'https://example.com/2'), []);
+
+ $handler->tick();
+ $this->assertSame(0, (PromiseInterface::FULFILLED === $p1->getState()) + (PromiseInterface::FULFILLED === $p2->getState()));
+
+ $handler->tick();
+ $handler->tick();
+ $this->assertSame(1, (PromiseInterface::FULFILLED === $p1->getState()) + (PromiseInterface::FULFILLED === $p2->getState()));
+
+ $handler->tick();
+ $handler->tick();
+ $handler->tick();
+ $this->assertSame(2, (PromiseInterface::FULFILLED === $p1->getState()) + (PromiseInterface::FULFILLED === $p2->getState()));
+ $this->assertSame('one', (string) $p1->wait()->getBody());
+ $this->assertSame('two', (string) $p2->wait()->getBody());
+ }
+
+ public function testNonStandardStatusCodeAbove599IsPreserved()
+ {
+ // GuzzleResponse rejects status codes >= 600 in its constructor, so the
+ // handler must smuggle the real code through reflection.
+ [$handler] = $this->makeHandler(
+ static fn () => new MockResponse('proprietary', ['http_code' => 999])
+ );
+
+ $response = $handler(new Request('GET', 'https://example.com/'), [])->wait();
+
+ $this->assertSame(999, $response->getStatusCode());
+ $this->assertSame('proprietary', (string) $response->getBody());
+ }
+
+ /**
+ * Build a handler backed by a spy MockHttpClient.
+ *
+ * The returned stdClass is populated with method / url / options after
+ * each request is fired through the handler.
+ */
+ private function makeHandler(?callable $responseFactory = null): array
+ {
+ $spy = new \stdClass();
+ $spy->method = null;
+ $spy->url = null;
+ $spy->options = null;
+
+ $client = new MockHttpClient(
+ static function (string $method, string $url, array $options) use ($responseFactory, $spy) {
+ $spy->method = $method;
+ $spy->url = $url;
+ $spy->options = $options;
+
+ return $responseFactory
+ ? ($responseFactory)($method, $url, $options)
+ : new MockResponse('{"ok":true}', ['http_code' => 200, 'response_headers' => ['content-type: application/json']]);
+ },
+ );
+
+ return [new GuzzleHttpHandler($client), $spy];
+ }
+}
diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json
index 4875f5a92f4..50dcbc5fbc9 100644
--- a/src/Symfony/Component/HttpClient/composer.json
+++ b/src/Symfony/Component/HttpClient/composer.json
@@ -30,7 +30,7 @@
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
- "guzzlehttp/promises": "^1.4|^2.0",
+ "guzzlehttp/guzzle": "^7.10",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",