Skip to content

Commit

Permalink
Merge pull request #151 from iRevive/mtl-module
Browse files Browse the repository at this point in the history
Implement retry syntax with ApplicativeHandle
  • Loading branch information
cb372 authored Jan 14, 2020
2 parents 61b26d0 + 0e9fdf8 commit 21a9764
Show file tree
Hide file tree
Showing 11 changed files with 712 additions and 5 deletions.
21 changes: 20 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ val moduleSettings = commonSettings ++ Seq(

val catsVersion = "2.0.0"
val catsEffectVersion = "2.0.0"
val catsMtlVersion = "0.7.0"
val scalatestVersion = "3.1.0"
val scalaTestPlusVersion = "3.1.0.0-RC2"
val scalacheckVersion = "1.14.3"
Expand Down Expand Up @@ -105,9 +106,25 @@ val alleycatsRetry = crossProject(JVMPlatform, JSPlatform)
val alleycatsJVM = alleycatsRetry.jvm
val alleycatsJS = alleycatsRetry.js

val mtlRetry = crossProject(JVMPlatform, JSPlatform)
.in(file("modules/mtl"))
.jvmConfigure(_.dependsOn(coreJVM))
.jsConfigure(_.dependsOn(coreJS))
.settings(moduleSettings)
.settings(
name := "cats-retry-mtl",
crossScalaVersions := scalaVersions,
libraryDependencies ++= Seq(
"org.typelevel" %%% "cats-mtl-core" % catsMtlVersion,
"org.scalatest" %%% "scalatest" % scalatestVersion % Test
)
)
val mtlJVM = mtlRetry.jvm
val mtlJS = mtlRetry.js

val docs = project
.in(file("modules/docs"))
.dependsOn(coreJVM, alleycatsJVM)
.dependsOn(coreJVM, alleycatsJVM, mtlJVM)
.enablePlugins(MicrositesPlugin, BuildInfoPlugin)
.settings(moduleSettings)
.settings(
Expand Down Expand Up @@ -146,6 +163,8 @@ val root = project
coreJS,
alleycatsJVM,
alleycatsJS,
mtlJVM,
mtlJS,
docs
)
.settings(commonSettings)
Expand Down
8 changes: 4 additions & 4 deletions modules/core/shared/src/main/scala/retry/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ package object retry {
def noop[M[_]: Monad, A]: (A, RetryDetails) => M[Unit] =
(_, _) => Monad[M].pure(())

private def applyPolicy[M[_]: Monad](
private[retry] def applyPolicy[M[_]: Monad](
policy: RetryPolicy[M],
retryStatus: RetryStatus
): M[NextStep] =
Expand All @@ -135,7 +135,7 @@ package object retry {
NextStep.GiveUp
}

private def buildRetryDetails(
private[retry] def buildRetryDetails(
currentStatus: RetryStatus,
nextStep: NextStep
): RetryDetails =
Expand All @@ -153,9 +153,9 @@ package object retry {
)
}

private sealed trait NextStep
private[retry] sealed trait NextStep

private object NextStep {
private[retry] object NextStep {
case object GiveUp extends NextStep

final case class RetryAfterDelay(
Expand Down
1 change: 1 addition & 0 deletions modules/docs/src/main/mdoc/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,6 @@ logMessages.foreach(println)
Next steps:

* Learn about the other available [combinators](combinators.html)
* Learn about the [MTL combinators](mtl-combinators.html)
* Learn more about [retry policies](policies.html)
* Learn about the [`Sleep` type class](sleep.html)
205 changes: 205 additions & 0 deletions modules/docs/src/main/mdoc/docs/mtl-combinators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
---
layout: docs
title: MTL Combinators
---

# MTL Combinators

The `cats-retry-mtl` module provides two additional retry methods that operating with errors produced
by `ApplicativeHandle` from [cats-mtl](https://github.com/typelevel/cats-mtl).

## Installation

To use `cats-retry-mtl`, add the following dependency to your `build.sbt`:
```scala mdoc:passthrough
println(
s"""
|```
|val catsRetryVersion = "${retry.BuildInfo.version.replaceFirst("\\+.*", "")}"
|libraryDependencies += "com.github.cb372" %% "cats-retry-mtl" % catsRetryVersion
|```
|""".stripMargin.trim
)
```

## 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 `ApplicativeHandle`.
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:
```scala
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 `ApplicativeHandle[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:

```scala
def retryingOnSomeErrors[M[_]: Monad, A, E: ApplicativeHandle[M, *]](
policy: RetryPolicy[M],
isWorthRetrying: E => 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:
```scala mdoc
import retry.{RetryDetails, RetryPolicies}
import cats.data.EitherT
import cats.effect.{Sync, IO, Timer}
import cats.mtl.ApplicativeHandle
import cats.mtl.instances.handle._
import scala.concurrent.duration._

// We need an implicit cats.effect.Timer
implicit val timer: Timer[IO] = IO.timer(scala.concurrent.ExecutionContext.global)

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

case class AppError(reason: String)

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

def isWorthRetrying(error: AppError): Boolean =
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)

retry.mtl
.retryingOnSomeErrors(policy, isWorthRetrying, logError[Effect])(failingOperation[Effect])
.value
.unsafeRunTimed(1.second)
```

## `retryingOnAllErrors`

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

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

```scala
def retryingOnSomeErrors[M[_]: Monad, A, E: ApplicativeHandle[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:
```scala mdoc:reset
import retry.{RetryDetails, RetryPolicies}
import cats.data.EitherT
import cats.effect.{Sync, IO, Timer}
import cats.mtl.ApplicativeHandle
import cats.mtl.instances.handle._
import scala.concurrent.duration._

// We need an implicit cats.effect.Timer
implicit val timer: Timer[IO] = IO.timer(scala.concurrent.ExecutionContext.global)

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

case class AppError(reason: String)

def failingOperation[F[_]: ApplicativeHandle[*[_], AppError]]: F[Unit] =
ApplicativeHandle[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)

retry.mtl
.retryingOnAllErrors(policy, logError[Effect])(failingOperation[Effect])
.value
.unsafeRunTimed(1.second)
```

## Syntactic sugar

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

```scala mdoc:reset
import retry._
import cats.data.EitherT
import cats.effect.{Sync, IO, Timer}
import cats.syntax.functor._
import cats.syntax.flatMap._
import cats.mtl.ApplicativeHandle
import cats.mtl.instances.handle._
import retry.mtl.syntax.all._
import retry.syntax.all._
import scala.concurrent.duration._

case class AppError(reason: String)

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

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

// evaluates retry on errors produced by MonadError and ApplicativeHandle
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]

implicit val timer: Timer[IO] = IO.timer(scala.concurrent.ExecutionContext.global)

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

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

service.findCoolCatGifRetryMtl(policy).value.attempt.unsafeRunTimed(1.second)
service.findCoolCatGifRetryAll(policy).value.attempt.unsafeRunTimed(1.second)
```
2 changes: 2 additions & 0 deletions modules/docs/src/main/resources/microsite/data/menu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ options:
url: docs/index.html
- title: Combinators
url: docs/combinators.html
- title: MTL Combinators
url: docs/mtl-combinators.html
- title: Retry policies
url: docs/policies.html
- title: Sleep
Expand Down
70 changes: 70 additions & 0 deletions modules/mtl/shared/src/main/scala/retry/mtl/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package retry

import cats.Monad
import cats.mtl.ApplicativeHandle
import cats.syntax.flatMap._
import cats.syntax.functor._

package object mtl {

def retryingOnSomeErrors[A] = new RetryingOnSomeErrorsPartiallyApplied[A]

private[retry] class RetryingOnSomeErrorsPartiallyApplied[A] {

def apply[M[_], E](
policy: RetryPolicy[M],
isWorthRetrying: E => Boolean,
onError: (E, RetryDetails) => M[Unit]
)(
action: => M[A]
)(
implicit
M: Monad[M],
AH: ApplicativeHandle[M, E],
S: Sleep[M]
): M[A] = {
def performNextStep(error: E, nextStep: NextStep): M[A] =
nextStep match {
case NextStep.RetryAfterDelay(delay, updatedStatus) =>
S.sleep(delay) >> performAction(updatedStatus)
case NextStep.GiveUp =>
AH.raise(error)
}

def handleError(error: E, status: RetryStatus): M[A] = {
for {
nextStep <- retry.applyPolicy(policy, status)
_ <- onError(error, retry.buildRetryDetails(status, nextStep))
result <- performNextStep(error, nextStep)
} yield result
}

def performAction(status: RetryStatus): M[A] =
AH.handleWith(action) { error =>
if (isWorthRetrying(error)) handleError(error, status)
else AH.raise(error)
}

performAction(RetryStatus.NoRetriesYet)
}
}

def retryingOnAllErrors[A] = new RetryingOnAllErrorsPartiallyApplied[A]

private[retry] class RetryingOnAllErrorsPartiallyApplied[A] {
def apply[M[_], E](
policy: RetryPolicy[M],
onError: (E, RetryDetails) => M[Unit]
)(
action: => M[A]
)(
implicit
M: Monad[M],
AH: ApplicativeHandle[M, E],
S: Sleep[M]
): M[A] = {
retryingOnSomeErrors[A].apply[M, E](policy, _ => true, onError)(action)
}
}

}
3 changes: 3 additions & 0 deletions modules/mtl/shared/src/main/scala/retry/mtl/syntax/all.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package retry.mtl.syntax

trait AllSyntax extends RetrySyntax
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package retry.mtl

package object syntax {
object all extends AllSyntax
}
Loading

0 comments on commit 21a9764

Please sign in to comment.