Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: s3 200 errors handling #2968

Merged
merged 5 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/S3/Parser/GetBucketLocationResultMutator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Aws\S3\Parser;

use Aws\CommandInterface;
use Aws\ResultInterface;
use Psr\Http\Message\ResponseInterface;

/**
* A custom mutator for a GetBucketLocation request, which
* extract the bucket location value and injects it into the
* result as the `LocationConstraint` field.
*
* @internal
*/
final class GetBucketLocationResultMutator implements S3ResultMutator
{
/**
* @inheritDoc
*/
public function __invoke(
ResultInterface $result,
CommandInterface $command,
ResponseInterface $response
): ResultInterface
{
if ($command->getName() !== 'GetBucketLocation') {
return $result;
}

static $location = 'us-east-1';
static $pattern = '/>(.+?)<\/LocationConstraint>/';
if (preg_match($pattern, $response->getBody(), $matches)) {
$location = $matches[1] === 'EU' ? 'eu-west-1' : $matches[1];
}

$result['LocationConstraint'] = $location;
$response->getBody()->rewind();

return $result;
}
}
275 changes: 275 additions & 0 deletions src/S3/Parser/S3Parser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
<?php

namespace Aws\S3\Parser;

use Aws\Api\ErrorParser\XmlErrorParser;
use Aws\Api\Parser\AbstractParser;
use Aws\Api\Parser\Exception\ParserException;
use Aws\Api\Service;
use Aws\Api\StructureShape;
use Aws\CommandInterface;
use Aws\Exception\AwsException;
use Aws\ResultInterface;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;

