MTL Combinators

The cats-retry-mtl module provides two additional retry methods that operating with errors produced by Handle from cats-mtl.

Installation

To use cats-retry-mtl, add the following dependency to your build.sbt:

val catsRetryVersion = "3.1.0"
libraryDependencies += "com.github.cb372" %% "cats-retry-mtl" % catsRetryVersion

Interaction with MonadError retry

MTL retry works independently from retry.retryingOnSomeErrors. The operations retry.mtl.retryingOnAllErrors and retry.mtl.retryingOnSomeErrors evaluating retry exclusively on errors produced by Handle. Thus errors produced by MonadError are not being taken into account and retry is not triggered.

If you want to retry in case of any error, you can chain the methods:

fa
  .retryingOnAllErrors(policy, onError = retry.noop[F, Throwable])
  .retryingOnAllMtlErrors[AppError](policy, onError = retry.noop[F, AppError])

retryingOnSomeErrors

This is useful when you are working with an Handle[M, E] but you only want to retry on some errors.

To use retryingOnSomeErrors, you need to pass in a predicate that decides whether a given error is worth retrying.

The API (modulo some type-inference trickery) looks like this:

def retryingOnSomeErrors[M[_]: Monad: Sleep, A, E: Handle[M, *]](
  policy: RetryPolicy[M],
  isWorthRetrying: E => M[Boolean],
  onError: (E, RetryDetails) => M[Unit]
)(action: => M[A]): M[A]

You need to pass in:

  • a retry policy
  • a predicate that decides whether a given error is worth retrying
  • an error handler, often used for logging
  • the operation that you want to wrap with retries

Example:

import retry.{RetryDetails, RetryPolicies}
import cats.data.EitherT
import cats.effect.{Sync, IO}
import cats.mtl.Handle
import scala.concurrent.duration._
import cats.effect.unsafe.implicits.global

type Effect[A] = EitherT[IO, AppError, A]

case class AppError(reason: String)

def failingOperation[F[_]: Handle[*[_], AppError]]: F[Unit] =
  Handle[F, AppError].raise(AppError("Boom!"))

def isWorthRetrying(error: AppError): Effect[Boolean] =
  EitherT.pure(error.reason.contains("Boom!"))

def logError[F[_]: Sync](error: AppError, details: RetryDetails): F[Unit] =
  Sync[F].delay(println(s"Raised error $error. Details $details"))

val policy = RetryPolicies.limitRetries[Effect](2)
// policy: retry.RetryPolicy[Effect] = RetryPolicy(
//   decideNextRetry = retry.RetryPolicy$$$Lambda$11987/1347236281@2f6e8ee8
// )

retry.mtl
  .retryingOnSomeErrors(policy, isWorthRetrying, logError[Effect])(failingOperation[Effect])
  .value
  .unsafeRunTimed(1.second)
// Raised error AppError(Boom!). Details WillDelayAndRetry(0 days,0,0 days)
// Raised error AppError(Boom!). Details WillDelayAndRetry(0 days,1,0 days)
// Raised error AppError(Boom!). Details GivingUp(2,0 days)
// res1: Option[Either[AppError, Unit]] = Some(
//   value = Left(value = AppError(reason = "Boom!"))
// )

retryingOnAllErrors

This is useful when you are working with a Handle[M, E] and you want to retry on all errors.

The API (modulo some type-inference trickery) looks like this:

def retryingOnSomeErrors[M[_]: Monad: Sleep, A, E: Handle[M, *]](
  policy: RetryPolicy[M],
  onError: (E, RetryDetails) => M[Unit]
)(action: => M[A]): M[A]

You need to pass in:

  • a retry policy
  • an error handler, often used for logging
  • the operation that you want to wrap with retries

Example:

import retry.{RetryDetails, RetryPolicies}
import cats.data.EitherT
import cats.effect.{Sync, IO}
import cats.mtl.Handle
import scala.concurrent.duration._
import cats.effect.unsafe.implicits.global

type Effect[A] = EitherT[IO, AppError, A]

case class AppError(reason: String)

def failingOperation[F[_]: Handle[*[_], AppError]]: F[Unit] =
  Handle[F, AppError].raise(AppError("Boom!"))

def logError[F[_]: Sync](error: AppError, details: RetryDetails): F[Unit] =
  Sync[F].delay(println(s"Raised error $error. Details $details"))

