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

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

Повторное выполнение неудачных вызовов

Возвращаясь к нашей типичной проблеме повторных неудачных вызовов, вы, вероятно, пытались решить ее с помощью простого решения catch-and-retry (перехвати и повтори):

def retry[T](n: Int)(fn: => T): T = {
  try {
    fn
  } catch {
    case e =>
      if (n > 1) retry(n - 1)(fn)
      else throw e
  }
}

Это самая простая реализация ретрая [повторной попытки], которую только можно придумать. Мы выполняем нашу функцию в блоке try-catch и рекурсивно повторяем, когда количество повторных попыток больше 0. Все хорошо и замечательно.

Вы можете немного модифицировать первую версию и помочь компилятору оптимизировать рекурсивные вызовы и сделать рекурсию хвостовой:

@annotation.tailrec
 def retry[T](n: Int)(fn: => T): T = {
  Try { fn } match {
   case Success(x) => x
   case _ if n > 1 => retry(n - 1)(fn)
   case Failure(e) => throw e
  }
 }

Теперь, когда мы вызываем нашу функцию retry, любые исключения, возникающие в Try, будут обработаны блоком match, и либо функция будет выполнена снова, либо мы просто передадим исключение дальше, когда у нас закончатся попытки.

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

Как вы уже, наверное, догадались, существует небольшая, но довольно привлекательная библиотека для этого, она работает с Cats IO. Мне нравится, что она хорошо конфигурируется и проста в использовании.

Встречайте cats-retry!

Проект с открытым исходным кодом и доступен на GitHub. Я постараюсь познакомить вас с основными фичами, которые предоставляет cats-retry, а также с некоторыми полезными советами, которые вы можете использовать в своем следующем или существующем проекте, над которым сейчас работаете.

Цель cats-retryобернуть любые произвольные монадические операции (например, обернутые в cats.effect.IO ). Затем, при выполнении, запустить операцию и потенциально повторить ее с некоторой настраиваемой задержкой и регулируемое количество раз. В конечном итоге вам будет проще работать, например, с сетевыми операциями ввода-вывода, которые часто испытывают периодические проблемы, и незаметно выполнять их повторно, меняя настройку.

Существует 2 основных способа обработки ретраев с помощью cats-retry. Первый работает путем проверки результата выполнения операции, и если это не то, что мы ожидали получить, то можно повторить попытку. Второй подход основан на MonadError с обернутым типом ошибки (обычно это потомок Throwable).

Самый простой способ понять, как работает cats-retry, — это поиграть с так называемыми Combinators, которые он предлагает.

Чтобы легко представить себе, как все это работает, давайте подготовим некоторые вспомогательные функции, которые мы будем использовать в процессе создания наших оберток для ретраев.

Комбинаторам Cats-retry нужна какая-либо информация о том, что должно быть выполнено, что следует вызвать в случае обнаружения ошибки и когда следует повторить попытку.

val policy: RetryPolicy[IO] = RetryPolicies.constantDelay[IO](1.second)

def onFailure(failedValue: Int, details: RetryDetails): IO[Unit] = {
    IO(println(s"Rolled a $failedValue, retrying ... ${details}"))
  }

def onError(err: Throwable, details: RetryDetails): IO[Unit] = {
    IO(println(s"recovering from ${err.getMessage}"))
  }

Функция onFailure может служить примером коллбэк-функции (функции обратного вызова), которая будет вызвана cats-retry, если достигнутый в результате вызова нашей операции результат не соответствует ожиданиям.

Функция onError, в свою очередь, является примером коллбэк-функции, которая вызывается, когда в результате выполнения нашего действия выскакивает исключение, обернутое в инстанс MonadError.

Обе эти функции обычно используются только для логирования.

policy определенная в начале, является лишь простым примером политики повторения вызова нашей операции каждую 1 секунду.

Комбинаторы

retryingOnFailures

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

import cats.effect.{IO, IOApp}
import com.softwaremill.util.LoadedDie
import retry._

import scala.concurrent.duration._

object CatsRetryOnFailures extends IOApp.Simple {

  val loadedDie: LoadedDie = LoadedDie(2, 5, 4, 1, 3, 2, 6)

  def unsafeFunction(): IO[Int] = {
    IO(loadedDie.roll())
  }

  val policy: RetryPolicy[IO] = RetryPolicies.constantDelay[IO](1.second)

  def onFailure(failedValue: Int, details: RetryDetails): IO[Unit] = {
    IO(println(s"Rolled a $failedValue, retrying ... ${details}"))
  }

  def isResultOk(i: Int) = IO {
    if(i == 3) true else false
  }

  val io: IO[Int] = retryingOnFailures(policy, isResultOk, onFailure){
    unsafeFunction()
  }

  override def run: IO[Unit] = {
    io.map(r => println(s"finished with: ${r}"))
  }
}

LoadedDie — это утилитарный класс, который можно найти в исходниках cats-retry, Он просто получает нам следующее значение из списка предоставленных в конструкторе значений, при каждом выполнении roll().

retryingOnFailures принимает 4 аргумента:

  • police — определенная в нашем примере для постоянной задержки в 1 секунду между вызовами,

  •  isResultOk — пример функции для проверки, было ли значение, полученное при вызове нашей основной функции, нормальным или нет, при неверном результате будет выполнен следующий вызов

  • onFailure — функция обратного вызова, выполняемая при возникновении ошибки,

  • unsafeFunction() — наша основная функция, которую мы хотим повторить, если что-то пойдет не так.