/**
* Custom S3 parser on top of the S3 protocol parser
* for handling specific S3 parsing scenarios.
*
* @internal
*/
final class S3Parser extends AbstractParser
{
/** @var AbstractParser */
private $protocolParser;
/** @var XmlErrorParser */
private $errorParser;
/** @var string */
private $exceptionClass;
/** @var array */
private $s3ResultMutators;

/**
* @param AbstractParser $protocolParser
* @param XmlErrorParser $errorParser
* @param Service $api
* @param string $exceptionClass
*/
public function __construct(
AbstractParser $protocolParser,
XmlErrorParser $errorParser,
Service $api,
string $exceptionClass = AwsException::class
)
{
parent::__construct($api);
$this->protocolParser = $protocolParser;
$this->errorParser = $errorParser;
$this->exceptionClass = $exceptionClass;
$this->s3ResultMutators = [];
}

/**
* Parses a S3 response.
*
* @param CommandInterface $command The command that originated the request.
* @param ResponseInterface $response The response received from the service.
*
* @return ResultInterface|null
*/
public function __invoke(
CommandInterface $command,
ResponseInterface $response
):? ResultInterface
{
// Check first if the response is an error
$this->parse200Error($command, $response);

try {
$parseFn = $this->protocolParser;
$result = $parseFn($command, $response);
} catch (ParserException $e) {
// Parsing errors will be considered retryable.
throw new $this->exceptionClass(
"Error parsing response for {$command->getName()}:"
. " AWS parsing error: {$e->getMessage()}",
$command,
['connection_error' => true, 'exception' => $e],
$e
);
}

return $this->executeS3ResultMutators($result, $command, $response);
}

/**
* Tries to parse a 200 response as an error from S3.
* If the parsed result contains a code and message then that means an error
* was found, and hence an exception is thrown with that error.
*
* @param CommandInterface $command
* @param ResponseInterface $response
*
* @return void
*/
private function parse200Error(
CommandInterface $command,
ResponseInterface $response
): void
{
// This error parsing should be just for 200 error responses
// and operations where its output shape does not have a streaming
// member.
if (200 !== $response->getStatusCode()
|| !$this->shouldBeConsidered200Error($command->getName())) {
return;
}

// To guarantee we try the error parsing just for an Error xml response.
if (!$this->isFirstRootElementError($response->getBody())) {
return;
}

try {
$errorParserFn = $this->errorParser;
$parsedError = $errorParserFn($response, $command);
} catch (ParserException $e) {
// Parsing errors will be considered retryable.
$parsedError = [
'code' => 'ConnectionError',
'message' => "An error connecting to the service occurred"
. " while performing the " . $command->getName()
. " operation."
];
}

if (isset($parsedError['code']) && isset($parsedError['message'])) {
throw new $this->exceptionClass(
$parsedError['message'],
$command,
[
'connection_error' => true,
'code' => $parsedError['code'],
'message' => $parsedError['message']
]
);
}
}

/**
* Checks if a specific operation should be considered
* a s3 200 error. Operations where any of its output members
* has a streaming or httpPayload trait should be not considered.
*
* @param $commandName
*
* @return bool
*/
private function shouldBeConsidered200Error($commandName): bool
{
$operation = $this->api->getOperation($commandName);
$output = $operation->getOutput();
foreach ($output->getMembers() as $_ => $memberProps) {
if (!empty($memberProps['eventstream']) || !empty($memberProps['streaming'])) {
return false;
}
}

return true;
}

/**
* Checks if the root element of the response body is "Error", which is
* when we should try to parse an error from a 200 response from s3.
* It is recommended to make sure the stream given is seekable, otherwise
* the rewind call will cause a user warning.
*
* @param StreamInterface $responseBody
yenfryherrerafeliz marked this conversation as resolved.
Show resolved Hide resolved
*
* @return bool
*/
private function isFirstRootElementError(StreamInterface $responseBody): bool
{
static $pattern = '/<\?xml version="1\.0" encoding="UTF-8"\?>\s*<Error>/';
// To avoid performance overhead in large streams
$reducedBodyContent = $responseBody->read(64);
$foundErrorElement = preg_match($pattern, $reducedBodyContent);
// A rewind is needed because the stream is partially or entirely consumed
// in the previous read operation.
$responseBody->rewind();

return $foundErrorElement;
}

/**
* Execute mutator implementations over a result.
* Mutators are logics that modifies a result.
*
* @param ResultInterface $result
* @param CommandInterface $command
* @param ResponseInterface $response
*
* @return ResultInterface
*/
private function executeS3ResultMutators(
ResultInterface $result,
CommandInterface $command,
ResponseInterface $response
): ResultInterface
{
foreach ($this->s3ResultMutators as $mutator) {
$result = $mutator($result, $command, $response);
}

return $result;
}

/**
* Adds a mutator into the list of mutators.
*
* @param string $mutatorName
* @param S3ResultMutator $s3ResultMutator
* @return void
*/
public function addS3ResultMutator(
string $mutatorName,
S3ResultMutator $s3ResultMutator
): void
{
if (isset($this->s3ResultMutators[$mutatorName])) {
trigger_error(
"The S3 Result Mutator {$mutatorName} already exists!",
E_USER_WARNING
);

return;
}

$this->s3ResultMutators[$mutatorName] = $s3ResultMutator;
}

/**
* Removes a mutator from the mutator list.
*
* @param string $mutatorName
* @return void
*/
public function removeS3ResultMutator(string $mutatorName): void
{
if (!isset($this->s3ResultMutators[$mutatorName])) {
trigger_error(
"The S3 Result Mutator {$mutatorName} does not exist!",
E_USER_WARNING
);

return;
}

unset($this->s3ResultMutators[$mutatorName]);
}

/**
* Returns the list of result mutators available.
*
* @return array
*/
public function getS3ResultMutators(): array
{
return $this->s3ResultMutators;
}

public function parseMemberFromStream(
StreamInterface $stream,
StructureShape $member,
$response
)
{
return $this->protocolParser->parseMemberFromStream(
$stream,
$member,
$response
);
}
}
36 changes: 36 additions & 0 deletions src/S3/Parser/S3ResultMutator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Aws\S3\Parser;

use Aws\CommandInterface;
use Aws\ResultInterface;
use Psr\Http\Message\ResponseInterface;

/**
* Interface for S3 result mutator implementations.
* A S3 result mutator is meant for modifying a request
* result before returning it to the user.
* One example is if a custom field is needed to be injected
* into the result or if an existent field needs to be modified.
* Since the command and the response itself are parameters when
* invoking the mutators then, this facilitates to make better
* decisions that may involve validations using the command parameters
* or response fields, etc.
*
* @internal
*/
interface S3ResultMutator
{
/**
* @param ResultInterface $result the result object to be modified.
* @param CommandInterface $command the command that originated the request.
* @param ResponseInterface $response the response resulting from the request.
*
* @return ResultInterface
*/
public function __invoke(
ResultInterface $result,
CommandInterface $command,
ResponseInterface $response
): ResultInterface;
}
Loading