RetryableHttpClient.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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. private $strategy;
  31. private $maxRetries;
  32. private $logger;
  33. /**
  34. * @param int $maxRetries The maximum number of times to retry
  35. */
  36. public function __construct(HttpClientInterface $client, RetryStrategyInterface $strategy = null, int $maxRetries = 3, LoggerInterface $logger = null)
  37. {
  38. $this->client = $client;
  39. $this->strategy = $strategy ?? new GenericRetryStrategy();
  40. $this->maxRetries = $maxRetries;
  41. $this->logger = $logger ?? new NullLogger();
  42. }
  43. public function request(string $method, string $url, array $options = []): ResponseInterface
  44. {
  45. if ($this->maxRetries <= 0) {
  46. return new AsyncResponse($this->client, $method, $url, $options);
  47. }
  48. $retryCount = 0;
  49. $content = '';
  50. $firstChunk = null;
  51. return new AsyncResponse($this->client, $method, $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ($method, $url, $options, &$retryCount, &$content, &$firstChunk) {
  52. $exception = null;
  53. try {
  54. if ($context->getInfo('canceled') || $chunk->isTimeout() || null !== $chunk->getInformationalStatus()) {
  55. yield $chunk;
  56. return;
  57. }
  58. } catch (TransportExceptionInterface $exception) {
  59. // catch TransportExceptionInterface to send it to the strategy
  60. }
  61. if (null !== $exception) {
  62. // always retry request that fail to resolve DNS
  63. if ('' !== $context->getInfo('primary_ip')) {
  64. $shouldRetry = $this->strategy->shouldRetry($context, null, $exception);
  65. if (null === $shouldRetry) {
  66. throw new \LogicException(sprintf('The "%s::shouldRetry()" method must not return null when called with an exception.', \get_class($this->strategy)));
  67. }
  68. if (false === $shouldRetry) {
  69. yield from $this->passthru($context, $firstChunk, $content, $chunk);
  70. return;
  71. }
  72. }
  73. } elseif ($chunk->isFirst()) {
  74. if (false === $shouldRetry = $this->strategy->shouldRetry($context, null, null)) {
  75. yield from $this->passthru($context, $firstChunk, $content, $chunk);
  76. return;
  77. }
  78. // Body is needed to decide
  79. if (null === $shouldRetry) {
  80. $firstChunk = $chunk;
  81. $content = '';
  82. return;
  83. }
  84. } else {
  85. if (!$chunk->isLast()) {
  86. $content .= $chunk->getContent();
  87. return;
  88. }
  89. if (null === $shouldRetry = $this->strategy->shouldRetry($context, $content, null)) {
  90. throw new \LogicException(sprintf('The "%s::shouldRetry()" method must not return null when called with a body.', \get_class($this->strategy)));
  91. }
  92. if (false === $shouldRetry) {
  93. yield from $this->passthru($context, $firstChunk, $content, $chunk);
  94. return;
  95. }
  96. }
  97. $context->getResponse()->cancel();
  98. $delay = $this->getDelayFromHeader($context->getHeaders()) ?? $this->strategy->getDelay($context, !$exception && $chunk->isLast() ? $content : null, $exception);
  99. ++$retryCount;
  100. $content = '';
  101. $firstChunk = null;
  102. $this->logger->info('Try #{count} after {delay}ms'.($exception ? ': '.$exception->getMessage() : ', status code: '.$context->getStatusCode()), [
  103. 'count' => $retryCount,
  104. 'delay' => $delay,
  105. ]);
  106. $context->setInfo('retry_count', $retryCount);
  107. $context->replaceRequest($method, $url, $options);
  108. $context->pause($delay / 1000);
  109. if ($retryCount >= $this->maxRetries) {
  110. $context->passthru();
  111. }
  112. });
  113. }
  114. private function getDelayFromHeader(array $headers): ?int
  115. {
  116. if (null !== $after = $headers['retry-after'][0] ?? null) {
  117. if (is_numeric($after)) {
  118. return (int) ($after * 1000);
  119. }
  120. if (false !== $time = strtotime($after)) {
  121. return max(0, $time - time()) * 1000;
  122. }
  123. }
  124. return null;
  125. }
  126. private function passthru(AsyncContext $context, ?ChunkInterface $firstChunk, string &$content, ChunkInterface $lastChunk): \Generator
  127. {
  128. $context->passthru();
  129. if (null !== $firstChunk) {
  130. yield $firstChunk;
  131. }
  132. if ('' !== $content) {
  133. $chunk = $context->createChunk($content);
  134. $content = '';
  135. yield $chunk;
  136. }
  137. yield $lastChunk;
  138. }
  139. }