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