val policy = RetryPolicies.limitRetries[Effect](2)
// policy: retry.RetryPolicy[Effect] = RetryPolicy(
//   decideNextRetry = retry.RetryPolicy$$$Lambda$11987/1347236281@44272bd6
// )

retry.mtl
  .retryingOnAllErrors(policy, logError[Effect])(failingOperation[Effect])
  .value
  .unsafeRunTimed(1.second)
// Raised error AppError(Boom!). Details WillDelayAndRetry(0 days,0,0 days)
// Raised error AppError(Boom!). Details WillDelayAndRetry(0 days,1,0 days)
// Raised error AppError(Boom!). Details GivingUp(2,0 days)
// res3: Option[Either[AppError, Unit]] = Some(
//   value = Left(value = AppError(reason = "Boom!"))
// )

Syntactic sugar

Cats-retry-mtl include some syntactic sugar in order to reduce boilerplate.

import retry._
import cats.data.EitherT
import cats.effect.{Sync, IO}
import cats.syntax.functor._
import cats.syntax.flatMap._
import cats.mtl.Handle
import retry.mtl.syntax.all._
import retry.syntax.all._
import scala.concurrent.duration._
import cats.effect.unsafe.implicits.global

case class AppError(reason: String)

class Service[F[_]: Sleep](client: util.FlakyHttpClient)(implicit F: Sync[F], AH: Handle[F, AppError]) {

  // evaluates retry exclusively on errors produced by Handle.
  def findCoolCatGifRetryMtl(policy: RetryPolicy[F]): F[String] =
    findCoolCatGif.retryingOnAllMtlErrors[AppError](policy, logMtlError)

  // evaluates retry on errors produced by MonadError and Handle
  def findCoolCatGifRetryAll(policy: RetryPolicy[F]): F[String] =
    findCoolCatGif
      .retryingOnAllErrors(policy, logError)
      .retryingOnAllMtlErrors[AppError](policy, logMtlError)

  private def findCoolCatGif: F[String] =
    for {
      gif <- findCatGif
      _ <- isCoolGif(gif)
    } yield gif

  private def findCatGif: F[String] =
    F.delay(client.getCatGif())

  private def isCoolGif(string: String): F[Unit] =
    if (string.contains("cool")) F.unit
    else AH.raise(AppError("Gif is not cool"))

  private def logError(error: Throwable, details: RetryDetails): F[Unit] =
    F.delay(println(s"Raised error $error. Details $details"))

  private def logMtlError(error: AppError, details: RetryDetails): F[Unit] =
    F.delay(println(s"Raised MTL error $error. Details $details"))
}

type Effect[A] = EitherT[IO, AppError, A]

val policy = RetryPolicies.limitRetries[Effect](5)
// policy: RetryPolicy[Effect] = RetryPolicy(
//   decideNextRetry = retry.RetryPolicy$$$Lambda$11987/1347236281@dbb3162
// )

val service = new Service[Effect](util.FlakyHttpClient())
// service: Service[Effect] = repl.MdocSession$App4$Service@6c54de73

service.findCoolCatGifRetryMtl(policy).value.attempt.unsafeRunTimed(1.second)
// res5: Option[Either[Throwable, Either[AppError, String]]] = Some(
//   value = Left(value = java.io.IOException: Failed to download)
// )
service.findCoolCatGifRetryAll(policy).value.attempt.unsafeRunTimed(1.second)
// Raised error java.io.IOException: Failed to download. Details WillDelayAndRetry(0 days,0,0 days)
// Raised error java.io.IOException: Failed to download. Details WillDelayAndRetry(0 days,1,0 days)
// Raised error java.io.IOException: Failed to download. Details WillDelayAndRetry(0 days,2,0 days)
// Raised MTL error AppError(Gif is not cool). Details WillDelayAndRetry(0 days,0,0 days)
// Raised MTL error AppError(Gif is not cool). Details WillDelayAndRetry(0 days,1,0 days)
// Raised MTL error AppError(Gif is not cool). Details WillDelayAndRetry(0 days,2,0 days)
// Raised MTL error AppError(Gif is not cool). Details WillDelayAndRetry(0 days,3,0 days)
// Raised MTL error AppError(Gif is not cool). Details WillDelayAndRetry(0 days,4,0 days)
// Raised MTL error AppError(Gif is not cool). Details GivingUp(5,0 days)
// res6: Option[Either[Throwable, Either[AppError, String]]] = Some(
//   value = Right(value = Left(value = AppError(reason = "Gif is not cool")))
// )