Повторная отправка 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 Middleware
{
    /**
     * @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 (серверную ошибку). В противном случае повторный запрос выполнять не нужно, т.к. другие статусы ответа сервера указывают на ошибку в нашем запросе или другие причины.

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

Тестирование 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 ошибки повторный запрос не выполняется, т.к. такая ошибка означает ошибку в самом запросе.

Пример кода из этой статьи, как всегда, можно скачать с GitHub: https://github.com/RusinovIG/blog-examples/pull/1 и запустить локально.

Заключение

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

Оставьте комментарий

Добавить комментарий

Этот сайт использует Akismet для борьбы со спамом. Узнайте как обрабатываются ваши данные комментариев.