Повторная отправка HTTP-запросов в Guzzle с помощью RetryMiddleware

Если приложение обращается к каким-либо сторонним сервисам через API, часто встречается ситуация, что сторонний сервис временно недоступен, или перегружен. Т.к. от результатов запроса может зависеть дальнейшее выполнение скрипта, требуется предусмотреть такую ситуацию, и временный отказ внешнего сервиса не должен вызывать серьезных сбоев в работе приложения.

Одним из решений будет повторная отправка http-запроса через заданный интервал времени, если запрос не был выполнен. Это можно реализовывать вручную при каждом обращении к стороннему сервису, но гораздо удобнее иметь готовый инструмент, который инициализируется одной строкой кода, когда это потребуется. О таком инструменте и пойдет речь в этой статье.

Для отправки HTTP-запросов в PHP имеется очень мощная библиотека Guzzle. В том числе она имеет функцию повторной отправки запросов по заданным правилам.

Установка Guzzle

Наиболее простой способ установки Guzzle — с помощью Composer. Для этого выполним следующую команду:

composer require guzzlehttp/guzzle

Реализация RetryMiddleware

Guzzle имеет встроенный класс RetryMiddleware, для инициализации которого требуется 2 параметра:

  • $decider — функция, принимающая на вход количество попыток отправки запроса, объект запроса, объект ответа, и исключение, если оно было получено во время попытки запроса, и возвращает boolean значение, нужно ли отправить запрос еще раз.
  • $delay — функция, принимающая на вход количество попыток отправки запроса и возвращающая количество миллисекунд задержки перед запросом.

Рассмотрим пример таких функций. Создадим вспомогательный класс, который будет возвращать объект RetryMiddleware с заданными параметрами:

<?php
namespace App;
use function foo\func;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class GuzzleMiddleware
{
    /**
     * @param $numberOfRetries
     * @return callable
     */
    public static function retryRequest($numberOfRetries)
    {
        return \GuzzleHttp\Middleware::retry(
            self::retryDecider($numberOfRetries),
            self::exponentialDelay()
        );
    }
    /**
     * @param $numberOfRetries
     * @return \Closure
     */
    public static function retryDecider($numberOfRetries)
    {
        return function (
            $retries,
            RequestInterface $request,
            ResponseInterface $response = null,
            RequestException $exception = null
        ) use ($numberOfRetries) {
            if ($retries >= $numberOfRetries) {
                return false;
            }
            // Retry connection exceptions
            if ($exception instanceof ConnectException) {
                return true;
            }
            if ($response) {
                // Retry on server errors
                if ($response->getStatusCode() >= 500) {
                    return true;
                }
            }
            return false;
        };
    }
    /**
     * @return \Closure
     */
    public static function exponentialDelay()
    {
        return function ($retries) {
            return (int) pow(2, $retries - 1);
        };
    }
}

Для проверки, нужно ли выполнить повторную отправку запроса, мы проверяем, не превышено ли максимальное количество попыток, не возникло ли ошибки соединения (например соединение было разорвано по таймауту), и проверяем, не вернул ли сервер ошибку с кодом >= 500 (серверную ошибку). В противном случае повторный запрос выполнять не нужно, т.к. другие статусы ответа сервера указывают на ошибку в нашем запросе или другие причины.

Для функции задержки я привел пример реализации по умолчанию, которая представляет собой экспоненциальное увеличение задержки перед запросом с каждой новой попыткой.

Чтобы создать клиент Guzzle, использующий наш кастомный middleware, нужно передать его в конструктор при инициализации:

$handlerStack = \GuzzleHttp\HandlerStack::create();
$retryMiddleware = GuzzleMiddleware::retryRequest(2);
$handlerStack->push($retryMiddleware);
$client = new \GuzzleHttp\Client(['handler' => $handlerStack]);
$response = $client->get('http://test.com');

Тестирование middleware

Для проверки функциональности, напишем unit-тесты для данного класса. Guzzle имеет возможность имитации ответов сервера, что удобно при тестировании. Ниже представлен код, проверяющий функцию переотправки запроса:

<?php
namespace Tests\Unit;
use App\GuzzleMiddleware;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Tests\TestCase;
class GuzzleMiddlewareTest extends TestCase
{
   public function testRetryRequestCatchesServerError() {
        $middleware = GuzzleMiddleware::retryRequest(2);
        $handler = new MockHandler([
            new Response(501),
            new Response(502),
            new Response(200)
        ]);
        $client = new Client(['handler' => $middleware($handler)]);
        $response = $client->get('http://test.com');
        $this->assertEquals(200, $response->getStatusCode());
    }
    public function testRetryRequestCatchesConnectionError()
    {
        $middleware = GuzzleMiddleware::retryRequest(1);
        $request = new Request('GET', 'http://test.com');
        $connectException = new ConnectException(
            'Connection failed',
            $request
        );
        $handler = new MockHandler([
            $connectException,
            new Response(200)
        ]);
        $client = new Client(['handler' => $middleware($handler)]);
        $response = $client->send($request, []);
        $this->assertEquals(200, $response->getStatusCode());
    }
    public function testRetryRequestNotCatchesClientError()
    {
        $middleware = GuzzleMiddleware::retryRequest(1);
        $handler = new MockHandler([new Response(400), new Response(202)]);
        $client = new Client(['handler' => $middleware($handler)]);
        $response = $client->get('http://test.com');
        $this->assertEquals(400, $response->getStatusCode());
    }
}

Разберем, что проверяют эти тесты:

  • testRetryRequestCatchesServerError проверяет, что при получении 5xx ошибки, запрос выполняется повторно.
  • testRetryRequestCatchesConnectionError проверяет, что при ошибке подключения к серверу, так же повторно выполняется запрос.
  • testRetryRequestNotCatchesClientError проверяет, что при получении 4xx ошибки повторный запрос не выполняется, т.к. такая ошибка означает ошибку в самом запросе.
  • Заключение

    Повторная отправка запросов помогает сделать ваше приложение более устойчивым к неполадкам в сторонних сервисах. Конечно, желательно, чтобы сценарии, которые используют внешние API можно было перезапустить полностью, т.к. неполадки в них могут длиться неопределенное время, а их отказ не приводил к бесконтрольному завершению программы и потере каких либо данных.  Поэтому, если приложению требуется отправлять какие-либо данные в сторонний сервис, желательно сначала сохранять и в локальное хранилище, и только затем, фоновым процессом или с помощью сервиса очередей, отправлять в API, чтобы не заставлять пользователя ждать, пока запрос выполнится полностью на стороне стороннего сервиса.