From 31c04b4a1baefc92ca60158ba323600839746418 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 23 Jul 2026 01:27:11 +0100 Subject: [PATCH] feat: add support for Guzzle 8 --- BigQuery/tests/Unit/BigQueryClientTest.php | 4 ++-- BigQuery/tests/Unit/Connection/RestTest.php | 11 +++++++++-- Core/composer.json | 8 ++++---- Core/src/RequestWrapper.php | 4 +++- Core/src/RetryDeciderTrait.php | 9 +++++++-- Core/tests/Unit/RequestWrapperTest.php | 4 ++-- Gax/composer.json | 6 +++--- Gax/src/ApiException.php | 4 +++- Gax/src/Transport/GrpcFallbackTransport.php | 4 +++- .../Transport/Rest/RestServerStreamingCall.php | 4 +++- Gax/src/Transport/RestTransport.php | 5 ++++- Storage/src/Connection/Rest.php | 5 ++++- Storage/tests/Unit/Connection/RestTest.php | 17 ++++++++++++----- composer.json | 6 +++--- dev/src/GitHub.php | 5 ++++- .../Unit/Command/ReleaseInfoCommandTest.php | 10 ++++++---- 16 files changed, 72 insertions(+), 34 deletions(-) diff --git a/BigQuery/tests/Unit/BigQueryClientTest.php b/BigQuery/tests/Unit/BigQueryClientTest.php index 1a2153cb19ea..5b2dba2693d9 100644 --- a/BigQuery/tests/Unit/BigQueryClientTest.php +++ b/BigQuery/tests/Unit/BigQueryClientTest.php @@ -47,7 +47,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\ServerException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -1033,7 +1033,7 @@ public function testRetryLogging() $doneJobResponse['status']['state'] = 'DONE'; $apiMockHandler = new MockHandler([ - new RequestException( + new ServerException( 'Transient error', new Request('POST', ''), new Response(502) diff --git a/BigQuery/tests/Unit/Connection/RestTest.php b/BigQuery/tests/Unit/Connection/RestTest.php index 3dcefc0000d7..ba5087389ec2 100644 --- a/BigQuery/tests/Unit/Connection/RestTest.php +++ b/BigQuery/tests/Unit/Connection/RestTest.php @@ -210,7 +210,14 @@ function ($args) use (&$actualRequest, $response) { private function getMetadata(Request $request) { - $lines = explode(PHP_EOL, (string) $request->getBody()); - return json_decode($lines[5], true); + // guzzle/psr7 2 generates per-part Content-Length headers, psr7 3 + // does not; strip them so the metadata line offset matches on both. + $lines = array_values(array_filter( + explode(PHP_EOL, (string) $request->getBody()), + function ($line) { + return strpos($line, 'Content-Length:') !== 0; + } + )); + return json_decode($lines[4], true); } } diff --git a/Core/composer.json b/Core/composer.json index 250456a29cee..77f0ddbcc7c1 100644 --- a/Core/composer.json +++ b/Core/composer.json @@ -6,10 +6,10 @@ "require": { "php": "^8.1", "rize/uri-template": "~0.3||~0.4", - "google/auth": "^1.34", - "guzzlehttp/guzzle": "^6.5.8||^7.4.4", - "guzzlehttp/promises": "^1.4||^2.0", - "guzzlehttp/psr7": "^2.6", + "google/auth": "^1.53", + "guzzlehttp/guzzle": "^7.8.2||^8.0", + "guzzlehttp/promises": "^2.0.3||^3.0", + "guzzlehttp/psr7": "^2.6.3||^3.0", "monolog/monolog": "^2.9||^3.0", "psr/http-message": "^1.0||^2.0", "google/gax": "^1.38.0" diff --git a/Core/src/RequestWrapper.php b/Core/src/RequestWrapper.php index 8170a282e824..e0d4d729d864 100644 --- a/Core/src/RequestWrapper.php +++ b/Core/src/RequestWrapper.php @@ -453,7 +453,9 @@ private function convertToGoogleException(\Exception $ex) */ private function getExceptionMessage(\Exception $ex) { - if ($ex instanceof RequestException && $ex->hasResponse()) { + // Guzzle 7 carries the response on RequestException, Guzzle 8 only on + // its ResponseException subclass, hence the method_exists() check. + if ($ex instanceof RequestException && method_exists($ex, 'getResponse') && $ex->getResponse()) { return (string) $ex->getResponse()->getBody(); } diff --git a/Core/src/RetryDeciderTrait.php b/Core/src/RetryDeciderTrait.php index d0d9fbde22b3..d6ced51a3c29 100644 --- a/Core/src/RetryDeciderTrait.php +++ b/Core/src/RetryDeciderTrait.php @@ -66,8 +66,13 @@ private function getRetryFunction($shouldRetryMessages = true) return false; } - $message = ($ex instanceof RequestException && $ex->hasResponse()) - ? (string) $ex->getResponse()->getBody() + // Guzzle 7 carries the response on RequestException, Guzzle 8 only + // on its ResponseException subclass, hence the method_exists() check. + $response = $ex instanceof RequestException && method_exists($ex, 'getResponse') + ? $ex->getResponse() + : null; + $message = $response + ? (string) $response->getBody() : $ex->getMessage(); try { diff --git a/Core/tests/Unit/RequestWrapperTest.php b/Core/tests/Unit/RequestWrapperTest.php index 66dce210cfdd..e7b891e97f2d 100644 --- a/Core/tests/Unit/RequestWrapperTest.php +++ b/Core/tests/Unit/RequestWrapperTest.php @@ -31,7 +31,7 @@ use Google\Cloud\Core\Exception\ServerException; use Google\Cloud\Core\Exception\ServiceException; use Google\Cloud\Core\RequestWrapper; -use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Request; @@ -335,7 +335,7 @@ public function testExceptionMessageIsNotTruncatedWithGuzzle() $msg = str_repeat('0', 121); $jsonMsg = '{"msg":"' . $msg . '"}'; - throw new RequestException( + throw new ClientException( $jsonMsg, $request, new Response(400, [], $jsonMsg) diff --git a/Gax/composer.json b/Gax/composer.json index bae29e2c1996..d2b521c8139e 100644 --- a/Gax/composer.json +++ b/Gax/composer.json @@ -9,12 +9,12 @@ "license": "BSD-3-Clause", "require": { "php": "^8.1", - "google/auth": "^1.52", + "google/auth": "^1.53", "google/grpc-gcp": "^0.4", "grpc/grpc": "^1.13", "google/protobuf": "^4.31||^5.34", - "guzzlehttp/promises": "^2.0", - "guzzlehttp/psr7": "^2.0", + "guzzlehttp/promises": "^2.0.3||^3.0", + "guzzlehttp/psr7": "^2.6.3||^3.0", "google/common-protos": "^4.9", "google/longrunning": "~0.4", "ramsey/uuid": "^4.0" diff --git a/Gax/src/ApiException.php b/Gax/src/ApiException.php index 89843149e332..90fcc3067640 100644 --- a/Gax/src/ApiException.php +++ b/Gax/src/ApiException.php @@ -354,7 +354,9 @@ public static function createFromRpcStatus(Status $status) */ public static function createFromRequestException(RequestException $ex, bool $isStream = false) { - $res = $ex->getResponse(); + // Guzzle 7 carries the response on RequestException, Guzzle 8 only on + // its ResponseException subclass, hence the method_exists() check. + $res = method_exists($ex, 'getResponse') ? $ex->getResponse() : null; $body = (string) $res->getBody(); $decoded = json_decode($body, true); diff --git a/Gax/src/Transport/GrpcFallbackTransport.php b/Gax/src/Transport/GrpcFallbackTransport.php index b248b052e3ef..474f7aa871ec 100644 --- a/Gax/src/Transport/GrpcFallbackTransport.php +++ b/Gax/src/Transport/GrpcFallbackTransport.php @@ -203,7 +203,9 @@ private function getCallOptions(array $options) */ private function transformException(\Exception $ex) { - if ($ex instanceof RequestException && $ex->hasResponse()) { + // Guzzle 7 carries the response on RequestException, Guzzle 8 only on + // its ResponseException subclass, hence the method_exists() check. + if ($ex instanceof RequestException && method_exists($ex, 'getResponse') && $ex->getResponse()) { $res = $ex->getResponse(); $body = (string) $res->getBody(); $status = new Status(); diff --git a/Gax/src/Transport/Rest/RestServerStreamingCall.php b/Gax/src/Transport/Rest/RestServerStreamingCall.php index 80d5e3e3c14f..92e1f2b1ffdd 100644 --- a/Gax/src/Transport/Rest/RestServerStreamingCall.php +++ b/Gax/src/Transport/Rest/RestServerStreamingCall.php @@ -86,7 +86,9 @@ public function start($request, array $headers = [], array $callOptions = []) $callOptions )->wait(); } catch (\Exception $ex) { - if ($ex instanceof RequestException && $ex->hasResponse()) { + // Guzzle 7 carries the response on RequestException, Guzzle 8 only + // on its ResponseException subclass, hence the method_exists() check. + if ($ex instanceof RequestException && method_exists($ex, 'getResponse') && $ex->getResponse()) { $ex = ApiException::createFromRequestException($ex, /* isStream */ true); } throw $ex; diff --git a/Gax/src/Transport/RestTransport.php b/Gax/src/Transport/RestTransport.php index aab687182aa8..0afd1193e77f 100644 --- a/Gax/src/Transport/RestTransport.php +++ b/Gax/src/Transport/RestTransport.php @@ -169,7 +169,10 @@ function (ResponseInterface $response) use ($call, $options) { return $return; }, function (\Throwable $ex) { - if ($ex instanceof RequestException && $ex->hasResponse()) { + // Guzzle 7 carries the response on RequestException, Guzzle 8 + // only on its ResponseException subclass, hence the + // method_exists() check. + if ($ex instanceof RequestException && method_exists($ex, 'getResponse') && $ex->getResponse()) { throw ApiException::createFromRequestException($ex); } diff --git a/Storage/src/Connection/Rest.php b/Storage/src/Connection/Rest.php index 02acc0867fd2..9388839e8bb4 100644 --- a/Storage/src/Connection/Rest.php +++ b/Storage/src/Connection/Rest.php @@ -408,8 +408,11 @@ public function downloadObject(array $args = []) &$attempt, ) { // if the exception has a response for us to use + // (Guzzle 7 carries it on RequestException, Guzzle 8 only on its + // ResponseException subclass, hence the method_exists() check) if ($e instanceof RequestException - && $e->hasResponse() + && method_exists($e, 'getResponse') + && $e->getResponse() && $e->getResponse()->getStatusCode() >= 200 && $e->getResponse()->getStatusCode() < 300 ) { diff --git a/Storage/tests/Unit/Connection/RestTest.php b/Storage/tests/Unit/Connection/RestTest.php index ab946d5edec8..d2afc3ddf590 100644 --- a/Storage/tests/Unit/Connection/RestTest.php +++ b/Storage/tests/Unit/Connection/RestTest.php @@ -28,7 +28,7 @@ use Google\Cloud\Storage\Connection\Rest; use Google\Cloud\Storage\Connection\RetryTrait; use GuzzleHttp\Client; -use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Request; @@ -511,7 +511,7 @@ function ($args) use ( $actualRequests[$requestIndex] = $args[0]; $requestHeaders[$requestIndex] = $args[1]['headers'] ?? []; if ($requestIndex++ === 0) { - throw new RequestException("Server error", $args[0], new Response($status1, [], $body1)); + throw new BadResponseException("Server error", $args[0], new Response($status1, [], $body1)); } return new Response($status2, [], $body2); } @@ -1041,10 +1041,17 @@ private function getContentTypeAndMetadata(RequestInterface $request) } // Multipart upload request - $lines = explode(PHP_EOL, (string) $request->getBody()); + // (guzzle/psr7 2 generates per-part Content-Length headers, psr7 3 + // does not; strip them so the line offsets match on both) + $lines = array_values(array_filter( + explode(PHP_EOL, (string) $request->getBody()), + function ($line) { + return strpos($line, 'Content-Length:') !== 0; + } + )); return [ - trim(explode(':', $lines[7])[1]), - json_decode($lines[5], true) + trim(explode(':', $lines[6])[1]), + json_decode($lines[4], true) ]; } } diff --git a/composer.json b/composer.json index 496621a55bea..5c4b78526b45 100644 --- a/composer.json +++ b/composer.json @@ -58,9 +58,9 @@ "require": { "php": "^8.1", "rize/uri-template": "~0.3", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/psr7": "^2.6", - "guzzlehttp/promises": "^2.0", + "guzzlehttp/guzzle": "^7.8.2||^8.0", + "guzzlehttp/psr7": "^2.6.3||^3.0", + "guzzlehttp/promises": "^2.0.3||^3.0", "monolog/monolog": "^2.9||^3.0", "psr/http-message": "^1.0|^2.0", "grpc/grpc": "^1.13", diff --git a/dev/src/GitHub.php b/dev/src/GitHub.php index efde3bc37435..e67fa9eb4b5d 100644 --- a/dev/src/GitHub.php +++ b/dev/src/GitHub.php @@ -213,7 +213,10 @@ public function getChangelog($target, $tagName): string|null|false return json_decode((string) $res->getBody(), true)['body']; } catch (RequestException $e) { $this->logException($e); - return $e->getResponse()?->getStatusCode() === 404 ? false : null; + // Guzzle 7 carries the response on RequestException, Guzzle 8 only + // on its ResponseException subclass, hence the method_exists() check. + $response = method_exists($e, 'getResponse') ? $e->getResponse() : null; + return $response?->getStatusCode() === 404 ? false : null; } } diff --git a/dev/tests/Unit/Command/ReleaseInfoCommandTest.php b/dev/tests/Unit/Command/ReleaseInfoCommandTest.php index 30ba169956b0..a8b7874cd3b9 100644 --- a/dev/tests/Unit/Command/ReleaseInfoCommandTest.php +++ b/dev/tests/Unit/Command/ReleaseInfoCommandTest.php @@ -20,6 +20,7 @@ use Google\Cloud\Dev\Command\ReleaseInfoCommand; use Google\Cloud\Dev\Composer; use GuzzleHttp\Client; +use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Exception\RequestException; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; @@ -146,7 +147,9 @@ public function testInvalidTagThrowsException() $response->getStatusCode() ->shouldBeCalledOnce() ->willReturn(404); - $exception = $this->prophesize(RequestException::class); + // On Guzzle 8 the response lives on the BadResponseException subclass, + // not on the base RequestException. + $exception = $this->prophesize(BadResponseException::class); $exception->getResponse() ->shouldBeCalledOnce() ->willReturn($response->reveal()); @@ -168,10 +171,9 @@ public function testBadRequestThrowsException() $this->expectExceptionMessage('Unable to retrieve tag'); $tag = 'v0.1000.0'; + // A base RequestException carries no response on Guzzle 8, so the + // changelog lookup cannot determine the status and returns null. $exception = $this->prophesize(RequestException::class); - $exception->getResponse() - ->shouldBeCalledOnce() - ->willReturn(null); $http = $this->prophesize(Client::class); $http->get( 'https://api.github.com/repos/googleapis/google-cloud-php/releases/tags/' . $tag,