Response.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. <?php
  2. declare(strict_types=1);
  3. namespace AsyncAws\Core;
  4. use AsyncAws\Core\AwsError\AwsErrorFactoryInterface;
  5. use AsyncAws\Core\AwsError\ChainAwsErrorFactory;
  6. use AsyncAws\Core\EndpointDiscovery\EndpointCache;
  7. use AsyncAws\Core\Exception\Exception;
  8. use AsyncAws\Core\Exception\Http\ClientException;
  9. use AsyncAws\Core\Exception\Http\HttpException;
  10. use AsyncAws\Core\Exception\Http\NetworkException;
  11. use AsyncAws\Core\Exception\Http\RedirectionException;
  12. use AsyncAws\Core\Exception\Http\ServerException;
  13. use AsyncAws\Core\Exception\InvalidArgument;
  14. use AsyncAws\Core\Exception\LogicException;
  15. use AsyncAws\Core\Exception\RuntimeException;
  16. use AsyncAws\Core\Exception\UnparsableResponse;
  17. use AsyncAws\Core\Stream\ResponseBodyResourceStream;
  18. use AsyncAws\Core\Stream\ResponseBodyStream;
  19. use AsyncAws\Core\Stream\ResultStream;
  20. use Psr\Log\LoggerInterface;
  21. use Psr\Log\LogLevel;
  22. use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
  23. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  24. use Symfony\Contracts\HttpClient\HttpClientInterface;
  25. use Symfony\Contracts\HttpClient\ResponseInterface;
  26. /**
  27. * The response provides a facade to manipulate HttpResponses.
  28. *
  29. * @author Jérémy Derussé <jeremy@derusse.com>
  30. *
  31. * @internal
  32. */
  33. class Response
  34. {
  35. /**
  36. * @var ResponseInterface
  37. */
  38. private $httpResponse;
  39. private $httpClient;
  40. /**
  41. * A Result can be resolved many times. This variable contains the last resolve result.
  42. * Null means that the result has never been resolved. Array contains material to create an exception.
  43. *
  44. * @var bool|HttpException|NetworkException|callable|null
  45. */
  46. private $resolveResult;
  47. /**
  48. * A flag that indicated that the body have been downloaded.
  49. *
  50. * @var bool
  51. */
  52. private $bodyDownloaded = false;
  53. /**
  54. * A flag that indicated that the body started being downloaded.
  55. *
  56. * @var bool
  57. */
  58. private $streamStarted = false;
  59. /**
  60. * A flag that indicated that an exception has been thrown to the user.
  61. */
  62. private $didThrow = false;
  63. /**
  64. * @var LoggerInterface
  65. */
  66. private $logger;
  67. /**
  68. * @var AwsErrorFactoryInterface
  69. */
  70. private $awsErrorFactory;
  71. /**
  72. * @var ?EndpointCache
  73. */
  74. private $endpointCache;
  75. /**
  76. * @var ?Request
  77. */
  78. private $request;
  79. /**
  80. * @var bool
  81. */
  82. private $debug;
  83. /**
  84. * @var array<string, string>
  85. */
  86. private $exceptionMapping;
  87. public function __construct(ResponseInterface $response, HttpClientInterface $httpClient, LoggerInterface $logger, ?AwsErrorFactoryInterface $awsErrorFactory = null, ?EndpointCache $endpointCache = null, ?Request $request = null, bool $debug = false, array $exceptionMapping = [])
  88. {
  89. $this->httpResponse = $response;
  90. $this->httpClient = $httpClient;
  91. $this->logger = $logger;
  92. $this->awsErrorFactory = $awsErrorFactory ?? new ChainAwsErrorFactory();
  93. $this->endpointCache = $endpointCache;
  94. $this->request = $request;
  95. $this->debug = $debug;
  96. $this->exceptionMapping = $exceptionMapping;
  97. }
  98. public function __destruct()
  99. {
  100. if (null === $this->resolveResult || !$this->didThrow) {
  101. $this->resolve();
  102. }
  103. }
  104. /**
  105. * Make sure the actual request is executed.
  106. *
  107. * @param float|null $timeout Duration in seconds before aborting. When null wait
  108. * until the end of execution. Using 0 means non-blocking
  109. *
  110. * @return bool whether the request is executed or not
  111. *
  112. * @throws NetworkException
  113. * @throws HttpException
  114. */
  115. public function resolve(?float $timeout = null): bool
  116. {
  117. if (null !== $this->resolveResult) {
  118. return $this->getResolveStatus();
  119. }
  120. try {
  121. if (null === $timeout) {
  122. $this->httpResponse->getStatusCode();
  123. } else {
  124. foreach ($this->httpClient->stream($this->httpResponse, $timeout) as $chunk) {
  125. if ($chunk->isTimeout()) {
  126. return false;
  127. }
  128. if ($chunk->isFirst()) {
  129. break;
  130. }
  131. }
  132. }
  133. $this->defineResolveStatus();
  134. } catch (TransportExceptionInterface $e) {
  135. $this->resolveResult = new NetworkException('Could not contact remote server.', 0, $e);
  136. }
  137. if (true === $this->debug) {
  138. $httpStatusCode = $this->httpResponse->getInfo('http_code');
  139. if (0 === $httpStatusCode) {
  140. // Network exception
  141. $this->logger->debug('AsyncAws HTTP request could not be sent due network issues');
  142. } else {
  143. $this->logger->debug('AsyncAws HTTP response received with status code {status_code}', [
  144. 'status_code' => $httpStatusCode,
  145. 'headers' => json_encode($this->httpResponse->getHeaders(false)),
  146. 'body' => $this->httpResponse->getContent(false),
  147. ]);
  148. $this->bodyDownloaded = true;
  149. }
  150. }
  151. return $this->getResolveStatus();
  152. }
  153. /**
  154. * Make sure all provided requests are executed.
  155. *
  156. * @param self[] $responses
  157. * @param float|null $timeout Duration in seconds before aborting. When null wait
  158. * until the end of execution. Using 0 means non-blocking
  159. * @param bool $downloadBody Wait until receiving the entire response body or only the first bytes
  160. *
  161. * @return iterable<self>
  162. *
  163. * @throws NetworkException
  164. * @throws HttpException
  165. */
  166. final public static function wait(iterable $responses, ?float $timeout = null, bool $downloadBody = false): iterable
  167. {
  168. /** @var self[] $responseMap */
  169. $responseMap = [];
  170. $indexMap = [];
  171. $httpResponses = [];
  172. $httpClient = null;
  173. foreach ($responses as $index => $response) {
  174. if (null !== $response->resolveResult && (true !== $response->resolveResult || !$downloadBody || $response->bodyDownloaded)) {
  175. yield $index => $response;
  176. continue;
  177. }
  178. if (null === $httpClient) {
  179. $httpClient = $response->httpClient;
  180. } elseif ($httpClient !== $response->httpClient) {
  181. throw new LogicException('Unable to wait for the given results, they all have to be created with the same HttpClient');
  182. }
  183. $httpResponses[] = $response->httpResponse;
  184. $indexMap[$hash = spl_object_id($response->httpResponse)] = $index;
  185. $responseMap[$hash] = $response;
  186. }
  187. // no response provided (or all responses already resolved)
  188. if (empty($httpResponses)) {
  189. return;
  190. }
  191. if (null === $httpClient) {
  192. throw new InvalidArgument('At least one response should have contain an Http Client');
  193. }
  194. foreach ($httpClient->stream($httpResponses, $timeout) as $httpResponse => $chunk) {
  195. $hash = spl_object_id($httpResponse);
  196. $response = $responseMap[$hash] ?? null;
  197. // Check if null, just in case symfony yield an unexpected response.
  198. if (null === $response) {
  199. continue;
  200. }
  201. // index could be null if already yield
  202. $index = $indexMap[$hash] ?? null;
  203. try {
  204. if ($chunk->isTimeout()) {
  205. // Receiving a timeout mean all responses are inactive.
  206. break;
  207. }
  208. } catch (TransportExceptionInterface $e) {
  209. // Exception is stored as an array, because storing an instance of \Exception will create a circular
  210. // reference and prevent `__destruct` being called.
  211. $response->resolveResult = new NetworkException('Could not contact remote server.', 0, $e);
  212. if (null !== $index) {
  213. unset($indexMap[$hash]);
  214. yield $index => $response;
  215. if (empty($indexMap)) {
  216. // early exit if all statusCode are known. We don't have to wait for all responses
  217. return;
  218. }
  219. }
  220. }
  221. if (!$response->streamStarted && '' !== $chunk->getContent()) {
  222. $response->streamStarted = true;
  223. }
  224. if ($chunk->isLast()) {
  225. $response->bodyDownloaded = true;
  226. if (null !== $index && $downloadBody) {
  227. unset($indexMap[$hash]);
  228. yield $index => $response;
  229. }
  230. }
  231. if ($chunk->isFirst()) {
  232. $response->defineResolveStatus();
  233. if (null !== $index && !$downloadBody) {
  234. unset($indexMap[$hash]);
  235. yield $index => $response;
  236. }
  237. }
  238. if (empty($indexMap)) {
  239. // early exit if all statusCode are known. We don't have to wait for all responses
  240. return;
  241. }
  242. }
  243. }
  244. /**
  245. * Returns info on the current request.
  246. *
  247. * @return array{
  248. * resolved: bool,
  249. * body_downloaded: bool,
  250. * response: \Symfony\Contracts\HttpClient\ResponseInterface,
  251. * status: int,
  252. * }
  253. */
  254. public function info(): array
  255. {
  256. return [
  257. 'resolved' => null !== $this->resolveResult,
  258. 'body_downloaded' => $this->bodyDownloaded,
  259. 'response' => $this->httpResponse,
  260. 'status' => (int) $this->httpResponse->getInfo('http_code'),
  261. ];
  262. }
  263. public function cancel(): void
  264. {
  265. $this->httpResponse->cancel();
  266. $this->resolveResult = false;
  267. }
  268. /**
  269. * @throws NetworkException
  270. * @throws HttpException
  271. */
  272. public function getHeaders(): array
  273. {
  274. $this->resolve();
  275. return $this->httpResponse->getHeaders(false);
  276. }
  277. /**
  278. * @throws NetworkException
  279. * @throws HttpException
  280. */
  281. public function getContent(): string
  282. {
  283. $this->resolve();
  284. try {
  285. return $this->httpResponse->getContent(false);
  286. } finally {
  287. $this->bodyDownloaded = true;
  288. }
  289. }
  290. /**
  291. * @throws NetworkException
  292. * @throws UnparsableResponse
  293. * @throws HttpException
  294. */
  295. public function toArray(): array
  296. {
  297. $this->resolve();
  298. try {
  299. return $this->httpResponse->toArray(false);
  300. } catch (DecodingExceptionInterface $e) {
  301. throw new UnparsableResponse('Could not parse response as array', 0, $e);
  302. } finally {
  303. $this->bodyDownloaded = true;
  304. }
  305. }
  306. public function getStatusCode(): int
  307. {
  308. return $this->httpResponse->getStatusCode();
  309. }
  310. /**
  311. * @throws NetworkException
  312. * @throws HttpException
  313. */
  314. public function toStream(): ResultStream
  315. {
  316. $this->resolve();
  317. if (\is_callable([$this->httpResponse, 'toStream'])) {
  318. return new ResponseBodyResourceStream($this->httpResponse->toStream());
  319. }
  320. if ($this->streamStarted) {
  321. throw new RuntimeException('Can not create a ResultStream because the body started being downloaded. The body was started to be downloaded in Response::wait()');
  322. }
  323. try {
  324. return new ResponseBodyStream($this->httpClient->stream($this->httpResponse));
  325. } finally {
  326. $this->bodyDownloaded = true;
  327. }
  328. }
  329. /**
  330. * In PHP < 7.4, a reference to the arguments is present in the stackTrace of the exception.
  331. * This creates a Circular reference: Response -> resolveResult -> Exception -> stackTrace -> Response.
  332. * This mean, that calling `unset($response)` does not call the `__destruct` method and does not throw the
  333. * remaining exception present in `resolveResult`. The `__destruct` method will be called once the garbage collector
  334. * will detect the loop.
  335. * That's why this method does not creates exception here, but creates closure instead that will be resolved right
  336. * before throwing the exception.
  337. */
  338. private function defineResolveStatus(): void
  339. {
  340. try {
  341. $statusCode = $this->httpResponse->getStatusCode();
  342. } catch (TransportExceptionInterface $e) {
  343. $this->resolveResult = static function () use ($e): NetworkException {
  344. return new NetworkException('Could not contact remote server.', 0, $e);
  345. };
  346. return;
  347. }
  348. if (300 <= $statusCode) {
  349. try {
  350. $awsError = $this->awsErrorFactory->createFromResponse($this->httpResponse);
  351. if ($this->request && $this->endpointCache && (400 === $statusCode || 'InvalidEndpointException' === $awsError->getCode())) {
  352. $this->endpointCache->removeEndpoint($this->request->getEndpoint());
  353. }
  354. } catch (UnparsableResponse $e) {
  355. $awsError = null;
  356. }
  357. if ((null !== $awsCode = ($awsError ? $awsError->getCode() : null)) && isset($this->exceptionMapping[$awsCode])) {
  358. $exceptionClass = $this->exceptionMapping[$awsCode];
  359. } elseif (500 <= $statusCode) {
  360. $exceptionClass = ServerException::class;
  361. } elseif (400 <= $statusCode) {
  362. $exceptionClass = ClientException::class;
  363. } else {
  364. $exceptionClass = RedirectionException::class;
  365. }
  366. $httpResponse = $this->httpResponse;
  367. /** @psalm-suppress MoreSpecificReturnType */
  368. $this->resolveResult = static function () use ($exceptionClass, $httpResponse, $awsError): HttpException {
  369. /** @psalm-suppress LessSpecificReturnStatement */
  370. return new $exceptionClass($httpResponse, $awsError);
  371. };
  372. return;
  373. }
  374. $this->resolveResult = true;
  375. }
  376. private function getResolveStatus(): bool
  377. {
  378. if (\is_bool($this->resolveResult)) {
  379. return $this->resolveResult;
  380. }
  381. if (\is_callable($this->resolveResult)) {
  382. /** @psalm-suppress PropertyTypeCoercion */
  383. $this->resolveResult = ($this->resolveResult)();
  384. }
  385. $code = null;
  386. $message = null;
  387. $context = ['exception' => $this->resolveResult];
  388. if ($this->resolveResult instanceof HttpException) {
  389. /** @var int $code */
  390. $code = $this->httpResponse->getInfo('http_code');
  391. /** @var string $url */
  392. $url = $this->httpResponse->getInfo('url');
  393. $context['aws_code'] = $this->resolveResult->getAwsCode();
  394. $context['aws_message'] = $this->resolveResult->getAwsMessage();
  395. $context['aws_type'] = $this->resolveResult->getAwsType();
  396. $context['aws_detail'] = $this->resolveResult->getAwsDetail();
  397. $message = sprintf('HTTP %d returned for "%s".', $code, $url);
  398. }
  399. if ($this->resolveResult instanceof Exception) {
  400. $this->logger->log(
  401. 404 === $code ? LogLevel::INFO : LogLevel::ERROR,
  402. $message ?? $this->resolveResult->getMessage(),
  403. $context
  404. );
  405. $this->didThrow = true;
  406. throw $this->resolveResult;
  407. }
  408. throw new RuntimeException('Unexpected resolve state');
  409. }
  410. }