| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470 |
- <?php
- declare(strict_types=1);
- namespace AsyncAws\Core;
- use AsyncAws\Core\AwsError\AwsErrorFactoryInterface;
- use AsyncAws\Core\AwsError\ChainAwsErrorFactory;
- use AsyncAws\Core\EndpointDiscovery\EndpointCache;
- use AsyncAws\Core\Exception\Exception;
- use AsyncAws\Core\Exception\Http\ClientException;
- use AsyncAws\Core\Exception\Http\HttpException;
- use AsyncAws\Core\Exception\Http\NetworkException;
- use AsyncAws\Core\Exception\Http\RedirectionException;
- use AsyncAws\Core\Exception\Http\ServerException;
- use AsyncAws\Core\Exception\InvalidArgument;
- use AsyncAws\Core\Exception\LogicException;
- use AsyncAws\Core\Exception\RuntimeException;
- use AsyncAws\Core\Exception\UnparsableResponse;
- use AsyncAws\Core\Stream\ResponseBodyResourceStream;
- use AsyncAws\Core\Stream\ResponseBodyStream;
- use AsyncAws\Core\Stream\ResultStream;
- use Psr\Log\LoggerInterface;
- use Psr\Log\LogLevel;
- use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
- use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
- use Symfony\Contracts\HttpClient\HttpClientInterface;
- use Symfony\Contracts\HttpClient\ResponseInterface;
- /**
- * The response provides a facade to manipulate HttpResponses.
- *
- * @author Jérémy Derussé <jeremy@derusse.com>
- *
- * @internal
- */
- class Response
- {
- /**
- * @var ResponseInterface
- */
- private $httpResponse;
- private $httpClient;
- /**
- * A Result can be resolved many times. This variable contains the last resolve result.
- * Null means that the result has never been resolved. Array contains material to create an exception.
- *
- * @var bool|HttpException|NetworkException|callable|null
- */
- private $resolveResult;
- /**
- * A flag that indicated that the body have been downloaded.
- *
- * @var bool
- */
- private $bodyDownloaded = false;
- /**
- * A flag that indicated that the body started being downloaded.
- *
- * @var bool
- */
- private $streamStarted = false;
- /**
- * A flag that indicated that an exception has been thrown to the user.
- */
- private $didThrow = false;
- /**
- * @var LoggerInterface
- */
- private $logger;
- /**
- * @var AwsErrorFactoryInterface
- */
- private $awsErrorFactory;
- /**
- * @var ?EndpointCache
- */
- private $endpointCache;
- /**
- * @var ?Request
- */
- private $request;
- /**
- * @var bool
- */
- private $debug;
- /**
- * @var array<string, string>
- */
- private $exceptionMapping;
- public function __construct(ResponseInterface $response, HttpClientInterface $httpClient, LoggerInterface $logger, ?AwsErrorFactoryInterface $awsErrorFactory = null, ?EndpointCache $endpointCache = null, ?Request $request = null, bool $debug = false, array $exceptionMapping = [])
- {
- $this->httpResponse = $response;
- $this->httpClient = $httpClient;
- $this->logger = $logger;
- $this->awsErrorFactory = $awsErrorFactory ?? new ChainAwsErrorFactory();
- $this->endpointCache = $endpointCache;
- $this->request = $request;
- $this->debug = $debug;
- $this->exceptionMapping = $exceptionMapping;
- }
- public function __destruct()
- {
- if (null === $this->resolveResult || !$this->didThrow) {
- $this->resolve();
- }
- }
- /**
- * Make sure the actual request is executed.
- *
- * @param float|null $timeout Duration in seconds before aborting. When null wait
- * until the end of execution. Using 0 means non-blocking
- *
- * @return bool whether the request is executed or not
- *
- * @throws NetworkException
- * @throws HttpException
- */
- public function resolve(?float $timeout = null): bool
- {
- if (null !== $this->resolveResult) {
- return $this->getResolveStatus();
- }
- try {
- if (null === $timeout) {
- $this->httpResponse->getStatusCode();
- } else {
- foreach ($this->httpClient->stream($this->httpResponse, $timeout) as $chunk) {
- if ($chunk->isTimeout()) {
- return false;
- }
- if ($chunk->isFirst()) {
- break;
- }
- }
- }
- $this->defineResolveStatus();
- } catch (TransportExceptionInterface $e) {
- $this->resolveResult = new NetworkException('Could not contact remote server.', 0, $e);
- }
- if (true === $this->debug) {
- $httpStatusCode = $this->httpResponse->getInfo('http_code');
- if (0 === $httpStatusCode) {
- // Network exception
- $this->logger->debug('AsyncAws HTTP request could not be sent due network issues');
- } else {
- $this->logger->debug('AsyncAws HTTP response received with status code {status_code}', [
- 'status_code' => $httpStatusCode,
- 'headers' => json_encode($this->httpResponse->getHeaders(false)),
- 'body' => $this->httpResponse->getContent(false),
- ]);
- $this->bodyDownloaded = true;
- }
- }
- return $this->getResolveStatus();
- }
- /**
- * Make sure all provided requests are executed.
- *
- * @param self[] $responses
- * @param float|null $timeout Duration in seconds before aborting. When null wait
- * until the end of execution. Using 0 means non-blocking
- * @param bool $downloadBody Wait until receiving the entire response body or only the first bytes
- *
- * @return iterable<self>
- *
- * @throws NetworkException
- * @throws HttpException
- */
- final public static function wait(iterable $responses, ?float $timeout = null, bool $downloadBody = false): iterable
- {
- /** @var self[] $responseMap */
- $responseMap = [];
- $indexMap = [];
- $httpResponses = [];
- $httpClient = null;
- foreach ($responses as $index => $response) {
- if (null !== $response->resolveResult && (true !== $response->resolveResult || !$downloadBody || $response->bodyDownloaded)) {
- yield $index => $response;
- continue;
- }
- if (null === $httpClient) {
- $httpClient = $response->httpClient;
- } elseif ($httpClient !== $response->httpClient) {
- throw new LogicException('Unable to wait for the given results, they all have to be created with the same HttpClient');
- }
- $httpResponses[] = $response->httpResponse;
- $indexMap[$hash = spl_object_id($response->httpResponse)] = $index;
- $responseMap[$hash] = $response;
- }
- // no response provided (or all responses already resolved)
- if (empty($httpResponses)) {
- return;
- }
- if (null === $httpClient) {
- throw new InvalidArgument('At least one response should have contain an Http Client');
- }
- foreach ($httpClient->stream($httpResponses, $timeout) as $httpResponse => $chunk) {
- $hash = spl_object_id($httpResponse);
- $response = $responseMap[$hash] ?? null;
- // Check if null, just in case symfony yield an unexpected response.
- if (null === $response) {
- continue;
- }
- // index could be null if already yield
- $index = $indexMap[$hash] ?? null;
- try {
- if ($chunk->isTimeout()) {
- // Receiving a timeout mean all responses are inactive.
- break;
- }
- } catch (TransportExceptionInterface $e) {
- // Exception is stored as an array, because storing an instance of \Exception will create a circular
- // reference and prevent `__destruct` being called.
- $response->resolveResult = new NetworkException('Could not contact remote server.', 0, $e);
- if (null !== $index) {
- unset($indexMap[$hash]);
- yield $index => $response;
- if (empty($indexMap)) {
- // early exit if all statusCode are known. We don't have to wait for all responses
- return;
- }
- }
- }
- if (!$response->streamStarted && '' !== $chunk->getContent()) {
- $response->streamStarted = true;
- }
- if ($chunk->isLast()) {
- $response->bodyDownloaded = true;
- if (null !== $index && $downloadBody) {
- unset($indexMap[$hash]);
- yield $index => $response;
- }
- }
- if ($chunk->isFirst()) {
- $response->defineResolveStatus();
- if (null !== $index && !$downloadBody) {
- unset($indexMap[$hash]);
- yield $index => $response;
- }
- }
- if (empty($indexMap)) {
- // early exit if all statusCode are known. We don't have to wait for all responses
- return;
- }
- }
- }
- /**
- * Returns info on the current request.
- *
- * @return array{
- * resolved: bool,
- * body_downloaded: bool,
- * response: \Symfony\Contracts\HttpClient\ResponseInterface,
- * status: int,
- * }
- */
- public function info(): array
- {
- return [
- 'resolved' => null !== $this->resolveResult,
- 'body_downloaded' => $this->bodyDownloaded,
- 'response' => $this->httpResponse,
- 'status' => (int) $this->httpResponse->getInfo('http_code'),
- ];
- }
- public function cancel(): void
- {
- $this->httpResponse->cancel();
- $this->resolveResult = false;
- }
- /**
- * @throws NetworkException
- * @throws HttpException
- */
- public function getHeaders(): array
- {
- $this->resolve();
- return $this->httpResponse->getHeaders(false);
- }
- /**
- * @throws NetworkException
- * @throws HttpException
- */
- public function getContent(): string
- {
- $this->resolve();
- try {
- return $this->httpResponse->getContent(false);
- } finally {
- $this->bodyDownloaded = true;
- }
- }
- /**
- * @throws NetworkException
- * @throws UnparsableResponse
- * @throws HttpException
- */
- public function toArray(): array
- {
- $this->resolve();
- try {
- return $this->httpResponse->toArray(false);
- } catch (DecodingExceptionInterface $e) {
- throw new UnparsableResponse('Could not parse response as array', 0, $e);
- } finally {
- $this->bodyDownloaded = true;
- }
- }
- public function getStatusCode(): int
- {
- return $this->httpResponse->getStatusCode();
- }
- /**
- * @throws NetworkException
- * @throws HttpException
- */
- public function toStream(): ResultStream
- {
- $this->resolve();
- if (\is_callable([$this->httpResponse, 'toStream'])) {
- return new ResponseBodyResourceStream($this->httpResponse->toStream());
- }
- if ($this->streamStarted) {
- throw new RuntimeException('Can not create a ResultStream because the body started being downloaded. The body was started to be downloaded in Response::wait()');
- }
- try {
- return new ResponseBodyStream($this->httpClient->stream($this->httpResponse));
- } finally {
- $this->bodyDownloaded = true;
- }
- }
- /**
- * In PHP < 7.4, a reference to the arguments is present in the stackTrace of the exception.
- * This creates a Circular reference: Response -> resolveResult -> Exception -> stackTrace -> Response.
- * This mean, that calling `unset($response)` does not call the `__destruct` method and does not throw the
- * remaining exception present in `resolveResult`. The `__destruct` method will be called once the garbage collector
- * will detect the loop.
- * That's why this method does not creates exception here, but creates closure instead that will be resolved right
- * before throwing the exception.
- */
- private function defineResolveStatus(): void
- {
- try {
- $statusCode = $this->httpResponse->getStatusCode();
- } catch (TransportExceptionInterface $e) {
- $this->resolveResult = static function () use ($e): NetworkException {
- return new NetworkException('Could not contact remote server.', 0, $e);
- };
- return;
- }
- if (300 <= $statusCode) {
- try {
- $awsError = $this->awsErrorFactory->createFromResponse($this->httpResponse);
- if ($this->request && $this->endpointCache && (400 === $statusCode || 'InvalidEndpointException' === $awsError->getCode())) {
- $this->endpointCache->removeEndpoint($this->request->getEndpoint());
- }
- } catch (UnparsableResponse $e) {
- $awsError = null;
- }
- if ((null !== $awsCode = ($awsError ? $awsError->getCode() : null)) && isset($this->exceptionMapping[$awsCode])) {
- $exceptionClass = $this->exceptionMapping[$awsCode];
- } elseif (500 <= $statusCode) {
- $exceptionClass = ServerException::class;
- } elseif (400 <= $statusCode) {
- $exceptionClass = ClientException::class;
- } else {
- $exceptionClass = RedirectionException::class;
- }
- $httpResponse = $this->httpResponse;
- /** @psalm-suppress MoreSpecificReturnType */
- $this->resolveResult = static function () use ($exceptionClass, $httpResponse, $awsError): HttpException {
- /** @psalm-suppress LessSpecificReturnStatement */
- return new $exceptionClass($httpResponse, $awsError);
- };
- return;
- }
- $this->resolveResult = true;
- }
- private function getResolveStatus(): bool
- {
- if (\is_bool($this->resolveResult)) {
- return $this->resolveResult;
- }
- if (\is_callable($this->resolveResult)) {
- /** @psalm-suppress PropertyTypeCoercion */
- $this->resolveResult = ($this->resolveResult)();
- }
- $code = null;
- $message = null;
- $context = ['exception' => $this->resolveResult];
- if ($this->resolveResult instanceof HttpException) {
- /** @var int $code */
- $code = $this->httpResponse->getInfo('http_code');
- /** @var string $url */
- $url = $this->httpResponse->getInfo('url');
- $context['aws_code'] = $this->resolveResult->getAwsCode();
- $context['aws_message'] = $this->resolveResult->getAwsMessage();
- $context['aws_type'] = $this->resolveResult->getAwsType();
- $context['aws_detail'] = $this->resolveResult->getAwsDetail();
- $message = sprintf('HTTP %d returned for "%s".', $code, $url);
- }
- if ($this->resolveResult instanceof Exception) {
- $this->logger->log(
- 404 === $code ? LogLevel::INFO : LogLevel::ERROR,
- $message ?? $this->resolveResult->getMessage(),
- $context
- );
- $this->didThrow = true;
- throw $this->resolveResult;
- }
- throw new RuntimeException('Unexpected resolve state');
- }
- }
|