Если приложение обращается к каким-либо сторонним сервисам через 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, чтобы не заставлять пользователя ждать, пока запрос выполнится полностью на стороне стороннего сервиса.