This commit is contained in:
Javier Casares 2026-06-09 12:52:52 +00:00
commit 19ef0566df
610 changed files with 21958 additions and 3112 deletions

View file

@ -9,6 +9,7 @@ use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7;
use GuzzleHttp\TransferStats;
use GuzzleHttp\TransportSharing;
use GuzzleHttp\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
@ -22,11 +23,49 @@ use Psr\Http\Message\UriInterface;
*/
class StreamHandler
{
private const CONNECTION_ERRORS = [
'php_network_getaddresses:',
'getaddrinfo',
'gethostbyname failed',
'Connection refused',
'No connection could be made because the target machine actively refused it',
"couldn't connect to host", // error on HHVM
'connection attempt failed',
'connect() failed',
'Connection timed out',
'Operation timed out',
'Network is unreachable',
'No route to host',
'Host is unreachable',
'Host is down',
'Cannot connect to HTTPS server through proxy',
];
/**
* @var array
*/
private $lastHeaders = [];
/**
* @var string
*/
private $transportSharingMode;
/**
* Accepts an associative array of options:
*
* - transport_sharing: Optional transport sharing mode.
*
* @param array{transport_sharing?: mixed} $options Array of options to use with the handler
*/
public function __construct(array $options = [])
{
$this->transportSharingMode = CurlShareHandleState::normalizeMode(
$options['transport_sharing'] ?? null,
'transport_sharing'
);
}
/**
* Sends an HTTP request.
*
@ -42,12 +81,22 @@ class StreamHandler
$protocolVersion = $request->getProtocolVersion();
if ('' === $protocolVersion) {
\trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Sending a request with an empty protocol version is deprecated; guzzlehttp/guzzle 8.0 will reject empty protocol versions.');
$protocolVersion = '1.1';
$request = Psr7\Utils::modifyRequest($request, ['version' => $protocolVersion]);
}
if ('1.0' !== $protocolVersion && '1.1' !== $protocolVersion) {
throw new ConnectException(sprintf('HTTP/%s is not supported by the stream handler.', $protocolVersion), $request);
}
$startTime = isset($options['on_stats']) ? Utils::currentTime() : null;
self::triggerUnsupportedRequestOptionDeprecations($request, $options);
$this->assertTransportSharingSupported();
try {
// Does not support the expect header.
$request = $request->withoutHeader('Expect');
@ -74,16 +123,10 @@ class StreamHandler
throw $e;
} catch (\Exception $e) {
// Determine if the error was a networking error.
$message = $e->getMessage();
// This list can probably get more comprehensive.
if (false !== \strpos($message, 'getaddrinfo') // DNS lookup failed
|| false !== \strpos($message, 'Connection refused')
|| false !== \strpos($message, "couldn't connect to host") // error on HHVM
|| false !== \strpos($message, 'connection attempt failed')
) {
if (self::isConnectionError($e->getMessage())) {
$e = new ConnectException($e->getMessage(), $request, $e);
} else {
$e = RequestException::wrapException($request, $e);
$e = $e instanceof RequestException ? $e : new RequestException($e->getMessage(), $request, null, $e);
}
$this->invokeStats($options, $request, $startTime, null, $e);
@ -91,6 +134,17 @@ class StreamHandler
}
}
private static function isConnectionError(string $message): bool
{
foreach (self::CONNECTION_ERRORS as $connectionError) {
if (false !== \strpos($message, $connectionError)) {
return true;
}
}
return false;
}
private function invokeStats(
array $options,
RequestInterface $request,
@ -114,10 +168,8 @@ class StreamHandler
try {
[$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($hdrs);
} catch (\Exception $e) {
return P\Create::rejectionFor(
new RequestException('An error was encountered while creating the response', $request, null, $e)
);
} catch (\Throwable $e) {
return $this->rejectResponseCreation($options, $request, $startTime, $e);
}
[$stream, $headers] = $this->checkDecode($options, $headers, $stream);
@ -130,16 +182,14 @@ class StreamHandler
try {
$response = new Psr7\Response($status, $headers, $sink, $ver, $reason);
} catch (\Exception $e) {
return P\Create::rejectionFor(
new RequestException('An error was encountered while creating the response', $request, null, $e)
);
} catch (\Throwable $e) {
return $this->rejectResponseCreation($options, $request, $startTime, $e);
}
if (isset($options['on_headers'])) {
try {
$options['on_headers']($response);
} catch (\Exception $e) {
} catch (\Throwable $e) {
return P\Create::rejectionFor(
new RequestException('An error was encountered during the on_headers event', $request, $response, $e)
);
@ -157,6 +207,24 @@ class StreamHandler
return new FulfilledPromise($response);
}
private function rejectResponseCreation(
array $options,
RequestInterface $request,
?float $startTime,
\Throwable $previous
): PromiseInterface {
$reason = new RequestException(
'An error was encountered while creating the response',
$request,
null,
$previous
);
$this->invokeStats($options, $request, $startTime, null, $reason);
return P\Create::rejectionFor($reason);
}
private function createSink(StreamInterface $stream, array $options): StreamInterface
{
if (!empty($options['stream'])) {
@ -185,15 +253,12 @@ class StreamHandler
// Remove content-encoding header
unset($headers[$normalizedKeys['content-encoding']]);
// Fix content-length header
// The decoded length cannot be known without inflating the
// stream, so keep the original length for inspection and
// drop the now-unknown Content-Length header.
if (isset($normalizedKeys['content-length'])) {
$headers['x-encoded-content-length'] = $headers[$normalizedKeys['content-length']];
$length = (int) $stream->getSize();
if ($length === 0) {
unset($headers[$normalizedKeys['content-length']]);
} else {
$headers[$normalizedKeys['content-length']] = [$length];
}
unset($headers[$normalizedKeys['content-length']]);
}
}
}
@ -279,8 +344,14 @@ class StreamHandler
$methods = \array_flip(\get_class_methods(__CLASS__));
}
if (!\in_array($request->getUri()->getScheme(), ['http', 'https'])) {
throw new RequestException(\sprintf("The scheme '%s' is not supported.", $request->getUri()->getScheme()), $request);
$scheme = $request->getUri()->getScheme();
if (!\in_array($scheme, ['http', 'https'], true)) {
throw new RequestException(\sprintf("The scheme '%s' is not supported.", $scheme), $request);
}
$protocols = Utils::normalizeProtocols($options['protocols'] ?? ['http', 'https']);
if (!\in_array($scheme, $protocols, true)) {
throw new RequestException(\sprintf('The scheme "%s" is not allowed by the protocols request option.', $scheme), $request);
}
// HTTP/1.1 streams using the PHP stream wrapper require a
@ -338,7 +409,6 @@ class StreamHandler
// See https://wiki.php.net/rfc/deprecations_php_8_5#deprecate_the_http_response_header_predefined_variable
if (function_exists('http_get_last_response_headers')) {
/** @var array|null */
$http_response_header = \http_get_last_response_headers();
}
@ -423,6 +493,121 @@ class StreamHandler
return $context;
}
private static function triggerUnsupportedRequestOptionDeprecations(RequestInterface $request, array $options): void
{
if (
\array_key_exists('curl', $options)
&& $options['curl'] !== null
&& $options['curl'] !== []
&& !self::isCurlOptionGeneratedByAuth($options)
) {
\trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "curl" request option to the stream handler is deprecated; guzzlehttp/guzzle 8.0 will reject this option because the stream handler ignores cURL options.');
}
if (self::usesDigestAuth($options)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing digest authentication to the stream handler is deprecated; guzzlehttp/guzzle 8.0 will reject digest authentication with the stream handler because it is only supported by cURL handlers.');
}
if (\array_key_exists('expect', $options) && $options['expect'] !== false && $request->hasHeader('Expect')) {
\trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "expect" request option to the stream handler is deprecated when it adds an Expect header; guzzlehttp/guzzle 8.0 will reject this option because the stream handler does not support Expect: 100-Continue.');
}
}
private function assertTransportSharingSupported(): void
{
if ($this->transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
throw new \InvalidArgumentException('The "transport_sharing" option requires transport sharing, but the stream handler does not support it.');
}
}
private static function isCurlOptionGeneratedByAuth(array $options): bool
{
if (!isset($options['curl']) || !\is_array($options['curl']) || !isset($options['auth'][2]) || !\is_string($options['auth'][2])) {
return false;
}
if (!\defined('CURLOPT_HTTPAUTH') || !\defined('CURLOPT_USERPWD')) {
return false;
}
$type = \strtolower($options['auth'][2]);
if ($type === 'digest') {
$httpAuth = \defined('CURLAUTH_DIGEST') ? \constant('CURLAUTH_DIGEST') : null;
} elseif ($type === 'ntlm') {
$httpAuth = \defined('CURLAUTH_NTLM') ? \constant('CURLAUTH_NTLM') : null;
} else {
return false;
}
return $httpAuth !== null
&& \count($options['curl']) === 2
&& isset($options['curl'][\CURLOPT_HTTPAUTH], $options['curl'][\CURLOPT_USERPWD])
&& $options['curl'][\CURLOPT_HTTPAUTH] === $httpAuth;
}
private static function usesDigestAuth(array $options): bool
{
return isset($options['auth'][2])
&& \is_string($options['auth'][2])
&& \strtolower($options['auth'][2]) === 'digest';
}
/**
* @param mixed $value as passed via Request transfer options.
*
* @return array{0: string, 1: string|null}
*/
private static function normalizeTlsFileOption(string $option, $value): array
{
$passphrase = null;
if (\is_array($value)) {
if (!isset($value[0]) || !\is_string($value[0])) {
throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option));
}
if (isset($value[1])) {
if (!\is_string($value[1])) {
throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option));
}
$passphrase = $value[1];
}
$value = $value[0];
}
if (!\is_string($value)) {
throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option));
}
return [$value, $passphrase];
}
private static function setTlsPassphrase(array &$options, ?string $passphrase, string $option): void
{
if ($passphrase === null) {
return;
}
if (isset($options['ssl']['passphrase']) && $options['ssl']['passphrase'] !== $passphrase) {
throw new \InvalidArgumentException(\sprintf('Cannot use different passphrases for cert and ssl_key with the stream handler; %s conflicts with an existing TLS passphrase.', $option));
}
$options['ssl']['passphrase'] = $passphrase;
}
/**
* @param mixed $value as passed via Request transfer options.
*/
private static function assertStreamTlsType(string $option, $value): void
{
if (!\is_string($value) || $value === '') {
throw new \InvalidArgumentException(\sprintf('%s must be a non-empty string', $option));
}
if (\strtoupper($value) !== 'PEM') {
throw new \InvalidArgumentException(\sprintf('The stream handler only supports "PEM" for the %s request option.', $option));
}
}
/**
* @param mixed $value as passed via Request transfer options.
*/
@ -435,7 +620,10 @@ class StreamHandler
} else {
$scheme = $request->getUri()->getScheme();
if (isset($value[$scheme])) {
if (!isset($value['no']) || !Utils::isHostInNoProxy($request->getUri()->getHost(), $value['no'])) {
if (
!isset($value['no'])
|| !Utils::isUriInNoProxy($request->getUri(), $value['no'])
) {
$uri = $value[$scheme];
}
}
@ -544,23 +732,56 @@ class StreamHandler
*/
private function add_cert(RequestInterface $request, array &$options, $value, array &$params): void
{
if (\is_array($value)) {
$options['ssl']['passphrase'] = $value[1];
$value = $value[0];
}
[$value, $passphrase] = self::normalizeTlsFileOption('cert', $value);
if (!\file_exists($value)) {
throw new \RuntimeException("SSL certificate not found: {$value}");
}
self::setTlsPassphrase($options, $passphrase, 'cert');
$options['ssl']['local_cert'] = $value;
}
/**
* @param mixed $value as passed via Request transfer options.
*/
private function add_cert_type(RequestInterface $request, array &$options, $value, array &$params): void
{
self::assertStreamTlsType('cert_type', $value);
}
/**
* @param mixed $value as passed via Request transfer options.
*/
private function add_ssl_key(RequestInterface $request, array &$options, $value, array &$params): void
{
[$value, $passphrase] = self::normalizeTlsFileOption('ssl_key', $value);
if (!\file_exists($value)) {
throw new \RuntimeException("SSL private key not found: {$value}");
}
self::setTlsPassphrase($options, $passphrase, 'ssl_key');
$options['ssl']['local_pk'] = $value;
}
/**
* @param mixed $value as passed via Request transfer options.
*/
private function add_ssl_key_type(RequestInterface $request, array &$options, $value, array &$params): void
{
self::assertStreamTlsType('ssl_key_type', $value);
}
/**
* @param mixed $value as passed via Request transfer options.
*/
private function add_progress(RequestInterface $request, array &$options, $value, array &$params): void
{
if (!\is_callable($value)) {
throw new \InvalidArgumentException('progress client option must be callable');
}
self::addNotification(
$params,
static function ($code, $a, $b, $c, $transferred, $total) use ($value) {