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