Skip to content

Latest commit

 

History

History
159 lines (110 loc) · 17 KB

README_RU.MD

File metadata and controls

159 lines (110 loc) · 17 KB

Retry Helper

Когда вы выполняете какое-то действие, которое может не удаться с первой попытки (например, запрос к удаленному серверу), вам может понадобиться какая-то обработка ошибок, повторные попытки, задержка между попытками и остановка при достижении максимального количества повторных попыток.

Этот простой пакет содержит класс RetryHelper, который упрощает обработку ошибок, выполняет повторные попытки, делает задержки и логирование. Он достаточно гибко настраивается за счет использования callback функций и поддерживает стандартный интерфейс логирования PHP-FIG/PSR-3. Участок потенциально проблемного кода должен быть обернут в анонимную функцию (Closure, callable) и передан методу execute(). Этот метод определяет ошибку только по исключению, брошенному внутри этой анонимной функции. Поэтому, если ваш код не бросает исключение при ошибке (например, если вы используете функцию curl_exec(), которая просто возвращает false при ошибке), то вам нужно обрабатывать возвращаемое значение и бросать исключение внутри этой функции.

Вот простейший пример, который пытается получить ответ от HTTP-сервера, используя пакет GuzzleHttp и до 10 попыток:

$request = new \GuzzleHttp\Psr7\Request("GET", "https://example.com");

$response = (new \gugglegum\RetryHelper\RetryHelper())
    ->execute(function() use ($request) {
        return (new \GuzzleHttp\Client())->send($request);
    }, 10);

echo $response->getBody()->getContents() . "\n";

В этом примере код, который может завершиться ошибкой, обернут в анонимную функцию и передается в качестве первого аргумента метода execute(). Второй аргумент определяет максимальное количество попыток (в данном примере 10). В большинстве случаев данный код выполнится успешно с первой попытки, возвращаемое значение анонимной функции будет перенаправлено в возвращаемое значение метода execute() и сразу же сохранено в переменной $response. Но если у вас нестабильное подключение к Интернету, то для получения ответа может потребоваться несколько попыток. Если же ваш Интернет совсем не работает или сайт лежит, то по достижении максимального количества попыток выполнение будет прекращено. При этом исключение, брошенное внутри анонимной функции при последней попытке, будет проброшено выше.

Поскольку это самый простой пример, в нем используется некоторое поведение по умолчанию, которое мы будем переопределять в следующих примерах. По умолчанию будет повторяться попытка при каждой ошибке (исключении), независимо от типа ошибки. В некоторых случаях это может быть излишним. Например, если у вас "ошибка аутентификации", то нет особого смысла повторять попытку с теми же учетными данными. Как правило, вам не нужно повторять попытку, если вы получаете код состояния HTTP 4xx. Все 400-е статусы означают, что проблема на стороне клиента (неправильный пароль, нет доступа, неправильный URL и т.д.) Таким образом, мы можем разделить все ошибки на "временные" (которые могут исчезнуть при следующей попытке) и "постоянные" (которые не исчезнут). RetryHelper позволяет определить callback функцию, которая будет вызываться после каждой неудачной попытки и решать, является ли эта ошибка (исключение) временной или нет. Если она вернет true (ошибка временная), то будет выполнена новая попытка (за исключением случаев, когда достигнуто максимальное количество попыток). Если она вернёт false, то попытки сразу же прекратятся. Итак, давайте рассмотрим следующий пример, который содержит эту специальную логику:

$request = new \GuzzleHttp\Psr7\Request("GET", "https://example.com");

$response = (new \gugglegum\RetryHelper\RetryHelper())
    ->setIsTemporaryException(function(\Throwable $e): bool {
        return $e instanceof \GuzzleHttp\Exception\ServerException
            || $e instanceof \GuzzleHttp\Exception\ConnectException;
    })
    ->execute(function() use ($request) {
        return (new \GuzzleHttp\Client())->send($request);
    }, 10);

echo $response->getBody()->getContents() . "\n";

По умолчанию все исключения считаются временными. Но в данном примере мы ограничиваем временные исключения только исключениями двух определенных классов: ServerException и ConnectException. Исключения всех остальных классов (например ClientException) будут считаться постоянными и новые попытки будут прекращаться. Таким образом, мы не будем зря долбить удаленный сервер. Внутри этой анонимной функции вы можете проверять класс исключения, его код или парсить сообщение об ошибке.

Для предотвращения перегрузки удаленного сервера RetryHelper делает задержку между попытками. По умолчанию эта задержка случайна и зависит от номера текущей попытки. После первой попытки делается случайная задержка от 0 до 10 секунд (включая дробные значения, например, 5.237 секунд), после второй попытки - от 0 до 20 секунд, после третьей - от 0 до 30 секунд и так далее. Такое поведение является оптимальным в большинстве случаев. Использование дробных секунд позволяет лучше решать конфликты параллельных процессов, которые запускаются через cron почти в одно и то же время. Например, дробная секундная задержка может лучше решить проблему MySQL "Deadlock found when trying to get lock; try restarting transaction". Но если вам нужен собственный механизм задержки, вы можете переопределить стандартную callback функцию, которая возвращает задержку перед следующей попыткой, используя метод setDelayBeforeNextAttempt().

