From 3f18ff86478e968e46257a34baa646de2a99df3a Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Thu, 20 Nov 2025 12:03:51 -0800 Subject: [PATCH 01/14] fix: add streaming flag and error handling - Evaluates if a client's operation requires http streaming flag to be set to true, and if so then it pass the option along. - Make error parsers to reused a parsed body for scenario where the body is non seekable and can't be consumed again. Also change the condition: (!$body->isSeekable() || $body->getSize()) To: (!$body->isSeekable() || !$body->getSize()) The reason is that a stream non seekable will most likely not have a size, which means this condition will always be true. --- src/Api/ErrorParser/AbstractErrorParser.php | 27 +--- src/Api/ErrorParser/JsonParserTrait.php | 30 +++-- src/Api/ErrorParser/RestJsonErrorParser.php | 4 + src/Api/ErrorParser/XmlErrorParser.php | 17 ++- src/Api/Operation.php | 2 +- src/Api/Parser/AbstractRestParser.php | 2 +- src/Api/Parser/QueryParser.php | 2 +- src/AwsClient.php | 28 ++++ src/CloudWatchLogs/CloudWatchLogsClient.php | 23 ---- src/WrappedHttpHandler.php | 6 +- tests/AwsClientTest.php | 137 +++++++++++++++++++- 11 files changed, 217 insertions(+), 61 deletions(-) diff --git a/src/Api/ErrorParser/AbstractErrorParser.php b/src/Api/ErrorParser/AbstractErrorParser.php index c72f03f647..0e96406b12 100644 --- a/src/Api/ErrorParser/AbstractErrorParser.php +++ b/src/Api/ErrorParser/AbstractErrorParser.php @@ -7,6 +7,7 @@ use Aws\Api\StructureShape; use Aws\CommandInterface; use Psr\Http\Message\ResponseInterface; +use SimpleXMLElement; abstract class AbstractErrorParser { @@ -27,23 +28,10 @@ public function __construct(?Service $api = null) } abstract protected function payload( - ResponseInterface $response, + ResponseInterface|SimpleXMLElement|array $responseOrParsedBody, StructureShape $member ); - protected function extractPayload( - StructureShape $member, - ResponseInterface $response - ) { - if ($member instanceof StructureShape) { - // Structure members parse top-level data into a specific key. - return $this->payload($response, $member); - } else { - // Streaming data is just the stream from the response body. - return $response->getBody(); - } - } - protected function populateShape( array &$data, ResponseInterface $response, @@ -57,16 +45,15 @@ protected function populateShape( if (!empty($data['code'])) { $errors = $this->api->getOperation($command->getName())->getErrors(); - foreach ($errors as $key => $error) { + foreach ($errors as $error) { // If error code matches a known error shape, populate the body if ($this->errorCodeMatches($data, $error)) { - $modeledError = $error; - $data['body'] = $this->extractPayload( - $modeledError, - $response + $data['body'] = $this->payload( + $data['parsed'] ?? $response, + $error, ); - $data['error_shape'] = $modeledError; + $data['error_shape'] = $error; foreach ($error->getMembers() as $name => $member) { switch ($member['location']) { diff --git a/src/Api/ErrorParser/JsonParserTrait.php b/src/Api/ErrorParser/JsonParserTrait.php index 67afb1645a..626ec01303 100644 --- a/src/Api/ErrorParser/JsonParserTrait.php +++ b/src/Api/ErrorParser/JsonParserTrait.php @@ -3,7 +3,9 @@ use Aws\Api\Parser\PayloadParserTrait; use Aws\Api\StructureShape; +use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\ResponseInterface; +use SimpleXMLElement; /** * Provides basic JSON error parsing functionality. @@ -37,12 +39,23 @@ private function genericHandler(ResponseInterface $response): array ); } - $parsedBody = null; $body = $response->getBody(); - if (!$body->isSeekable() || $body->getSize()) { - $parsedBody = $this->parseJson((string) $body, $response); + // If the body is not seekable then, read the full message + // before de-serializing + if (!$body->isSeekable()) { + $tempBody = Utils::streamFor(); + Utils::copyToStream($body, $tempBody); + $body = $tempBody; } + // Parsing the body to avoid having to read the response body again. + // This will avoid issues when the body is not seekable + $parsedBody = $this->parseJson((string) $body, $response); + + // Make the casing lowercase in order to match + // how error shapes are modeled + $parsedBody = array_change_key_case($parsedBody); + // Parse error code from response body if (!$error_code && $parsedBody) { $error_code = $this->parseErrorFromBody($parsedBody); @@ -129,14 +142,13 @@ private function extractErrorCode(string $rawErrorCode): string } protected function payload( - ResponseInterface $response, + ResponseInterface|SimpleXMLElement|array $responseOrParsedBody, StructureShape $member ) { - $body = $response->getBody(); - if (!$body->isSeekable() || $body->getSize()) { - $jsonBody = $this->parseJson($body, $response); - } else { - $jsonBody = (string) $body; + $jsonBody = $responseOrParsedBody; + if ($responseOrParsedBody instanceof ResponseInterface) { + $body = $responseOrParsedBody->getBody(); + $jsonBody = $this->parseJson($body, $responseOrParsedBody); } return $this->parser->parse($member, $jsonBody); diff --git a/src/Api/ErrorParser/RestJsonErrorParser.php b/src/Api/ErrorParser/RestJsonErrorParser.php index 3d50a73ca6..b7aed8b0f5 100644 --- a/src/Api/ErrorParser/RestJsonErrorParser.php +++ b/src/Api/ErrorParser/RestJsonErrorParser.php @@ -44,6 +44,10 @@ public function __invoke( $this->populateShape($data, $response, $command); + if (!empty($data['body'])) { + $data = array_replace($data, $data['body']); + } + return $data; } } diff --git a/src/Api/ErrorParser/XmlErrorParser.php b/src/Api/ErrorParser/XmlErrorParser.php index 86f5d0be54..c1359a37ee 100644 --- a/src/Api/ErrorParser/XmlErrorParser.php +++ b/src/Api/ErrorParser/XmlErrorParser.php @@ -1,6 +1,7 @@ parseXml($response->getBody(), $response); + $xmlBody = $responseOrParsedBody; + if ($responseOrParsedBody instanceof ResponseInterface) { + $xmlBody = $this->parseXml( + $responseOrParsedBody->getBody(), + $responseOrParsedBody + ); + } + + $prefix = $this->registerNamespacePrefix($xmlBody); $errorBody = $xmlBody->xpath("//{$prefix}Error"); if (is_array($errorBody) && !empty($errorBody[0])) { return $this->parser->parse($member, $errorBody[0]); } + + throw new ParserException( + "Error element not found in parsed body" + ); } } diff --git a/src/Api/Operation.php b/src/Api/Operation.php index 36ba3950cb..43c695e72b 100644 --- a/src/Api/Operation.php +++ b/src/Api/Operation.php @@ -89,7 +89,7 @@ public function getOutput() /** * Get an array of operation error shapes. * - * @return Shape[] + * @return StructureShape[] */ public function getErrors() { diff --git a/src/Api/Parser/AbstractRestParser.php b/src/Api/Parser/AbstractRestParser.php index a0267e8d72..963890b9f5 100644 --- a/src/Api/Parser/AbstractRestParser.php +++ b/src/Api/Parser/AbstractRestParser.php @@ -57,7 +57,7 @@ public function __invoke( $body = $response->getBody(); if (!$payload - && (!$body->isSeekable() || $body->getSize()) + && (!$body->isSeekable() || !$body->getSize()) && count($output->getMembers()) > 0 ) { // if no payload was found, then parse the contents of the body diff --git a/src/Api/Parser/QueryParser.php b/src/Api/Parser/QueryParser.php index 2ea0676675..b99f08398b 100644 --- a/src/Api/Parser/QueryParser.php +++ b/src/Api/Parser/QueryParser.php @@ -41,7 +41,7 @@ public function __invoke( ) { $output = $this->api->getOperation($command->getName())->getOutput(); $body = $response->getBody(); - $xml = !$body->isSeekable() || $body->getSize() + $xml = (!$body->isSeekable() || !$body->getSize()) ? $this->parseXml($body, $response) : null; diff --git a/src/AwsClient.php b/src/AwsClient.php index 2b860b2211..b29c1ac061 100644 --- a/src/AwsClient.php +++ b/src/AwsClient.php @@ -283,6 +283,7 @@ public function __construct(array $args) $args['with_resolved']($config); } $this->addUserAgentMiddleware($config); + $this->addEventStreamHttpFlagMiddleware(); } public function getHandlerList() @@ -643,6 +644,33 @@ private function addUserAgentMiddleware($args) ); } + /** + * Enables streaming the response by using the stream flag. + * + * @return void + */ + private function addEventStreamHttpFlagMiddleware(): void + { + $this->getHandlerList() + -> appendInit( + function (callable $handler) { + return function (CommandInterface $command, $request = null) use ($handler) { + $operation = $this->getApi()->getOperation($command->getName()); + $output = $operation->getOutput(); + foreach ($output->getMembers() as $memberProps) { + if (!empty($memberProps['eventstream'])) { + $command['@http']['stream'] = true; + break; + } + } + + return $handler($command, $request); + }; + }, + 'event-streaming-flag-middleware' + ); + } + /** * Retrieves client context param definition from service model, * creates mapping of client context param names with client-provided diff --git a/src/CloudWatchLogs/CloudWatchLogsClient.php b/src/CloudWatchLogs/CloudWatchLogsClient.php index 8023c0029a..35034e4d71 100644 --- a/src/CloudWatchLogs/CloudWatchLogsClient.php +++ b/src/CloudWatchLogs/CloudWatchLogsClient.php @@ -199,29 +199,6 @@ class CloudWatchLogsClient extends AwsClient { public function __construct(array $args) { parent::__construct($args); - $this->addStreamingFlagMiddleware(); - } - - private function addStreamingFlagMiddleware() - { - $this->getHandlerList() - -> appendInit( - $this->getStreamingFlagMiddleware(), - 'streaming-flag-middleware' - ); - } - - private function getStreamingFlagMiddleware(): callable - { - return function (callable $handler) { - return function (CommandInterface $command, $request = null) use ($handler) { - if (!empty(self::$streamingCommands[$command->getName()])) { - $command['@http']['stream'] = true; - } - - return $handler($command, $request); - }; - }; } /** diff --git a/src/WrappedHttpHandler.php b/src/WrappedHttpHandler.php index 1c602de494..7d9ce7d40c 100644 --- a/src/WrappedHttpHandler.php +++ b/src/WrappedHttpHandler.php @@ -166,7 +166,7 @@ private function parseError( throw new \RuntimeException('The HTTP handler was rejected without an "exception" key value pair.'); } - $serviceError = "AWS HTTP error: " . $err['exception']->getMessage(); + $serviceError = "AWS HTTP error: \n"; if (!isset($err['response'])) { $parts = ['response' => null]; @@ -177,8 +177,8 @@ private function parseError( $err['response'], $command ); - $serviceError .= " {$parts['code']} ({$parts['type']}): " - . "{$parts['message']} - " . $err['response']->getBody(); + $serviceError .= "{$parts['code']} ({$parts['type']}): " + . "{$parts['message']}"; } catch (ParserException $e) { $parts = []; $serviceError .= ' Unable to parse error information from ' diff --git a/tests/AwsClientTest.php b/tests/AwsClientTest.php index 2f612faaf5..be1f476c5f 100644 --- a/tests/AwsClientTest.php +++ b/tests/AwsClientTest.php @@ -3,6 +3,7 @@ use Aws\Api\ApiProvider; use Aws\Api\ErrorParser\JsonRpcErrorParser; +use Aws\Api\Service; use Aws\AwsClient; use Aws\CommandInterface; use Aws\Credentials\Credentials; @@ -22,6 +23,7 @@ use Aws\Token\Token; use Aws\Waiter; use Aws\WrappedHttpHandler; +use Exception; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise\RejectedPromise; use GuzzleHttp\Psr7\Response; @@ -106,7 +108,7 @@ public function testWrapsExceptions() $h = new WrappedHttpHandler( function () { return new RejectedPromise([ - 'exception' => new \Exception('Baz Bar!'), + 'exception' => new Exception('Baz Bar!'), 'connection_error' => true, 'response' => null ]); @@ -1021,4 +1023,137 @@ public function testAppendsUserAgentMiddleware() ]); $client->listBuckets(); } + + /** + * @dataProvider appendEventStreamFlagMiddlewareProvider + * + * @param array $definition + * @param bool $isFlagPresent + * + * @return void + * + * @throws Exception + */ + public function testAppendEventStreamHttpFlagMiddleware( + array $definition, + bool $isFlagPresent + ): void + { + + $service = new Service($definition, function () {}); + $client = new AwsClient([ + 'service' => 'TestService', + 'api_provider' => function () use ($service) { + return $service->toArray(); + }, + 'region' => 'us-east-1', + 'version' => 'latest', + ]); + $called = false; + $client->getHandlerList()->setHandler(new MockHandler([new Result()])); + $client->getHandlerList()->appendInit(function(callable $handler) + use ($isFlagPresent, &$called) { + return function (CommandInterface $command, RequestInterface $request = null) + use ($handler, $isFlagPresent, &$called) { + $called = true; + $this->assertTrue( + ($command['@http']['stream'] ?? false) === $isFlagPresent, + ); + + return $handler($command, $request); + }; + }); + + $command = $client->getCommand('OperationTest'); + $client->execute($command); + $this->assertTrue($called); + } + + /** + * @return array[] + */ + public function appendEventStreamFlagMiddlewareProvider(): array + { + return [ + 'service_with_flag_present' => [ + 'definition' => [ + 'metadata' => [ + 'protocol' => 'rest-json', + 'protocols' => [ + 'rest-json' + ] + ], + 'operations' => [ + 'OperationTest' => [ + 'name' => 'OperationTest', + 'http' => [ + 'method' => 'POST', + 'requestUri' => '/operationTest', + 'responseCode' => 200, + ], + 'input' => ['shape' => 'OperationTestInput'], + 'output' => ['shape' => 'OperationTestOutput'], + ] + ], + 'shapes' => [ + 'OperationTestInput' => [ + 'type' => 'structure', + 'members' => [ + ] + ], + 'OperationTestOutput' => [ + 'type' => 'structure', + 'members' => [ + 'Stream' => [ + 'shape' => 'StreamShape', + 'eventstream' => true + ] + ] + ], + 'StreamShape' => ['type' => 'structure'] + ] + ], + 'present' => true + ], + 'service_with_flag_no_present' => [ + 'definition' => [ + 'metadata' => [ + 'protocol' => 'rest-json', + 'protocols' => [ + 'rest-json' + ] + ], + 'operations' => [ + 'OperationTest' => [ + 'name' => 'OperationTest', + 'http' => [ + 'method' => 'POST', + 'requestUri' => '/operationTest', + 'responseCode' => 200, + ], + 'input' => ['shape' => 'OperationTestInput'], + 'output' => ['shape' => 'OperationTestOutput'], + ] + ], + 'shapes' => [ + 'OperationTestInput' => [ + 'type' => 'structure', + 'members' => [ + ] + ], + 'OperationTestOutput' => [ + 'type' => 'structure', + 'members' => [ + 'NoStream' => [ + 'shape' => 'NoStreamShape', + ] + ] + ], + 'NoStreamShape' => ['type' => 'structure'] + ] + ], + 'present' => false + ] + ]; + } } From ee873c7b4697ade07596efe704b1baa65facfcdc Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Thu, 20 Nov 2025 12:41:14 -0800 Subject: [PATCH 02/14] revert: revert condition change Reverted the condition to what it was: (!$body->isSeekable() || $body->getSize()) Why?, I need to understand better what is the usage of this condition before changing. It was breaking some integ tests. --- src/Api/Parser/AbstractRestParser.php | 2 +- src/Api/Parser/QueryParser.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/Parser/AbstractRestParser.php b/src/Api/Parser/AbstractRestParser.php index 963890b9f5..a0267e8d72 100644 --- a/src/Api/Parser/AbstractRestParser.php +++ b/src/Api/Parser/AbstractRestParser.php @@ -57,7 +57,7 @@ public function __invoke( $body = $response->getBody(); if (!$payload - && (!$body->isSeekable() || !$body->getSize()) + && (!$body->isSeekable() || $body->getSize()) && count($output->getMembers()) > 0 ) { // if no payload was found, then parse the contents of the body diff --git a/src/Api/Parser/QueryParser.php b/src/Api/Parser/QueryParser.php index b99f08398b..5b94ce6907 100644 --- a/src/Api/Parser/QueryParser.php +++ b/src/Api/Parser/QueryParser.php @@ -41,7 +41,7 @@ public function __invoke( ) { $output = $this->api->getOperation($command->getName())->getOutput(); $body = $response->getBody(); - $xml = (!$body->isSeekable() || !$body->getSize()) + $xml = (!$body->isSeekable() || $body->getSize()) ? $this->parseXml($body, $response) : null; From a78557bd68947141762415e36c78ba5ea997b86f Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Thu, 20 Nov 2025 13:42:02 -0800 Subject: [PATCH 03/14] fix: handle empty body responses - When a response body is empty then we dont try to parse it. - When the response body is non seekable then, we read the full body before parsing. --- src/Api/ErrorParser/JsonParserTrait.php | 17 ++++++++++------- src/Api/ErrorParser/JsonRpcErrorParser.php | 14 ++++++++------ src/Api/ErrorParser/RestJsonErrorParser.php | 9 +++++++-- src/Api/ErrorParser/XmlErrorParser.php | 13 ++++++++++++- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/Api/ErrorParser/JsonParserTrait.php b/src/Api/ErrorParser/JsonParserTrait.php index 626ec01303..1a6e599960 100644 --- a/src/Api/ErrorParser/JsonParserTrait.php +++ b/src/Api/ErrorParser/JsonParserTrait.php @@ -48,13 +48,16 @@ private function genericHandler(ResponseInterface $response): array $body = $tempBody; } - // Parsing the body to avoid having to read the response body again. - // This will avoid issues when the body is not seekable - $parsedBody = $this->parseJson((string) $body, $response); - - // Make the casing lowercase in order to match - // how error shapes are modeled - $parsedBody = array_change_key_case($parsedBody); + // Cast it to string + $body = (string) $body; + $parsedBody = []; + + // Avoid parsing an empty body + if (!empty($body)) { + // Parsing the body to avoid having to read the response body again. + // This will avoid issues when the body is not seekable + $parsedBody = $this->parseJson($body, $response); + } // Parse error code from response body if (!$error_code && $parsedBody) { diff --git a/src/Api/ErrorParser/JsonRpcErrorParser.php b/src/Api/ErrorParser/JsonRpcErrorParser.php index 35e8ebe0ed..f12a6c078c 100644 --- a/src/Api/ErrorParser/JsonRpcErrorParser.php +++ b/src/Api/ErrorParser/JsonRpcErrorParser.php @@ -28,16 +28,18 @@ public function __invoke( $data = $this->genericHandler($response); // Make the casing consistent across services. - if ($data['parsed']) { - $data['parsed'] = array_change_key_case($data['parsed']); + $parsed = $data['parsed'] ?? null; + if ($parsed) { + $parsed = array_change_key_case($data['parsed']); } - if (isset($data['parsed']['__type'])) { + if (isset($parsed['__type'])) { if (!isset($data['code'])) { - $parts = explode('#', $data['parsed']['__type']); - $data['code'] = isset($parts[1]) ? $parts[1] : $parts[0]; + $parts = explode('#', $parsed['__type']); + $data['code'] = $parts[1] ?? $parts[0]; } - $data['message'] = $data['parsed']['message'] ?? null; + + $data['message'] = $parsed['message'] ?? null; } $this->populateShape($data, $response, $command); diff --git a/src/Api/ErrorParser/RestJsonErrorParser.php b/src/Api/ErrorParser/RestJsonErrorParser.php index b7aed8b0f5..2e878f17c5 100644 --- a/src/Api/ErrorParser/RestJsonErrorParser.php +++ b/src/Api/ErrorParser/RestJsonErrorParser.php @@ -38,9 +38,14 @@ public function __invoke( $data['type'] = strtolower($data['type']); } + // Make the casing consistent across services. + $parsed = $data['parsed'] ?? null; + if ($parsed) { + $parsed = array_change_key_case($data['parsed']); + } + // Retrieve error message directly - $data['message'] = $data['parsed']['message'] - ?? ($data['parsed']['Message'] ?? null); + $data['message'] = $parsed['message'] ?? null; $this->populateShape($data, $response, $command); diff --git a/src/Api/ErrorParser/XmlErrorParser.php b/src/Api/ErrorParser/XmlErrorParser.php index c1359a37ee..2378c66f4a 100644 --- a/src/Api/ErrorParser/XmlErrorParser.php +++ b/src/Api/ErrorParser/XmlErrorParser.php @@ -7,6 +7,7 @@ use Aws\Api\Service; use Aws\Api\StructureShape; use Aws\CommandInterface; +use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\ResponseInterface; /** @@ -39,7 +40,17 @@ public function __invoke( ]; $body = $response->getBody(); - if ($body->getSize() > 0) { + if (!$body->isSeekable()) { + $tempBody = Utils::streamFor(); + Utils::copyToStream($body, $tempBody); + $body = $tempBody; + } + + // Cast it to string + $body = (string) $body; + + // Parse just if is not empty + if (!empty($body)) { $this->parseBody($this->parseXml($body, $response), $data); } else { $this->parseHeaders($response, $data); From 93daaabd0502ce6892e7a23bd97a212858e26a1e Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Thu, 20 Nov 2025 13:57:20 -0800 Subject: [PATCH 04/14] fix: no populate data with body fields --- src/Api/ErrorParser/RestJsonErrorParser.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Api/ErrorParser/RestJsonErrorParser.php b/src/Api/ErrorParser/RestJsonErrorParser.php index 2e878f17c5..259f132009 100644 --- a/src/Api/ErrorParser/RestJsonErrorParser.php +++ b/src/Api/ErrorParser/RestJsonErrorParser.php @@ -49,10 +49,6 @@ public function __invoke( $this->populateShape($data, $response, $command); - if (!empty($data['body'])) { - $data = array_replace($data, $data['body']); - } - return $data; } } From e83146305b9c746d831ac591866ee9cde045e2e8 Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Fri, 21 Nov 2025 09:10:17 -0800 Subject: [PATCH 05/14] chore: make parsed to be all lowercase when returnd --- src/Api/ErrorParser/JsonRpcErrorParser.php | 3 +++ src/Api/ErrorParser/RestJsonErrorParser.php | 3 +++ src/WrappedHttpHandler.php | 4 +++- tests/AwsClientTest.php | 4 ++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Api/ErrorParser/JsonRpcErrorParser.php b/src/Api/ErrorParser/JsonRpcErrorParser.php index f12a6c078c..77da4087fe 100644 --- a/src/Api/ErrorParser/JsonRpcErrorParser.php +++ b/src/Api/ErrorParser/JsonRpcErrorParser.php @@ -44,6 +44,9 @@ public function __invoke( $this->populateShape($data, $response, $command); + // Now lets make parsed to be all lowercase + $data['parsed'] = $parsed; + return $data; } } diff --git a/src/Api/ErrorParser/RestJsonErrorParser.php b/src/Api/ErrorParser/RestJsonErrorParser.php index 259f132009..6407e5d725 100644 --- a/src/Api/ErrorParser/RestJsonErrorParser.php +++ b/src/Api/ErrorParser/RestJsonErrorParser.php @@ -49,6 +49,9 @@ public function __invoke( $this->populateShape($data, $response, $command); + // Now lets make parsed to be all lowercase + $data['parsed'] = $parsed; + return $data; } } diff --git a/src/WrappedHttpHandler.php b/src/WrappedHttpHandler.php index 7d9ce7d40c..c6cfa3260c 100644 --- a/src/WrappedHttpHandler.php +++ b/src/WrappedHttpHandler.php @@ -166,10 +166,11 @@ private function parseError( throw new \RuntimeException('The HTTP handler was rejected without an "exception" key value pair.'); } - $serviceError = "AWS HTTP error: \n"; + $serviceError = "AWS HTTP error:\n"; if (!isset($err['response'])) { $parts = ['response' => null]; + $serviceError .= $err['exception']->getMessage(); } else { try { $parts = call_user_func( @@ -177,6 +178,7 @@ private function parseError( $err['response'], $command ); + $serviceError .= "{$parts['code']} ({$parts['type']}): " . "{$parts['message']}"; } catch (ParserException $e) { diff --git a/tests/AwsClientTest.php b/tests/AwsClientTest.php index be1f476c5f..96134647d5 100644 --- a/tests/AwsClientTest.php +++ b/tests/AwsClientTest.php @@ -101,8 +101,8 @@ public function testReturnsCommandForOperation() public function testWrapsExceptions() { - $this->expectExceptionMessage("Error executing \"foo\" on \"http://us-east-1.foo.amazonaws.com/\"; AWS HTTP error: Baz Bar!"); - $this->expectException(\Aws\S3\Exception\S3Exception::class); + $this->expectExceptionMessage("Error executing \"foo\" on \"http://us-east-1.foo.amazonaws.com/\"; AWS HTTP error:\nBaz Bar!"); + $this->expectException(S3Exception::class); $parser = function () {}; $errorParser = new JsonRpcErrorParser(); $h = new WrappedHttpHandler( From 0ca2ca6342be5756fb8aa0c207ea5dfad39d68d8 Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Fri, 21 Nov 2025 10:38:50 -0800 Subject: [PATCH 06/14] chore: remove lowercasing parsed - Remove lowercasing parsed in rest json error parser. --- src/Api/ErrorParser/RestJsonErrorParser.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Api/ErrorParser/RestJsonErrorParser.php b/src/Api/ErrorParser/RestJsonErrorParser.php index 6407e5d725..259f132009 100644 --- a/src/Api/ErrorParser/RestJsonErrorParser.php +++ b/src/Api/ErrorParser/RestJsonErrorParser.php @@ -49,9 +49,6 @@ public function __invoke( $this->populateShape($data, $response, $command); - // Now lets make parsed to be all lowercase - $data['parsed'] = $parsed; - return $data; } } From db5dd6a29df5d035ff1e1a4351a542c8519649f1 Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Tue, 25 Nov 2025 13:28:09 -0800 Subject: [PATCH 07/14] test: test error is parsed Error is parsed when the body of the response is not seekable. --- tests/WrappedHttpHandlerTest.php | 82 ++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/WrappedHttpHandlerTest.php b/tests/WrappedHttpHandlerTest.php index 9311b47ae6..c5ea10282b 100644 --- a/tests/WrappedHttpHandlerTest.php +++ b/tests/WrappedHttpHandlerTest.php @@ -11,6 +11,8 @@ use Aws\Result; use Aws\WrappedHttpHandler; use GuzzleHttp\Promise\RejectedPromise; +use GuzzleHttp\Psr7\NoSeekStream; +use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\RequestInterface; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; @@ -375,4 +377,84 @@ public function testPassesOnTransferStatsCallbackToHandlerWhenRequested() $wrapped(new Command('a'), new Request('GET', 'http://foo.com')) ->wait(); } + + /** + * @dataProvider errorIsParsedOnNonSeekableResponseBodyProvider + * + * @return void + */ + public function testErrorIsParsedOnNonSeekableResponseBody( + string $protocol, + string $body, + string $expected + ) + { + $service = $this->generateTestService($protocol); + $parser = Service::createParser($service); + $errorParser = Service::createErrorParser($service->getProtocol(), $service); + $client = $this->generateTestClient( + $service + ); + $command = $client->getCommand('TestOperation'); + $exception = new AwsException( + 'Failed performing test operation', + $command, + ); + $uri = 'http://myservice.myregion.foo.com'; + $request = new Request('GET', $uri); + $response = new Response( + 403, + [], + new NoSeekStream( + Utils::streamFor($body) + ) + ); + $handler = function () use ($exception, $response) { + return new RejectedPromise([ + 'exception' => $exception, + 'response' => $response, + ]); + }; + $wrapped = new WrappedHttpHandler($handler, $parser, $errorParser); + try { + $wrapped($command, $request)->wait(); + $this->fail( + "Operation should have failed!" + ); + } catch (\Exception $exception) { + $this->assertStringContainsString( + $expected, + $exception->getMessage() + ); + } + } + + /** + * @return array[] + */ + public function errorIsParsedOnNonSeekableResponseBodyProvider(): array + { + return [ + 'json' => [ + 'protocol' => 'json', + 'body' => '{"Message": "Action not allowed!", "__Type": "ListObjects"}', + 'expected' => 'ListObjects (client): Action not allowed!', + ], + 'query' => [ + 'protocol' => 'query', + 'body' => 'ListObjectsAction not allowed!', + 'expected' => 'ListObjects (client): Action not allowed!', + ], + 'rest-xml' => [ + 'protocol' => 'rest-xml', + 'body' => 'ListObjectsAction not allowed!', + 'expected' => 'ListObjects (client): Action not allowed!', + ], + 'rest-json' => [ + 'protocol' => 'rest-json', + 'body' => '{"message": "Action not allowed!", "code": "ListObjects"}', + 'expected' => 'Action not allowed!', + ] + ]; + } } From 8d1cdcdee06c2ceb3aa599eb840e7ac1365eff4b Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Tue, 25 Nov 2025 13:32:43 -0800 Subject: [PATCH 08/14] chore: add code to expected error --- tests/WrappedHttpHandlerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WrappedHttpHandlerTest.php b/tests/WrappedHttpHandlerTest.php index c5ea10282b..f813f4089b 100644 --- a/tests/WrappedHttpHandlerTest.php +++ b/tests/WrappedHttpHandlerTest.php @@ -453,7 +453,7 @@ public function errorIsParsedOnNonSeekableResponseBodyProvider(): array 'rest-json' => [ 'protocol' => 'rest-json', 'body' => '{"message": "Action not allowed!", "code": "ListObjects"}', - 'expected' => 'Action not allowed!', + 'expected' => 'ListObjects (client): Action not allowed!', ] ]; } From 12770bf3dedbc6e902db9d68ea949fa0801376ef Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Wed, 24 Dec 2025 08:58:09 -0800 Subject: [PATCH 09/14] fix: parsing empty payloads When the response body is a non seekable stream, which happens when the stream flag is set in the request, the parser condition to do a parsing is evaluated to true, but in empty payloads, such as when doing head requests, it fails. To remedy this we read the full body content and evaluate whether the returned value is empty before parsing. --- src/Api/ErrorParser/JsonParserTrait.php | 13 ++----- src/Api/ErrorParser/XmlErrorParser.php | 12 ++----- src/Api/Parser/AbstractRestParser.php | 6 ++-- src/Api/Parser/QueryParser.php | 6 ++-- tests/Api/Parser/RestJsonParserTest.php | 47 +++++++++++++++++++++++++ tests/Api/Parser/RestXmlParserTest.php | 47 +++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 25 deletions(-) diff --git a/src/Api/ErrorParser/JsonParserTrait.php b/src/Api/ErrorParser/JsonParserTrait.php index 1a6e599960..738cb3b34e 100644 --- a/src/Api/ErrorParser/JsonParserTrait.php +++ b/src/Api/ErrorParser/JsonParserTrait.php @@ -39,19 +39,10 @@ private function genericHandler(ResponseInterface $response): array ); } - $body = $response->getBody(); - // If the body is not seekable then, read the full message - // before de-serializing - if (!$body->isSeekable()) { - $tempBody = Utils::streamFor(); - Utils::copyToStream($body, $tempBody); - $body = $tempBody; - } + // Read the full payload, even in non-seekable streams + $body = $response->getBody()->getContents(); - // Cast it to string - $body = (string) $body; $parsedBody = []; - // Avoid parsing an empty body if (!empty($body)) { // Parsing the body to avoid having to read the response body again. diff --git a/src/Api/ErrorParser/XmlErrorParser.php b/src/Api/ErrorParser/XmlErrorParser.php index 2378c66f4a..cb46819d4f 100644 --- a/src/Api/ErrorParser/XmlErrorParser.php +++ b/src/Api/ErrorParser/XmlErrorParser.php @@ -39,16 +39,8 @@ public function __invoke( 'parsed' => null ]; - $body = $response->getBody(); - if (!$body->isSeekable()) { - $tempBody = Utils::streamFor(); - Utils::copyToStream($body, $tempBody); - $body = $tempBody; - } - - // Cast it to string - $body = (string) $body; - + // Read the full payload, even in non-seekable streams + $body = $response->getBody()->getContents(); // Parse just if is not empty if (!empty($body)) { $this->parseBody($this->parseXml($body, $response), $data); diff --git a/src/Api/Parser/AbstractRestParser.php b/src/Api/Parser/AbstractRestParser.php index a0267e8d72..d15079f003 100644 --- a/src/Api/Parser/AbstractRestParser.php +++ b/src/Api/Parser/AbstractRestParser.php @@ -55,9 +55,11 @@ public function __invoke( } } - $body = $response->getBody(); + // Read the full payload, even in non-seekable streams + $body = $response->getBody()->getContents(); + // Make sure empty payloads are not parsed if (!$payload - && (!$body->isSeekable() || $body->getSize()) + && !empty($body) && count($output->getMembers()) > 0 ) { // if no payload was found, then parse the contents of the body diff --git a/src/Api/Parser/QueryParser.php b/src/Api/Parser/QueryParser.php index 5b94ce6907..52ef5a7ed2 100644 --- a/src/Api/Parser/QueryParser.php +++ b/src/Api/Parser/QueryParser.php @@ -40,8 +40,10 @@ public function __invoke( ResponseInterface $response ) { $output = $this->api->getOperation($command->getName())->getOutput(); - $body = $response->getBody(); - $xml = (!$body->isSeekable() || $body->getSize()) + // Read the full payload, even in non-seekable streams + $body = $response->getBody()->getContents(); + // Just parse when the body is not empty + $xml = !empty($body) ? $this->parseXml($body, $response) : null; diff --git a/tests/Api/Parser/RestJsonParserTest.php b/tests/Api/Parser/RestJsonParserTest.php index 132c2cc35d..6f6b64e7bd 100644 --- a/tests/Api/Parser/RestJsonParserTest.php +++ b/tests/Api/Parser/RestJsonParserTest.php @@ -6,7 +6,9 @@ use Aws\Api\Service; use Aws\CommandInterface; use Aws\Test\Api\Parser\ParserTestServiceTrait; +use GuzzleHttp\Psr7\NoSeekStream; use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Utils; use Yoast\PHPUnitPolyfills\TestCases\TestCase; /** @@ -180,4 +182,49 @@ public function testParsesFalsyHeaderValues(): void // Empty string should still be skipped $this->assertArrayNotHasKey('Empty', $result); } + + public function testParsesEmptyResponseOnNonSeekableStream(): void + { + $shape = [ + 'type' => 'structure', + 'members' => [ + 'TestMember' => [ + 'type' => 'string', + ] + ] + ]; + + $api = new Service([ + 'metadata' => [ + 'protocol' => 'rest-json' + ], + 'operations' => [ + 'TestOperation' => [ + 'http' => ['method' => 'GET'], + 'output' => $shape + ] + ], + 'shapes' => [] + ], function () {}); + + $parser = new RestJsonParser($api); + $response = new Response( + 200, + [], + new NoSeekStream( + Utils::streamFor() + ) + ); + $command = $this->getMockBuilder( + CommandInterface::class + )->getMock(); + $command->method('getName')->willReturn('TestOperation'); + + $parser($command, $response); + + // Not error occurred. Test successfully. + // Previously, on non-seekable streams it would have failed. + // Because, it would have tried to parse an empty string. + $this->assertTrue(true); + } } diff --git a/tests/Api/Parser/RestXmlParserTest.php b/tests/Api/Parser/RestXmlParserTest.php index 3a968373ab..b586ad40b4 100644 --- a/tests/Api/Parser/RestXmlParserTest.php +++ b/tests/Api/Parser/RestXmlParserTest.php @@ -5,7 +5,9 @@ use Aws\Api\Parser\RestXmlParser; use Aws\Api\Service; use Aws\CommandInterface; +use GuzzleHttp\Psr7\NoSeekStream; use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Utils; use Yoast\PHPUnitPolyfills\TestCases\TestCase; /** @@ -110,4 +112,49 @@ public function testParsesFalsyHeaderValues(): void // Empty string should still be skipped $this->assertArrayNotHasKey('Empty', $result); } + + public function testParsesEmptyResponseOnNonSeekableStream(): void + { + $shape = [ + 'type' => 'structure', + 'members' => [ + 'TestMember' => [ + 'type' => 'string', + ] + ] + ]; + + $api = new Service([ + 'metadata' => [ + 'protocol' => 'rest-xml' + ], + 'operations' => [ + 'TestOperation' => [ + 'http' => ['method' => 'GET'], + 'output' => $shape + ] + ], + 'shapes' => [] + ], function () {}); + + $parser = new RestXmlParser($api); + $response = new Response( + 200, + [], + new NoSeekStream( + Utils::streamFor() + ) + ); + $command = $this->getMockBuilder( + CommandInterface::class + )->getMock(); + $command->method('getName')->willReturn('TestOperation'); + + $parser($command, $response); + + // Not error occurred. Test successfully. + // Previously, on non-seekable streams it would have failed. + // Because, it would have tried to parse an empty string. + $this->assertTrue(true); + } } From bd73efd189ad2fca248a702ee3403b5de841c627 Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Wed, 24 Dec 2025 09:02:25 -0800 Subject: [PATCH 10/14] chore: remove unused variable --- src/CloudWatchLogs/CloudWatchLogsClient.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/CloudWatchLogs/CloudWatchLogsClient.php b/src/CloudWatchLogs/CloudWatchLogsClient.php index 35034e4d71..284924c135 100644 --- a/src/CloudWatchLogs/CloudWatchLogsClient.php +++ b/src/CloudWatchLogs/CloudWatchLogsClient.php @@ -192,9 +192,6 @@ * @method \GuzzleHttp\Promise\Promise updateLogAnomalyDetectorAsync(array $args = []) */ class CloudWatchLogsClient extends AwsClient { - static $streamingCommands = [ - 'StartLiveTail' => true - ]; public function __construct(array $args) { From dfa9e41a3c30a7111427ccba474349d14887455d Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Fri, 2 Jan 2026 04:43:42 -0800 Subject: [PATCH 11/14] chore: use a response wrapper To make a body content to be fully available always in implementations a response wrapper class has been created that takes in a response and evaluates if the body is non-seekable then it wrappes it into a CachingStream that will allow the body to rewind. --- src/Api/ErrorParser/AbstractErrorParser.php | 5 ++- src/Api/ErrorParser/JsonParserTrait.php | 20 +++++---- src/Api/ErrorParser/JsonRpcErrorParser.php | 2 + src/Api/ErrorParser/RestJsonErrorParser.php | 2 + src/Api/ErrorParser/XmlErrorParser.php | 30 ++++++++------ src/Api/ResponseWrapper.php | 46 +++++++++++++++++++++ 6 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 src/Api/ResponseWrapper.php diff --git a/src/Api/ErrorParser/AbstractErrorParser.php b/src/Api/ErrorParser/AbstractErrorParser.php index 0e96406b12..6402409ad9 100644 --- a/src/Api/ErrorParser/AbstractErrorParser.php +++ b/src/Api/ErrorParser/AbstractErrorParser.php @@ -5,6 +5,7 @@ use Aws\Api\Parser\PayloadParserTrait; use Aws\Api\Service; use Aws\Api\StructureShape; +use Aws\Api\ResponseWrapper; use Aws\CommandInterface; use Psr\Http\Message\ResponseInterface; use SimpleXMLElement; @@ -28,7 +29,7 @@ public function __construct(?Service $api = null) } abstract protected function payload( - ResponseInterface|SimpleXMLElement|array $responseOrParsedBody, + ResponseInterface $response, StructureShape $member ); @@ -50,7 +51,7 @@ protected function populateShape( // If error code matches a known error shape, populate the body if ($this->errorCodeMatches($data, $error)) { $data['body'] = $this->payload( - $data['parsed'] ?? $response, + $response, $error, ); $data['error_shape'] = $error; diff --git a/src/Api/ErrorParser/JsonParserTrait.php b/src/Api/ErrorParser/JsonParserTrait.php index 738cb3b34e..0dee30801a 100644 --- a/src/Api/ErrorParser/JsonParserTrait.php +++ b/src/Api/ErrorParser/JsonParserTrait.php @@ -3,6 +3,7 @@ use Aws\Api\Parser\PayloadParserTrait; use Aws\Api\StructureShape; +use Aws\Api\ResponseWrapper; use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\ResponseInterface; use SimpleXMLElement; @@ -39,14 +40,12 @@ private function genericHandler(ResponseInterface $response): array ); } - // Read the full payload, even in non-seekable streams + // We get the full body content $body = $response->getBody()->getContents(); $parsedBody = []; // Avoid parsing an empty body if (!empty($body)) { - // Parsing the body to avoid having to read the response body again. - // This will avoid issues when the body is not seekable $parsedBody = $this->parseJson($body, $response); } @@ -64,7 +63,7 @@ private function genericHandler(ResponseInterface $response): array 'code' => $error_code ?? null, 'message' => null, 'type' => $error_type, - 'parsed' => $parsedBody + 'parsed' => $parsedBody, ]; } @@ -136,15 +135,18 @@ private function extractErrorCode(string $rawErrorCode): string } protected function payload( - ResponseInterface|SimpleXMLElement|array $responseOrParsedBody, + ResponseInterface $response, StructureShape $member ) { - $jsonBody = $responseOrParsedBody; - if ($responseOrParsedBody instanceof ResponseInterface) { - $body = $responseOrParsedBody->getBody(); - $jsonBody = $this->parseJson($body, $responseOrParsedBody); + $body = $response->getBody()->getContents(); + + // Avoid parsing empty bodies + if (empty($body)) { + return []; } + $jsonBody = $this->parseJson($body, $response); + return $this->parser->parse($member, $jsonBody); } } diff --git a/src/Api/ErrorParser/JsonRpcErrorParser.php b/src/Api/ErrorParser/JsonRpcErrorParser.php index 77da4087fe..1ed190352a 100644 --- a/src/Api/ErrorParser/JsonRpcErrorParser.php +++ b/src/Api/ErrorParser/JsonRpcErrorParser.php @@ -3,6 +3,7 @@ use Aws\Api\Parser\JsonParser; use Aws\Api\Service; +use Aws\Api\ResponseWrapper; use Aws\CommandInterface; use Psr\Http\Message\ResponseInterface; @@ -25,6 +26,7 @@ public function __invoke( ResponseInterface $response, ?CommandInterface $command = null ) { + $response = new ResponseWrapper($response); $data = $this->genericHandler($response); // Make the casing consistent across services. diff --git a/src/Api/ErrorParser/RestJsonErrorParser.php b/src/Api/ErrorParser/RestJsonErrorParser.php index 259f132009..b6a1bdbb94 100644 --- a/src/Api/ErrorParser/RestJsonErrorParser.php +++ b/src/Api/ErrorParser/RestJsonErrorParser.php @@ -4,6 +4,7 @@ use Aws\Api\Parser\JsonParser; use Aws\Api\Service; use Aws\Api\StructureShape; +use Aws\Api\ResponseWrapper; use Aws\CommandInterface; use Psr\Http\Message\ResponseInterface; @@ -26,6 +27,7 @@ public function __invoke( ResponseInterface $response, ?CommandInterface $command = null ) { + $response = new ResponseWrapper($response); $data = $this->genericHandler($response); // Merge in error data from the JSON body diff --git a/src/Api/ErrorParser/XmlErrorParser.php b/src/Api/ErrorParser/XmlErrorParser.php index cb46819d4f..2b25d39b4a 100644 --- a/src/Api/ErrorParser/XmlErrorParser.php +++ b/src/Api/ErrorParser/XmlErrorParser.php @@ -6,6 +6,7 @@ use Aws\Api\Parser\XmlParser; use Aws\Api\Service; use Aws\Api\StructureShape; +use Aws\Api\ResponseWrapper; use Aws\CommandInterface; use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\ResponseInterface; @@ -29,6 +30,7 @@ public function __invoke( ResponseInterface $response, ?CommandInterface $command = null ) { + $response = new ResponseWrapper($response); $code = (string) $response->getStatusCode(); $data = [ @@ -36,11 +38,12 @@ public function __invoke( 'request_id' => null, 'code' => null, 'message' => null, - 'parsed' => null + 'parsed' => null, ]; - // Read the full payload, even in non-seekable streams + // Get the full body content $body = $response->getBody()->getContents(); + // Parse just if is not empty if (!empty($body)) { $this->parseBody($this->parseXml($body, $response), $data); @@ -101,17 +104,20 @@ protected function registerNamespacePrefix(\SimpleXMLElement $element) } protected function payload( - ResponseInterface|\SimpleXMLElement|array $responseOrParsedBody, + ResponseInterface $response, StructureShape $member ) { - $xmlBody = $responseOrParsedBody; - if ($responseOrParsedBody instanceof ResponseInterface) { - $xmlBody = $this->parseXml( - $responseOrParsedBody->getBody(), - $responseOrParsedBody - ); + $body = $response->getBody()->getContents(); + + // Avoid parsing empty bodies + if (empty($body)) { + return []; } + $xmlBody = $this->parseXml( + $body, + $response + ); $prefix = $this->registerNamespacePrefix($xmlBody); $errorBody = $xmlBody->xpath("//{$prefix}Error"); @@ -120,8 +126,8 @@ protected function payload( return $this->parser->parse($member, $errorBody[0]); } - throw new ParserException( - "Error element not found in parsed body" - ); + // Fallback since we should either throw an exception or return a value + // when the condition above is not met. + return []; } } diff --git a/src/Api/ResponseWrapper.php b/src/Api/ResponseWrapper.php new file mode 100644 index 0000000000..2c27b0cb91 --- /dev/null +++ b/src/Api/ResponseWrapper.php @@ -0,0 +1,46 @@ +getBody(); + if (!$body->isSeekable()) { + $body = new CachingStream($response->getBody()); + } + + parent::__construct( + $response->getStatusCode(), + $response->getHeaders(), + $body, + $response->getProtocolVersion(), + $response->getReasonPhrase() + ); + } + + /** + * @return StreamInterface + */ + public function getBody(): StreamInterface + { + $stream = parent::getBody(); + $stream->rewind(); + + return $stream; + } +} From 51140beb3e5ef68f597824b359e457fac1761bbb Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Fri, 2 Jan 2026 07:12:29 -0800 Subject: [PATCH 12/14] chore: add response wrapper in abstract parser --- src/Api/Parser/AbstractRestParser.php | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Api/Parser/AbstractRestParser.php b/src/Api/Parser/AbstractRestParser.php index d15079f003..4898d6475a 100644 --- a/src/Api/Parser/AbstractRestParser.php +++ b/src/Api/Parser/AbstractRestParser.php @@ -2,6 +2,7 @@ namespace Aws\Api\Parser; use Aws\Api\DateTimeResult; +use Aws\Api\ResponseWrapper; use Aws\Api\Shape; use Aws\Api\StructureShape; use Aws\Result; @@ -55,7 +56,12 @@ public function __invoke( } } - // Read the full payload, even in non-seekable streams + // Wrap response to allow the body + // to be read multiple times + // even in non-seekable streams. + $response = new ResponseWrapper($response); + + // Get the body content to know whether is empty $body = $response->getBody()->getContents(); // Make sure empty payloads are not parsed if (!$payload @@ -77,17 +83,25 @@ private function extractPayload( ) { $member = $output->getMember($payload); $body = $response->getBody(); - if (!empty($member['eventstream'])) { $result[$payload] = new EventParsingIterator( $body, $member, $this ); - } elseif ($member instanceof StructureShape) { + + return; + } + + // We don't create the wrapper above + // because on event-stream we want to keep + // the original non-seekable stream. + $response = new ResponseWrapper($response); + if ($member instanceof StructureShape) { //Unions must have at least one member set to a non-null value // If the body is empty, we can assume it is unset - if (!empty($member['union']) && ($body->isSeekable() && !$body->getSize())) { + if (!empty($member['union']) + && empty($response->getBody()->getContents())) { return; } From e7167fada3b651a4712a0cca7b11584765811f42 Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Fri, 2 Jan 2026 08:18:15 -0800 Subject: [PATCH 13/14] chore: add nullable mark to request --- tests/AwsClientTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AwsClientTest.php b/tests/AwsClientTest.php index 96134647d5..d546b70937 100644 --- a/tests/AwsClientTest.php +++ b/tests/AwsClientTest.php @@ -1053,7 +1053,7 @@ public function testAppendEventStreamHttpFlagMiddleware( $client->getHandlerList()->setHandler(new MockHandler([new Result()])); $client->getHandlerList()->appendInit(function(callable $handler) use ($isFlagPresent, &$called) { - return function (CommandInterface $command, RequestInterface $request = null) + return function (CommandInterface $command, ?RequestInterface $request = null) use ($handler, $isFlagPresent, &$called) { $called = true; $this->assertTrue( From 461e2e0659dff0b72bde8149ba97e08d6d44c4be Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Fri, 2 Jan 2026 09:20:21 -0800 Subject: [PATCH 14/14] chore: remove unused imports --- src/Api/ErrorParser/AbstractErrorParser.php | 2 -- src/Api/ErrorParser/JsonParserTrait.php | 3 --- src/Api/ErrorParser/RestJsonErrorParser.php | 1 - src/Api/ErrorParser/XmlErrorParser.php | 2 -- 4 files changed, 8 deletions(-) diff --git a/src/Api/ErrorParser/AbstractErrorParser.php b/src/Api/ErrorParser/AbstractErrorParser.php index 6402409ad9..887f1ea152 100644 --- a/src/Api/ErrorParser/AbstractErrorParser.php +++ b/src/Api/ErrorParser/AbstractErrorParser.php @@ -5,10 +5,8 @@ use Aws\Api\Parser\PayloadParserTrait; use Aws\Api\Service; use Aws\Api\StructureShape; -use Aws\Api\ResponseWrapper; use Aws\CommandInterface; use Psr\Http\Message\ResponseInterface; -use SimpleXMLElement; abstract class AbstractErrorParser { diff --git a/src/Api/ErrorParser/JsonParserTrait.php b/src/Api/ErrorParser/JsonParserTrait.php index 0dee30801a..5a28865c96 100644 --- a/src/Api/ErrorParser/JsonParserTrait.php +++ b/src/Api/ErrorParser/JsonParserTrait.php @@ -3,10 +3,7 @@ use Aws\Api\Parser\PayloadParserTrait; use Aws\Api\StructureShape; -use Aws\Api\ResponseWrapper; -use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\ResponseInterface; -use SimpleXMLElement; /** * Provides basic JSON error parsing functionality. diff --git a/src/Api/ErrorParser/RestJsonErrorParser.php b/src/Api/ErrorParser/RestJsonErrorParser.php index b6a1bdbb94..38ea1b4ff4 100644 --- a/src/Api/ErrorParser/RestJsonErrorParser.php +++ b/src/Api/ErrorParser/RestJsonErrorParser.php @@ -3,7 +3,6 @@ use Aws\Api\Parser\JsonParser; use Aws\Api\Service; -use Aws\Api\StructureShape; use Aws\Api\ResponseWrapper; use Aws\CommandInterface; use Psr\Http\Message\ResponseInterface; diff --git a/src/Api/ErrorParser/XmlErrorParser.php b/src/Api/ErrorParser/XmlErrorParser.php index 2b25d39b4a..91aadf0f90 100644 --- a/src/Api/ErrorParser/XmlErrorParser.php +++ b/src/Api/ErrorParser/XmlErrorParser.php @@ -1,14 +1,12 @@