RetryableHttpClient.php 5.7 KB

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