Запуск вышеописанной функции даст результат, как показано ниже:

Rolled a 2, retrying ... WillDelayAndRetry(1 second,0,0 days)
Rolled a 5, retrying ... WillDelayAndRetry(1 second,1,1 second)
Rolled a 4, retrying ... WillDelayAndRetry(1 second,2,2 seconds)
Rolled a 1, retrying ... WillDelayAndRetry(1 second,3,3 seconds)
finished with: 3

Process finished with exit code 0

Если мы никогда не возвращаем true из функции isResultOk, то при созданной нами политике (constantDelay) мы никогда не завершим работу и будем повторять попытки вечно.

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

sealed trait RetryDetails {
  def retriesSoFar: Int
  def cumulativeDelay: FiniteDuration
  def givingUp: Boolean
  def upcomingDelay: Option[FiniteDuration]
}

Давайте изменим нашу политику на RetryPolicies.limitRetries(3), чтобы мы никогда не достигли искомого значения 3, так как оно находится на 5 месте в нашем экземпляре LoadedDice. Запуская приложение с такой политикой, мы получим другой результат:

Rolled a 2, retrying ... WillDelayAndRetry(0 days,0,0 days)
Rolled a 5, retrying ... WillDelayAndRetry(0 days,1,0 days)
Rolled a 4, retrying ... WillDelayAndRetry(0 days,2,0 days)
Rolled a 1, retrying ... GivingUp(3,0 days)
finished with: 1

Process finished with exit code 0

retryingOnSomeErrors

Другой способ обработки ретраев — проверить ошибку, которая произошла во время выполнения основной функции, и действовать в соответствии с ней. Комбинатор retryingOnSomeErrors позволяет нам работать с типами MonadError и выполнять повторные попытки только по выбранным ошибкам.

Аналогично retryingOnFailures, мы должны предоставить какие-либо функции обратного вызова, чтобы помочь cats-retry решить, нужно ли дальше совершать повторные попытки или нет. Небольшое отличие состоит в том, что для этого мы будем использовать тип E из MonadError[M, E], который в нашем случае будет обычным Throwable.

object CatsRetryOnSomeErrors extends IOApp.Simple {

  val loadedDie: LoadedDie = LoadedDie(2, 5, 4, 1, 3, 2, 6)

  def unsafeFunction(): IO[Int] = {
    val res = loadedDie.roll()
    if(res != 4) {
      IO.raiseError(new IllegalArgumentException("roll different than 4"))
    } else {
      IO.pure(res)
    }
  }

  val policy: RetryPolicy[IO] = RetryPolicies.constantDelay[IO](1.second)

  def isIOException(e: Throwable): IO[Boolean] = e match {
    case _: IllegalArgumentException => IO.pure(true)
    case _ => IO.pure(false)
  }

  def onError(err: Throwable, details: RetryDetails): IO[Unit] = {
    IO(println(s"recovering from ${err.getMessage}"))
  }

  val io: IO[Int] = retryingOnSomeErrors(isWorthRetrying = isIOException, policy = policy, onError = onError){
    unsafeFunction()
  }

  override def run: IO[Unit] = {
    io.map(r => println(s"finished with: ${r}"))
  }
}

Я также модифицировал unsafeFunction, чтобы инициировать ошибки при IO как с нашей реализацией MonadError.

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

recovering from roll different than 4
recovering from roll different than 4
finished with: 4

Process finished with exit code 0

Число 4 является третьим по счету в нашем экземпляре LoadedDie, поэтому мы видим только 2 лога, когда мы выполнили ретраи нашей unsafeFunction.

retryingOnAllErrors

Это несколько упрощенная версия комбинатора retryingOnSomeErrors, поскольку она не требует от нас функции, решающей, следует ли продолжать повторную попытку или нет. Он просто выполнит повторную попытку для всех ошибок, содержащихся в нашей MonadError.

retryingOnFailuresAndSomeErrors, retryingOnFailuresAndAllErrors

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

Политики

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

Встроенные политики вполне понятны, здесь есть constantDelay и limitRetries, которые мы уже использовали, а также политики с изменяющимся временем задержки между вызовами, такие как exponentialBackoff, fibonacciBackoff или fullJitter.

Вы также можете модифицировать определенные политики, преобразуя их с помощью дополнительных трансформеров, например: capDelay — для установки ограничения на задержку между ретраями.

И последнее, но не менее важное: вы можете объединять политики. Существует несколько различных способов объединения определенных политик с разной логикой работы при различных задержках, определенных в объединенных политиках и т.д. Для получения дополнительной информации об этом я настоятельно рекомендую обратиться к документации по cats-retry.

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

Политики в cats-retry напоминают мне все разнообразие способов приготовления креветок в известном фильме «Форрест Гамп».

Можно приготовить их на барбекю, сварить, запечь... Есть гамбо из креветок, креветки жареные на сковороде, жареные во фритюре...
Можно приготовить их на барбекю, сварить, запечь... Есть гамбо из креветок, креветки жареные на сковороде, жареные во фритюре...

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


В заключение статьи приглашаем всех желающих на открытое занятие «Основы и особенности языка Scala».

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

Занятие будет полезно тем, кто не знаком со Scala и только присматривается к переходу на Scala с других языков. Записывайтесь по ссылке.

Комментарии (0)