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