RetryableHttpClient.php 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\HttpClient;
  11. use Psr\Log\LoggerInterface;
  12. use Psr\Log\NullLogger;
  13. use Symfony\Component\HttpClient\Response\AsyncContext;
  14. use Symfony\Component\HttpClient\Response\AsyncResponse;
  15. use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
  16. use Symfony\Component\HttpClient\Retry\RetryStrategyInterface;
  17. use Symfony\Contracts\HttpClient\ChunkInterface;
  18. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  19. use Symfony\Contracts\HttpClient\HttpClientInterface;
  20. use Symfony\Contracts\HttpClient\ResponseInterface;
  21. use Symfony\Contracts\Service\ResetInterface;
  22. /**
  23. * Automatically retries failing HTTP requests.
  24. *
  25. * @author Jérémy Derussé <jeremy@derusse.com>
  26. */
  27. class RetryableHttpClient implements HttpClientInterface, ResetInterface
  28. {
  29. use AsyncDecoratorTrait;
  30. /**
  31. * @var \Symfony\Component\HttpClient\Retry\RetryStrategyInterface
  32. */
  33. private $strategy;
  34. /**
  35. * @var int
  36. */
  37. private $maxRetries;
  38. /**
  39. * @var \Psr\Log\LoggerInterface
  40. */
  41. private $logger;
  42. /**
  43. * @var mixed[]
  44. */
  45. private $baseUris = [];
  46. /**
  47. * @param int $maxRetries The maximum number of times to retry
  48. * @param \Symfony\Contracts\HttpClient\HttpClientInterface $client
  49. * @param \Symfony\Component\HttpClient\Retry\RetryStrategyInterface|null $strategy
  50. * @param \Psr\Log\LoggerInterface|null $logger
  51. */
  52. public function __construct($client, $strategy = null, $maxRetries = 3, $logger = null)
  53. {
  54. $this->client = $client;
  55. $this->strategy = $strategy ?? new GenericRetryStrategy();
  56. $this->maxRetries = $maxRetries;
  57. $this->logger = $logger ?? new NullLogger();
  58. }
  59. /**
  60. * @return $this
  61. * @param mixed[] $options
  62. */
  63. public function withOptions($options)
  64. {
  65. if (\array_key_exists('base_uri', $options)) {
  66. if (\is_array($options['base_uri'])) {
  67. $this->baseUris = $options['base_uri'];
  68. unset($options['base_uri']);
  69. } else {
  70. $this->baseUris = [];
  71. }
  72. }
  73. $clone = clone $this;
  74. $clone->client = $this->client->withOptions($options);
  75. return $clone;
  76. }
  77. /**
  78. * @param string $method
  79. * @param string $url
  80. * @param mixed[] $options
  81. */
  82. public function request($method, $url, $options = [])
  83. {
  84. $baseUris = \array_key_exists('base_uri', $options) ? $options['base_uri'] : $this->baseUris;
  85. $baseUris = \is_array($baseUris) ? $baseUris : [];
  86. $options = self::shiftBaseUri($options, $baseUris);
  87. if ($this->maxRetries <= 0) {
  88. return new AsyncResponse($this->client, $method, $url, $options);
  89. }
  90. return new AsyncResponse($this->client, $method, $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ($method, $url, $options, &$baseUris) {
  91. static $retryCount = 0;
  92. static $content = '';
  93. static $firstChunk;
  94. $exception = null;
  95. try {
  96. if ($context->getInfo('canceled') || $chunk->isTimeout() || null !== $chunk->getInformationalStatus()) {
  97. yield $chunk;
  98. return;
  99. }
  100. } catch (TransportExceptionInterface $exception) {
  101. // catch TransportExceptionInterface to send it to the strategy
  102. }
  103. if (null !== $exception) {
  104. // always retry request that fail to resolve DNS
  105. if ('' !== $context->getInfo('primary_ip')) {
  106. $shouldRetry = $this->strategy->shouldRetry($context, null, $exception);
  107. if (null === $shouldRetry) {
  108. throw new \LogicException(sprintf('The "%s::shouldRetry()" method must not return null when called with an exception.', get_class($this->strategy)));
  109. }
  110. if (false === $shouldRetry) {
  111. yield from $this->passthru($context, $firstChunk, $content, $chunk);
  112. return;
  113. }
  114. }
  115. } elseif ($chunk->isFirst()) {
  116. if (false === $shouldRetry = $this->strategy->shouldRetry($context, null, null)) {
  117. yield from $this->passthru($context, $firstChunk, $content, $chunk);
  118. return;
  119. }
  120. // Body is needed to decide
  121. if (null === $shouldRetry) {
  122. $firstChunk = $chunk;
  123. $content = '';
  124. return;
  125. }
  126. } else {
  127. if (!$chunk->isLast()) {
  128. $content .= $chunk->getContent();
  129. return;
  130. }
  131. if (null === $shouldRetry = $this->strategy->shouldRetry($context, $content, null)) {
  132. throw new \LogicException(sprintf('The "%s::shouldRetry()" method must not return null when called with a body.', get_class($this->strategy)));
  133. }
  134. if (false === $shouldRetry) {
  135. yield from $this->passthru($context, $firstChunk, $content, $chunk);
  136. return;
  137. }
  138. }
  139. $context->getResponse()->cancel();
  140. $delay = $this->getDelayFromHeader($context->getHeaders()) ?? $this->strategy->getDelay($context, !$exception && $chunk->isLast() ? $content : null, $exception);
  141. ++$retryCount;
  142. $content = '';
  143. $firstChunk = null;
  144. $this->logger->info('Try #{count} after {delay}ms'.($exception ? ': '.$exception->getMessage() : ', status code: '.$context->getStatusCode()), [
  145. 'count' => $retryCount,
  146. 'delay' => $delay,
  147. ]);
  148. $context->setInfo('retry_count', $retryCount);
  149. $context->replaceRequest($method, $url, self::shiftBaseUri($options, $baseUris));
  150. $context->pause($delay / 1000);
  151. if ($retryCount >= $this->maxRetries) {
  152. $context->passthru();
  153. }
  154. });
  155. }
  156. /**
  157. * @param mixed[] $headers
  158. */
  159. private function getDelayFromHeader($headers)
  160. {
  161. if (null !== $after = $headers['retry-after'][0] ?? null) {
  162. if (is_numeric($after)) {
  163. return (int) ($after * 1000);
  164. }
  165. if (false !== $time = strtotime($after)) {
  166. return max(0, $time - time()) * 1000;
  167. }
  168. }
  169. return null;
  170. }
  171. /**
  172. * @param \Symfony\Component\HttpClient\Response\AsyncContext $context
  173. * @param \Symfony\Contracts\HttpClient\ChunkInterface|null $firstChunk
  174. * @param string $content
  175. * @param \Symfony\Contracts\HttpClient\ChunkInterface $lastChunk
  176. */
  177. private function passthru($context, $firstChunk, &$content, $lastChunk)
  178. {
  179. $context->passthru();
  180. if (null !== $firstChunk) {
  181. yield $firstChunk;
  182. }
  183. if ('' !== $content) {
  184. $chunk = $context->createChunk($content);
  185. $content = '';
  186. yield $chunk;
  187. }
  188. yield $lastChunk;
  189. }
  190. /**
  191. * @param mixed[] $options
  192. * @param mixed[] $baseUris
  193. */
  194. private static function shiftBaseUri($options, &$baseUris)
  195. {
  196. if ($baseUris) {
  197. $baseUri = 1 < \count($baseUris) ? array_shift($baseUris) : current($baseUris);
  198. $options['base_uri'] = \is_array($baseUri) ? $baseUri[array_rand($baseUri)] : $baseUri;
  199. }
  200. return $options;
  201. }
  202. }