Также может понадобиться специальный обработчик события, когда всё закончилось неуспешно, т.е. когда либо попытки кончились, либо последняя попытка была перманентной (не временной). Задайте его с помощью setOnFailure(), передав туда callback функцию. При вызове она получит объект исключения и номер попытки, т.е. сигнатура функции function(\Throwable $e, int $attempt): void. В этом обработчике вы можете выполнить какое-то действие и/или бросить новое исключение с изменённым сообщением и/или кодом (см. следующий пример).

Наконец, вы можете захотеть вывести в лог или в STDOUT/STDERR поток сообщения обо всех неудачных попытках: какие исключения произошли, количество попыток, длительность задержки. В этом случае RetryHelper поддерживает стандартный интерфейс PHP-FIG/PSR-3 для логирования. Используя метод setLogger() вы можете определить свой собственный логгер (который должен лишь реализовывать \Psr\Log\LoggerInterface) и он будет получать сообщения во время выполнения попыток. Если код выполнится успешно с первой попытки, сообщения в логгер отправляться не будут. По умолчанию логирование не ведётся.

Вот пример, реализующий все вышеупомянутые возможности, плюс try-catch для исключения, которое будет выброшено, если будет достигнуто максимальное количество попыток или если выброшенное исключение не будет считаться временным. Кроме того, этот пример делает исключение для ошибок подключения, связанных с "Could not resolve host". Такая ошибка может возникнуть, если доменное имя не существует. Поэтому в данном примере скрипт прекратит повторные попытки после этой ошибки. Это просто для примера, чтобы продемонстрировать гибкость RetryHelper.

$request = new \GuzzleHttp\Psr7\Request("GET", "https://example.com");

try {
    /** @var \Psr\Http\Message\ResponseInterface $response */
    $response = (new \gugglegum\RetryHelper\RetryHelper())
        ->setIsTemporaryException(function(\Throwable $e): bool {
            return $e instanceof \GuzzleHttp\Exception\ServerException
                || ($e instanceof \GuzzleHttp\Exception\ConnectException && !str_contains($e->getMessage(), 'Could not resolve host'));
        })
        ->setDelayBeforeNextAttempt(function(int $attempt): float|int {
            return $attempt * 5;
        })
        ->setOnFailure(function(\Throwable $e, int $attempt): void {
            throw new RuntimeException($e->getMessage() . " (attempt " . $attempt . ")", $e->getCode(), $e);
        })
        ->setLogger(new class extends \Psr\Log\AbstractLogger {
            public function log($level, string|Stringable $message, array $context = []): void
            {
                echo "[" . strtoupper($level) . "] {$message}\n";
            }
        })
        ->execute(function() use ($request) {
            return (new \GuzzleHttp\Client())->send($request);
        }, 10);

    echo $response->getBody()->getContents() . "\n";

} catch (\Throwable $e) {
    echo "\nExiting due to an error: {$e->getMessage()}\n";
}

Если вам нужен логгер внутри вашей основной функции, вы можете передать его туда через синтаксис use ($logger). Если вы не хотите создавать полноценный класс логгера для однократного использования, вы можете использовать объект анонимного класса:

$logger = new class extends \Psr\Log\AbstractLogger {
    public function log($level, string|Stringable $message, array $context = []): void
    {
        echo "[" . strtoupper($level) . "] {$message}\n";
    }
};
$request = new \GuzzleHttp\Psr7\Request("GET", "https://example.com");
$response = (new \gugglegum\RetryHelper\RetryHelper())
    ->execute(function() use ($request, $logger) {
        $logger->debug("Send GET request");         // <------ Вот здесь мы используем логгер в главной callback функции
        return (new \GuzzleHttp\Client())->send($request);
    }, 10);

Callback функции

Существует 3 вида дополнительных callback функций, которые вы можете передать в RetryHelper. Вот аргументы, которые они получают, и значения, которые они должны возвращать.

Метод setIsTemporaryException()

function(\Throwable $e): bool { ... }

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

Аргументы

  1. $e объект исключения, пойманного на последней попытке

Возвращаемое значение

Возвращает значение типа bool, где true означает, что исключение в $e временное и новые попытки могут решить проблему, false означает, что оно постоянное и повторять попытки не нужно.

Метод setDelayBeforeNextAttempt()

function(int $attempt): float|int { ... }

Функция вызывается после каждой неудачной попытки и определяет задержку в секундах перед следующей попыткой.

Аргументы

  1. $attempt номер последней попытки (начиная с 1)

Возвращаемое значение

Возвращает значение типа float или int с количеством секунд.

Метод setOnFailure()

function(\Throwable $e, int $attempt): void { ... }

Функция вызывается, если все попытки не удались: достигнуто максимальное количество попыток, или получено исключение, которое считается не временным (по функции обратного вызова, определенной в setIsTemporaryException()).

Здесь вы можете вывести какой-то текст, закрыть сетевое соединение. Также вы можете здесь бросить какое-то специфичное исключение, но даже если нет, то изначальное исключение, пойманное внутри метода execute(), будет автоматически брошено повторно.

Аргументы

  1. $e объект исключения, пойманного на последней попытке
  2. $attempt номер последней попытки (начиная с 1)

Нет возвращаемого значения.

Установка

Предельно простая, как и со всеми другими Composer пакетами:

composer require gugglegum/retry-helper