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 = "4.0.0"
libraryDependencies += "com.github.cb372" %% "cats-retry-mtl" % catsRetryVersion

Interaction with cats-retry core combinators

MTL retry works independently from retry.retryingOnErrors. The retry.mtl.retryingOnErrors combinator evaluates retry exclusively on errors produced by Handle. Thus errors produced in the effect monad’s error channel are not taken into account and retry is not triggered.

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

action
  .retryingOnErrors(policy, exceptionHandler)
  .retryingOnMtlErrors[AppError](policy, mtlErrorHandler)

retryingOnErrors

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

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

The API looks like this:

def retryingOnErrors[F[_]: Temporal, A, E: Handle[F, *]](
    action: F[A]
)(
    policy: RetryPolicy[F],
    errorHandler: ResultHandler[F, E, A]
): F[A]

The inputs are:

  • the operation that you want to wrap with retries
  • a retry policy, which determines the maximum number of retries and how long to delay after each attempt
  • an error that decides whether a given error is worth retrying, and does any necessary logging

Example:

import retry.{HandlerDecision, ResultHandler, RetryDetails, RetryPolicies}
import cats.data.EitherT
import cats.effect.{Sync, IO}
import cats.mtl.Handle
import cats.syntax.all.*
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[_]: [M[_]] =>> Handle[M, 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 effect = retry.mtl.retryingOnErrors(failingOperation[Effect])(
  policy = RetryPolicies.limitRetries[Effect](2),
  errorHandler = (error: AppError, details: RetryDetails) =>
    logError[Effect](error, details).as(
      if error.reason.contains("Boom!") then HandlerDecision.Continue else
HandlerDecision.Stop
    )
)
effect
  .value
  .unsafeRunTimed(1.second)
// Raised error AppError(Boom!). Details RetryDetails(0,0 days,DelayAndRetry(0 days))
// Raised error AppError(Boom!). Details RetryDetails(1,0 days,DelayAndRetry(0 days))
// Raised error AppError(Boom!). Details RetryDetails(2,0 days,GiveUp)
// res1: Option[Either[AppError, Unit]] = Some(
//   value = Left(value = AppError(reason = "Boom!"))
// )

Syntactic sugar

The cats-retry-mtl API is also available as an extension method.

You need to opt into this using an import:

import retry.mtl.syntax.*

Here’s an example showing how extension methods from both the core module and the MTL module can be used in combination:

import retry.*
import cats.data.EitherT
import cats.effect.{Async, LiftIO, IO}
import cats.syntax.all.*
import cats.mtl.Handle
import retry.mtl.syntax.*
import retry.syntax.*
import scala.concurrent.duration.*
import cats.effect.unsafe.implicits.global

case class AppError(reason: String)

class Service[F[_]](client: util.FlakyHttpClient)(implicit F: Async[F], L: LiftIO[F], AH: Handle[F, AppError]) {

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

  // evaluates retry on errors produced by MonadError and Handle
  def findCoolCatGifRetryAll(policy: RetryPolicy[F, Any]): F[String] =
    findCoolCatGif
      .retryingOnErrors(policy, logAndRetryOnAllErrors)
      .retryingOnMtlErrors[AppError](policy, logAndRetryOnAllMtlErrors)

  private def findCoolCatGif: F[String] =
    for {
      gif <- L.liftIO(client.getCatGif)
      _ <- isCoolGif(gif)
    } yield gif

  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"))

  private val logAndRetryOnAllErrors: ErrorHandler[F, String] =
    (error: Throwable, details: RetryDetails) =>
      logError(error, details).as(HandlerDecision.Continue)

  private val logAndRetryOnAllMtlErrors: ResultHandler[F, AppError, String] =
    (error: AppError, details: RetryDetails) =>
      logMtlError(error, details).as(HandlerDecision.Continue)
      
}

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

val policy = RetryPolicies.limitRetries[Effect](5)

val service = new Service[Effect](util.FlakyHttpClient())

Retrying only on MTL errors:

service.findCoolCatGifRetryMtl(policy).value.attempt.unsafeRunTimed(1.second)
// res3: Option[Either[Throwable, Either[AppError, String]]] = Some(
//   value = Left(value = java.io.IOException: Failed to download)
// )

Retrying on both exceptions and MTL errors:

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