From 8872539de064137e286a3f6a1dc6cc12b2b9cc34 Mon Sep 17 00:00:00 2001 From: Maksim Ochenashko Date: Mon, 6 Jan 2020 20:07:22 +0200 Subject: [PATCH 1/4] Implement retry syntax with ApplicativeHandle --- build.sbt | 17 ++ .../shared/src/main/scala/retry/package.scala | 8 +- .../src/main/scala/retry/mtl/Attempt.scala | 48 ++++ .../src/main/scala/retry/mtl/package.scala | 81 +++++++ .../src/main/scala/retry/mtl/syntax/all.scala | 3 + .../main/scala/retry/mtl/syntax/package.scala | 5 + .../main/scala/retry/mtl/syntax/retry.scala | 36 +++ .../test/scala/retry/mtl/AttemptSpec.scala | 65 ++++++ .../scala/retry/mtl/PackageObjectSpec.scala | 193 ++++++++++++++++ .../src/test/scala/retry/mtl/SyntaxSpec.scala | 208 ++++++++++++++++++ 10 files changed, 660 insertions(+), 4 deletions(-) create mode 100644 modules/mtl/shared/src/main/scala/retry/mtl/Attempt.scala create mode 100644 modules/mtl/shared/src/main/scala/retry/mtl/package.scala create mode 100644 modules/mtl/shared/src/main/scala/retry/mtl/syntax/all.scala create mode 100644 modules/mtl/shared/src/main/scala/retry/mtl/syntax/package.scala create mode 100644 modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala create mode 100644 modules/mtl/shared/src/test/scala/retry/mtl/AttemptSpec.scala create mode 100644 modules/mtl/shared/src/test/scala/retry/mtl/PackageObjectSpec.scala create mode 100644 modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala diff --git a/build.sbt b/build.sbt index fb42ec86..057f294f 100644 --- a/build.sbt +++ b/build.sbt @@ -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" @@ -105,6 +106,22 @@ 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) diff --git a/modules/core/shared/src/main/scala/retry/package.scala b/modules/core/shared/src/main/scala/retry/package.scala index 586dee4a..92f37f6a 100644 --- a/modules/core/shared/src/main/scala/retry/package.scala +++ b/modules/core/shared/src/main/scala/retry/package.scala @@ -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] = @@ -135,7 +135,7 @@ package object retry { NextStep.GiveUp } - private def buildRetryDetails( + private[retry] def buildRetryDetails( currentStatus: RetryStatus, nextStep: NextStep ): RetryDetails = @@ -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( diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/Attempt.scala b/modules/mtl/shared/src/main/scala/retry/mtl/Attempt.scala new file mode 100644 index 00000000..604c2b0d --- /dev/null +++ b/modules/mtl/shared/src/main/scala/retry/mtl/Attempt.scala @@ -0,0 +1,48 @@ +package retry.mtl + +import cats.MonadError +import cats.mtl.ApplicativeHandle +import cats.syntax.functor._ + +object Attempt { + + def attempt[M[_], ME, AH, A](action: => M[A])( + implicit ME: MonadError[M, ME], + AH: ApplicativeHandle[M, AH] + ): M[Result[ME, AH, A]] = + ME.attempt(AH.attempt(action)).map { + case Left(error) => Result.MonadErrorRaised(error) + case Right(Left(error)) => Result.ApplicativeHandleRaised(error) + case Right(Right(value)) => Result.Success(value) + } + + def rethrow[M[_], ME, AH, A](result: Result[ME, AH, A])( + implicit ME: MonadError[M, ME], + AH: ApplicativeHandle[M, AH] + ): M[A] = + result match { + case Result.MonadErrorRaised(error) => ME.raiseError(error) + case Result.ApplicativeHandleRaised(error) => AH.raise(error) + case Result.Success(value) => ME.pure(value) + } + + /** + * Represents a result of the execution attempt + * + * @tparam ME the error type that can be produced by [[MonadError]] + * @tparam AH the error type that can be produced by [[ApplicativeHandle]] + * @tparam A the result type + */ + sealed abstract class Result[+ME, +AH, +A] + + object Result { + final case class Success[A](value: A) extends Result[Nothing, Nothing, A] + + final case class ApplicativeHandleRaised[AH](cause: AH) + extends Result[Nothing, AH, Nothing] + + final case class MonadErrorRaised[ME](cause: ME) + extends Result[ME, Nothing, Nothing] + } + +} diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/package.scala b/modules/mtl/shared/src/main/scala/retry/mtl/package.scala new file mode 100644 index 00000000..6fb8a3de --- /dev/null +++ b/modules/mtl/shared/src/main/scala/retry/mtl/package.scala @@ -0,0 +1,81 @@ +package retry + +import cats.mtl.ApplicativeHandle +import cats.{Applicative, MonadError} +import cats.syntax.functor._ +import cats.syntax.flatMap._ + +package object mtl { + + def retryingOnSomeErrors[A] = new RetryingOnSomeErrorsPartiallyApplied[A] + + private[retry] class RetryingOnSomeErrorsPartiallyApplied[A] { + def apply[M[_], ME, AH]( + policy: RetryPolicy[M], + isWorthRetrying: Either[ME, AH] => Boolean, + onError: (Either[ME, AH], RetryDetails) => M[Unit] + )( + action: => M[A] + )( + implicit + ME: MonadError[M, ME], + AH: ApplicativeHandle[M, AH], + S: Sleep[M] + ): M[A] = { + def performNextStep(error: Either[ME, AH], nextStep: NextStep): M[A] = + nextStep match { + case NextStep.RetryAfterDelay(delay, updatedStatus) => + S.sleep(delay) >> performAction(updatedStatus) + case NextStep.GiveUp => + error.fold(ME.raiseError, AH.raise) + } + + def handleError(error: Either[ME, AH], 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] = + Attempt.attempt[M, ME, AH, A](action).flatMap { + case Attempt.Result.MonadErrorRaised(cause) + if isWorthRetrying(Left(cause)) => + handleError(Left(cause), status) + case Attempt.Result.ApplicativeHandleRaised(cause) + if isWorthRetrying(Right(cause)) => + handleError(Right(cause), status) + case other => + Attempt.rethrow(other) + } + + performAction(RetryStatus.NoRetriesYet) + } + } + + def retryingOnAllErrors[A] = new RetryingOnAllErrorsPartiallyApplied[A] + + private[retry] class RetryingOnAllErrorsPartiallyApplied[A] { + def apply[M[_], ME, AH]( + policy: RetryPolicy[M], + onError: (Either[ME, AH], RetryDetails) => M[Unit] + )( + action: => M[A] + )( + implicit + ME: MonadError[M, ME], + AH: ApplicativeHandle[M, AH], + S: Sleep[M] + ): M[A] = { + retryingOnSomeErrors[A].apply[M, ME, AH](policy, _ => true, onError)( + action + ) + } + } + + def noop[M[_]: Applicative, ME, AH] + : (Either[ME, AH], RetryDetails) => M[Unit] = + (_, _) => Applicative[M].pure(()) + +} diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/syntax/all.scala b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/all.scala new file mode 100644 index 00000000..879f2e12 --- /dev/null +++ b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/all.scala @@ -0,0 +1,3 @@ +package retry.mtl.syntax + +trait AllSyntax extends RetrySyntax diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/syntax/package.scala b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/package.scala new file mode 100644 index 00000000..672a6a0a --- /dev/null +++ b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/package.scala @@ -0,0 +1,5 @@ +package retry.mtl + +package object syntax { + object all extends AllSyntax +} diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala new file mode 100644 index 00000000..886f35d2 --- /dev/null +++ b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala @@ -0,0 +1,36 @@ +package retry.mtl.syntax + +import cats.MonadError +import cats.mtl.ApplicativeHandle +import retry.{RetryDetails, RetryPolicy, Sleep} + +trait RetrySyntax { + implicit final def retrySyntaxError[M[_], A, ME]( + action: => M[A] + )(implicit M: MonadError[M, ME]): RetryingErrorOps[M, A, ME] = + new RetryingErrorOps[M, A, ME](action) +} + +final class RetryingErrorOps[M[_], A, ME](action: => M[A])( + implicit M: MonadError[M, ME] +) { + def retryingOnAllErrors[AH]( + policy: RetryPolicy[M], + onError: (Either[ME, AH], RetryDetails) => M[Unit] + )(implicit S: Sleep[M], AH: ApplicativeHandle[M, AH]): M[A] = + retry.mtl.retryingOnAllErrors( + policy = policy, + onError = onError + )(action) + + def retryingOnSomeErrors[AH]( + isWorthRetrying: Either[ME, AH] => Boolean, + policy: RetryPolicy[M], + onError: (Either[ME, AH], RetryDetails) => M[Unit] + )(implicit S: Sleep[M], AH: ApplicativeHandle[M, AH]): M[A] = + retry.mtl.retryingOnSomeErrors( + policy = policy, + isWorthRetrying = isWorthRetrying, + onError = onError + )(action) +} diff --git a/modules/mtl/shared/src/test/scala/retry/mtl/AttemptSpec.scala b/modules/mtl/shared/src/test/scala/retry/mtl/AttemptSpec.scala new file mode 100644 index 00000000..184c5185 --- /dev/null +++ b/modules/mtl/shared/src/test/scala/retry/mtl/AttemptSpec.scala @@ -0,0 +1,65 @@ +package retry.mtl + +import cats.data.EitherT +import cats.data.EitherT.catsDataMonadErrorFForEitherT +import cats.mtl.instances.handle._ +import cats.instances.either._ +import org.scalatest.EitherValues._ +import org.scalatest.flatspec.AnyFlatSpec + +class AttemptSpec extends AnyFlatSpec { + type ErrOr[A] = Either[Throwable, A] + type F[A] = EitherT[ErrOr, String, A] + + behavior of "Attempt.attempt" + + it should "return MonadError exception" in { + val error = new RuntimeException("Boom!") + val raised = Left(error): ErrOr[Either[String, Int]] + val attempt = Attempt.attempt(EitherT(raised)).value + val expected = Attempt.Result.MonadErrorRaised(error) + + assert(attempt.right.value == Right(expected)) + } + + it should "return ApplicativeHandle error" in { + val attempt = Attempt.attempt(EitherT.leftT[ErrOr, Int]("My Error")).value + val expected = Attempt.Result.ApplicativeHandleRaised("My Error") + + assert(attempt.right.value == Right(expected)) + } + + it should "return a result" in { + val attempt = Attempt.attempt(EitherT.rightT[ErrOr, String](42)).value + val expected = Attempt.Result.Success(42) + + assert(attempt.right.value == Right(expected)) + } + + behavior of "Attempt.rethrow" + + it should "rethrow MonadErrorRaised error" in { + val error = new RuntimeException("Boom!") + val rethrow = Attempt.rethrow[F, Throwable, String, Int]( + Attempt.Result.MonadErrorRaised(error) + ) + + assert(rethrow.value.left.value == error) + } + + it should "rethrow ApplicativeHandleRaised error" in { + val rethrow = Attempt.rethrow[F, Throwable, String, Int]( + Attempt.Result.ApplicativeHandleRaised("My Error") + ) + + assert(rethrow.value.right.value == Left("My Error")) + } + + it should "evaluate Success" in { + val rethrow = Attempt.rethrow[F, Throwable, String, Int]( + Attempt.Result.Success(42) + ) + + assert(rethrow.value.right.value == Right(42)) + } +} diff --git a/modules/mtl/shared/src/test/scala/retry/mtl/PackageObjectSpec.scala b/modules/mtl/shared/src/test/scala/retry/mtl/PackageObjectSpec.scala new file mode 100644 index 00000000..57b6c711 --- /dev/null +++ b/modules/mtl/shared/src/test/scala/retry/mtl/PackageObjectSpec.scala @@ -0,0 +1,193 @@ +package retry.mtl + +import cats.data.EitherT +import cats.instances.either._ +import cats.mtl.instances.handle._ +import org.scalatest.flatspec.AnyFlatSpec +import retry.{RetryDetails, RetryPolicies, Sleep} + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.duration._ + +class PackageObjectSpec extends AnyFlatSpec { + type ErrorOr[A] = Either[Throwable, A] + type F[A] = EitherT[ErrorOr, String, A] + + behavior of "retryingOnSomeErrors" + + it should "retry until the action succeeds" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy = RetryPolicies.constantDelay[F](1.second) + + val isWorthRetrying: Either[Throwable, String] => Boolean = { + case Right(string) => string == "one more time" + case Left(cause) => cause == error + case _ => false + } + + val finalResult = retryingOnSomeErrors(policy, isWorthRetrying, onError) { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.pure[ErrorOr, String]("yay") + } + } + + assert(finalResult.value == Right(Right("yay"))) + assert(attempts == 3) + assert(errors.toList == List(Right("one more time"), Left(error))) + assert(!gaveUp) + } + + it should "retry only if the error is worth retrying" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy = RetryPolicies.constantDelay[F](1.second) + + val isWorthRetrying: Either[Throwable, String] => Boolean = { + case Right(string) => string == "one more time" + case Left(cause) => cause == error + case _ => false + } + + val finalResult = retryingOnSomeErrors(policy, isWorthRetrying, onError) { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.leftT[ErrorOr, String]("nope") + } + } + + assert(finalResult.value == Right(Left("nope"))) + assert(attempts == 3) + assert(errors.toList == List(Right("one more time"), Left(error))) + assert(!gaveUp) // false because onError is only called when the error is worth retrying + } + + it should "retry until the policy chooses to give up" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy = RetryPolicies.limitRetries[F](2) + + val isWorthRetrying: Either[Throwable, String] => Boolean = { + case Right(string) => string == "one more time" + case Left(cause) => cause == error + case _ => false + } + + val finalResult = retryingOnSomeErrors(policy, isWorthRetrying, onError) { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.leftT[ErrorOr, String]("one more time") + } + } + + assert(finalResult.value == Right(Left("one more time"))) + assert(attempts == 3) + assert( + errors.toList == List( + Right("one more time"), + Left(error), + Right("one more time") + ) + ) + assert(gaveUp) + } + + behavior of "retryingOnAllErrors" + + it should "retry until the action succeeds" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy = RetryPolicies.constantDelay[F](1.second) + + val finalResult = retryingOnAllErrors(policy, onError) { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.pure[ErrorOr, String]("yay") + } + } + + assert(finalResult.value == Right(Right("yay"))) + assert(attempts == 3) + assert(errors.toList == List(Right("one more time"), Left(error))) + assert(!gaveUp) + } + + it should "retry until the policy chooses to give up" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy = RetryPolicies.limitRetries[F](2) + + val finalResult = retryingOnAllErrors(policy, onError) { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.leftT[ErrorOr, String]("one more time") + } + } + + assert(finalResult.value == Right(Left("one more time"))) + assert(attempts == 3) + assert( + errors.toList == List( + Right("one more time"), + Left(error), + Right("one more time") + ) + ) + assert(gaveUp) + } + + private class TestContext { + var attempts = 0 + val errors = ArrayBuffer.empty[Either[Throwable, String]] + val delays = ArrayBuffer.empty[FiniteDuration] + var gaveUp = false + + def onError( + error: Either[Throwable, String], + details: RetryDetails + ): F[Unit] = { + errors.append(error) + details match { + case RetryDetails.WillDelayAndRetry(delay, _, _) => delays.append(delay) + case RetryDetails.GivingUp(_, _) => gaveUp = true + } + EitherT.pure(()) + } + } +} diff --git a/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala b/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala new file mode 100644 index 00000000..22d99290 --- /dev/null +++ b/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala @@ -0,0 +1,208 @@ +package retry.mtl + +import cats.data.EitherT +import cats.data.EitherT.catsDataMonadErrorFForEitherT +import cats.instances.either._ +import cats.mtl.instances.handle._ +import org.scalatest.flatspec.AnyFlatSpec +import retry.mtl.syntax.all._ +import retry.{RetryDetails, RetryPolicies, RetryPolicy, Sleep} + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.duration._ + +class SyntaxSpec extends AnyFlatSpec { + type ErrorOr[A] = Either[Throwable, A] + type F[A] = EitherT[ErrorOr, String, A] + + behavior of "retryingOnSomeErrors" + + it should "retry until the action succeeds" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy: RetryPolicy[F] = RetryPolicies.constantDelay[F](1.second) + + val isWorthRetrying: Either[Throwable, String] => Boolean = { + case Right(string) => string == "one more time" + case Left(cause) => cause == error + case _ => false + } + + def action: F[String] = { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.pure[ErrorOr, String]("yay") + } + } + + val finalResult: F[String] = + action.retryingOnSomeErrors[String](isWorthRetrying, policy, onError) + + assert(finalResult.value == Right(Right("yay"))) + assert(attempts == 3) + assert(errors.toList == List(Right("one more time"), Left(error))) + assert(!gaveUp) + } + + it should "retry only if the error is worth retrying" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy: RetryPolicy[F] = RetryPolicies.constantDelay[F](1.second) + + val isWorthRetrying: Either[Throwable, String] => Boolean = { + case Right(string) => string == "one more time" + case Left(cause) => cause == error + case _ => false + } + + def action: F[String] = { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.leftT[ErrorOr, String]("nope") + } + } + + val finalResult = + action.retryingOnSomeErrors[String](isWorthRetrying, policy, onError) + + assert(finalResult.value == Right(Left("nope"))) + assert(attempts == 3) + assert(errors.toList == List(Right("one more time"), Left(error))) + assert(!gaveUp) // false because onError is only called when the error is worth retrying + } + + it should "retry until the policy chooses to give up" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy: RetryPolicy[F] = RetryPolicies.limitRetries[F](2) + + val isWorthRetrying: Either[Throwable, String] => Boolean = { + case Right(string) => string == "one more time" + case Left(cause) => cause == error + case _ => false + } + + def action: F[String] = { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.leftT[ErrorOr, String]("one more time") + } + } + + val finalResult: F[String] = + action.retryingOnSomeErrors(isWorthRetrying, policy, onError) + + assert(finalResult.value == Right(Left("one more time"))) + assert(attempts == 3) + assert( + errors.toList == List( + Right("one more time"), + Left(error), + Right("one more time") + ) + ) + assert(gaveUp) + } + + behavior of "retryingOnAllErrors" + + it should "retry until the action succeeds" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy: RetryPolicy[F] = RetryPolicies.constantDelay[F](1.second) + + def action: F[String] = { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.pure[ErrorOr, String]("yay") + } + } + + val finalResult: F[String] = action.retryingOnAllErrors(policy, onError) + + assert(finalResult.value == Right(Right("yay"))) + assert(attempts == 3) + assert(errors.toList == List(Right("one more time"), Left(error))) + assert(!gaveUp) + } + + it should "retry until the policy chooses to give up" in new TestContext { + implicit val sleepForEither: Sleep[F] = + new Sleep[F] { + def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) + } + + val error = new RuntimeException("Boom!") + val policy: RetryPolicy[F] = RetryPolicies.limitRetries[F](2) + + def action: F[String] = { + attempts = attempts + 1 + + attempts match { + case 1 => EitherT.leftT[ErrorOr, String]("one more time") + case 2 => EitherT[ErrorOr, String, String](Left(error)) + case _ => EitherT.leftT[ErrorOr, String]("one more time") + } + } + + val finalResult = action.retryingOnAllErrors(policy, onError) + + assert(finalResult.value == Right(Left("one more time"))) + assert(attempts == 3) + assert( + errors.toList == List( + Right("one more time"), + Left(error), + Right("one more time") + ) + ) + assert(gaveUp) + } + + private class TestContext { + var attempts = 0 + val errors = ArrayBuffer.empty[Either[Throwable, String]] + val delays = ArrayBuffer.empty[FiniteDuration] + var gaveUp = false + + def onError( + error: Either[Throwable, String], + details: RetryDetails + ): F[Unit] = { + errors.append(error) + details match { + case RetryDetails.WillDelayAndRetry(delay, _, _) => delays.append(delay) + case RetryDetails.GivingUp(_, _) => gaveUp = true + } + EitherT.pure(()) + } + } +} From ff7926379e95725e0aefa1f6bf80015d1a764487 Mon Sep 17 00:00:00 2001 From: Maksim Ochenashko Date: Mon, 13 Jan 2020 19:30:34 +0200 Subject: [PATCH 2/4] Retry only on MTL errors --- .../src/main/scala/retry/mtl/Attempt.scala | 48 -------- .../src/main/scala/retry/mtl/package.scala | 49 ++++---- .../main/scala/retry/mtl/syntax/retry.scala | 28 ++--- .../test/scala/retry/mtl/AttemptSpec.scala | 65 ----------- .../scala/retry/mtl/PackageObjectSpec.scala | 107 ++++++------------ .../src/test/scala/retry/mtl/SyntaxSpec.scala | 60 +++++----- 6 files changed, 99 insertions(+), 258 deletions(-) delete mode 100644 modules/mtl/shared/src/main/scala/retry/mtl/Attempt.scala delete mode 100644 modules/mtl/shared/src/test/scala/retry/mtl/AttemptSpec.scala diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/Attempt.scala b/modules/mtl/shared/src/main/scala/retry/mtl/Attempt.scala deleted file mode 100644 index 604c2b0d..00000000 --- a/modules/mtl/shared/src/main/scala/retry/mtl/Attempt.scala +++ /dev/null @@ -1,48 +0,0 @@ -package retry.mtl - -import cats.MonadError -import cats.mtl.ApplicativeHandle -import cats.syntax.functor._ - -object Attempt { - - def attempt[M[_], ME, AH, A](action: => M[A])( - implicit ME: MonadError[M, ME], - AH: ApplicativeHandle[M, AH] - ): M[Result[ME, AH, A]] = - ME.attempt(AH.attempt(action)).map { - case Left(error) => Result.MonadErrorRaised(error) - case Right(Left(error)) => Result.ApplicativeHandleRaised(error) - case Right(Right(value)) => Result.Success(value) - } - - def rethrow[M[_], ME, AH, A](result: Result[ME, AH, A])( - implicit ME: MonadError[M, ME], - AH: ApplicativeHandle[M, AH] - ): M[A] = - result match { - case Result.MonadErrorRaised(error) => ME.raiseError(error) - case Result.ApplicativeHandleRaised(error) => AH.raise(error) - case Result.Success(value) => ME.pure(value) - } - - /** - * Represents a result of the execution attempt - * - * @tparam ME the error type that can be produced by [[MonadError]] - * @tparam AH the error type that can be produced by [[ApplicativeHandle]] - * @tparam A the result type - */ - sealed abstract class Result[+ME, +AH, +A] - - object Result { - final case class Success[A](value: A) extends Result[Nothing, Nothing, A] - - final case class ApplicativeHandleRaised[AH](cause: AH) - extends Result[Nothing, AH, Nothing] - - final case class MonadErrorRaised[ME](cause: ME) - extends Result[ME, Nothing, Nothing] - } - -} diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/package.scala b/modules/mtl/shared/src/main/scala/retry/mtl/package.scala index 6fb8a3de..96cd2c6d 100644 --- a/modules/mtl/shared/src/main/scala/retry/mtl/package.scala +++ b/modules/mtl/shared/src/main/scala/retry/mtl/package.scala @@ -1,36 +1,37 @@ package retry +import cats.Monad import cats.mtl.ApplicativeHandle -import cats.{Applicative, MonadError} -import cats.syntax.functor._ 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[_], ME, AH]( + + def apply[M[_], E]( policy: RetryPolicy[M], - isWorthRetrying: Either[ME, AH] => Boolean, - onError: (Either[ME, AH], RetryDetails) => M[Unit] + isWorthRetrying: E => Boolean, + onError: (E, RetryDetails) => M[Unit] )( action: => M[A] )( implicit - ME: MonadError[M, ME], - AH: ApplicativeHandle[M, AH], + M: Monad[M], + AH: ApplicativeHandle[M, E], S: Sleep[M] ): M[A] = { - def performNextStep(error: Either[ME, AH], nextStep: NextStep): 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 => - error.fold(ME.raiseError, AH.raise) + AH.raise(error) } - def handleError(error: Either[ME, AH], status: RetryStatus): M[A] = { + def handleError(error: E, status: RetryStatus): M[A] = { for { nextStep <- retry.applyPolicy(policy, status) _ <- onError(error, retry.buildRetryDetails(status, nextStep)) @@ -39,15 +40,9 @@ package object mtl { } def performAction(status: RetryStatus): M[A] = - Attempt.attempt[M, ME, AH, A](action).flatMap { - case Attempt.Result.MonadErrorRaised(cause) - if isWorthRetrying(Left(cause)) => - handleError(Left(cause), status) - case Attempt.Result.ApplicativeHandleRaised(cause) - if isWorthRetrying(Right(cause)) => - handleError(Right(cause), status) - case other => - Attempt.rethrow(other) + AH.handleWith(action) { error => + if (isWorthRetrying(error)) handleError(error, status) + else AH.raise(error) } performAction(RetryStatus.NoRetriesYet) @@ -57,25 +52,19 @@ package object mtl { def retryingOnAllErrors[A] = new RetryingOnAllErrorsPartiallyApplied[A] private[retry] class RetryingOnAllErrorsPartiallyApplied[A] { - def apply[M[_], ME, AH]( + def apply[M[_], E]( policy: RetryPolicy[M], - onError: (Either[ME, AH], RetryDetails) => M[Unit] + onError: (E, RetryDetails) => M[Unit] )( action: => M[A] )( implicit - ME: MonadError[M, ME], - AH: ApplicativeHandle[M, AH], + M: Monad[M], + AH: ApplicativeHandle[M, E], S: Sleep[M] ): M[A] = { - retryingOnSomeErrors[A].apply[M, ME, AH](policy, _ => true, onError)( - action - ) + retryingOnSomeErrors[A].apply[M, E](policy, _ => true, onError)(action) } } - def noop[M[_]: Applicative, ME, AH] - : (Either[ME, AH], RetryDetails) => M[Unit] = - (_, _) => Applicative[M].pure(()) - } diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala index 886f35d2..0b0e664b 100644 --- a/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala +++ b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala @@ -1,36 +1,38 @@ package retry.mtl.syntax -import cats.MonadError +import cats.Monad import cats.mtl.ApplicativeHandle import retry.{RetryDetails, RetryPolicy, Sleep} trait RetrySyntax { - implicit final def retrySyntaxError[M[_], A, ME]( + implicit final def retrySyntaxMtlError[M[_], A]( action: => M[A] - )(implicit M: MonadError[M, ME]): RetryingErrorOps[M, A, ME] = - new RetryingErrorOps[M, A, ME](action) + )(implicit M: Monad[M]): RetryingMtlErrorOps[M, A] = + new RetryingMtlErrorOps[M, A](action) } -final class RetryingErrorOps[M[_], A, ME](action: => M[A])( - implicit M: MonadError[M, ME] +final class RetryingMtlErrorOps[M[_], A](action: => M[A])( + implicit M: Monad[M] ) { - def retryingOnAllErrors[AH]( + + def retryingOnAllErrorsMtl[E]( policy: RetryPolicy[M], - onError: (Either[ME, AH], RetryDetails) => M[Unit] - )(implicit S: Sleep[M], AH: ApplicativeHandle[M, AH]): M[A] = + onError: (E, RetryDetails) => M[Unit] + )(implicit S: Sleep[M], AH: ApplicativeHandle[M, E]): M[A] = retry.mtl.retryingOnAllErrors( policy = policy, onError = onError )(action) - def retryingOnSomeErrors[AH]( - isWorthRetrying: Either[ME, AH] => Boolean, + def retryingOnSomeErrorsMtl[E]( + isWorthRetrying: E => Boolean, policy: RetryPolicy[M], - onError: (Either[ME, AH], RetryDetails) => M[Unit] - )(implicit S: Sleep[M], AH: ApplicativeHandle[M, AH]): M[A] = + onError: (E, RetryDetails) => M[Unit] + )(implicit S: Sleep[M], AH: ApplicativeHandle[M, E]): M[A] = retry.mtl.retryingOnSomeErrors( policy = policy, isWorthRetrying = isWorthRetrying, onError = onError )(action) + } diff --git a/modules/mtl/shared/src/test/scala/retry/mtl/AttemptSpec.scala b/modules/mtl/shared/src/test/scala/retry/mtl/AttemptSpec.scala deleted file mode 100644 index 184c5185..00000000 --- a/modules/mtl/shared/src/test/scala/retry/mtl/AttemptSpec.scala +++ /dev/null @@ -1,65 +0,0 @@ -package retry.mtl - -import cats.data.EitherT -import cats.data.EitherT.catsDataMonadErrorFForEitherT -import cats.mtl.instances.handle._ -import cats.instances.either._ -import org.scalatest.EitherValues._ -import org.scalatest.flatspec.AnyFlatSpec - -class AttemptSpec extends AnyFlatSpec { - type ErrOr[A] = Either[Throwable, A] - type F[A] = EitherT[ErrOr, String, A] - - behavior of "Attempt.attempt" - - it should "return MonadError exception" in { - val error = new RuntimeException("Boom!") - val raised = Left(error): ErrOr[Either[String, Int]] - val attempt = Attempt.attempt(EitherT(raised)).value - val expected = Attempt.Result.MonadErrorRaised(error) - - assert(attempt.right.value == Right(expected)) - } - - it should "return ApplicativeHandle error" in { - val attempt = Attempt.attempt(EitherT.leftT[ErrOr, Int]("My Error")).value - val expected = Attempt.Result.ApplicativeHandleRaised("My Error") - - assert(attempt.right.value == Right(expected)) - } - - it should "return a result" in { - val attempt = Attempt.attempt(EitherT.rightT[ErrOr, String](42)).value - val expected = Attempt.Result.Success(42) - - assert(attempt.right.value == Right(expected)) - } - - behavior of "Attempt.rethrow" - - it should "rethrow MonadErrorRaised error" in { - val error = new RuntimeException("Boom!") - val rethrow = Attempt.rethrow[F, Throwable, String, Int]( - Attempt.Result.MonadErrorRaised(error) - ) - - assert(rethrow.value.left.value == error) - } - - it should "rethrow ApplicativeHandleRaised error" in { - val rethrow = Attempt.rethrow[F, Throwable, String, Int]( - Attempt.Result.ApplicativeHandleRaised("My Error") - ) - - assert(rethrow.value.right.value == Left("My Error")) - } - - it should "evaluate Success" in { - val rethrow = Attempt.rethrow[F, Throwable, String, Int]( - Attempt.Result.Success(42) - ) - - assert(rethrow.value.right.value == Right(42)) - } -} diff --git a/modules/mtl/shared/src/test/scala/retry/mtl/PackageObjectSpec.scala b/modules/mtl/shared/src/test/scala/retry/mtl/PackageObjectSpec.scala index 57b6c711..7f894ab9 100644 --- a/modules/mtl/shared/src/test/scala/retry/mtl/PackageObjectSpec.scala +++ b/modules/mtl/shared/src/test/scala/retry/mtl/PackageObjectSpec.scala @@ -21,28 +21,23 @@ class PackageObjectSpec extends AnyFlatSpec { def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) } - val error = new RuntimeException("Boom!") val policy = RetryPolicies.constantDelay[F](1.second) - val isWorthRetrying: Either[Throwable, String] => Boolean = { - case Right(string) => string == "one more time" - case Left(cause) => cause == error - case _ => false - } + val isWorthRetrying: String => Boolean = _ == "one more time" - val finalResult = retryingOnSomeErrors(policy, isWorthRetrying, onError) { - attempts = attempts + 1 + val finalResult = + retryingOnSomeErrors(policy, isWorthRetrying, onMtlError) { + attempts = attempts + 1 - attempts match { - case 1 => EitherT.leftT[ErrorOr, String]("one more time") - case 2 => EitherT[ErrorOr, String, String](Left(error)) - case _ => EitherT.pure[ErrorOr, String]("yay") + if (attempts < 3) + EitherT.leftT[ErrorOr, String]("one more time") + else + EitherT.pure[ErrorOr, String]("yay") } - } assert(finalResult.value == Right(Right("yay"))) assert(attempts == 3) - assert(errors.toList == List(Right("one more time"), Left(error))) + assert(errors.toList == List("one more time", "one more time")) assert(!gaveUp) } @@ -52,28 +47,23 @@ class PackageObjectSpec extends AnyFlatSpec { def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) } - val error = new RuntimeException("Boom!") val policy = RetryPolicies.constantDelay[F](1.second) - val isWorthRetrying: Either[Throwable, String] => Boolean = { - case Right(string) => string == "one more time" - case Left(cause) => cause == error - case _ => false - } + val isWorthRetrying: String => Boolean = _ == "one more time" - val finalResult = retryingOnSomeErrors(policy, isWorthRetrying, onError) { - attempts = attempts + 1 + val finalResult = + retryingOnSomeErrors(policy, isWorthRetrying, onMtlError) { + attempts = attempts + 1 - attempts match { - case 1 => EitherT.leftT[ErrorOr, String]("one more time") - case 2 => EitherT[ErrorOr, String, String](Left(error)) - case _ => EitherT.leftT[ErrorOr, String]("nope") + if (attempts < 3) + EitherT.leftT[ErrorOr, String]("one more time") + else + EitherT.leftT[ErrorOr, String]("nope") } - } assert(finalResult.value == Right(Left("nope"))) assert(attempts == 3) - assert(errors.toList == List(Right("one more time"), Left(error))) + assert(errors.toList == List("one more time", "one more time")) assert(!gaveUp) // false because onError is only called when the error is worth retrying } @@ -83,33 +73,20 @@ class PackageObjectSpec extends AnyFlatSpec { def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) } - val error = new RuntimeException("Boom!") val policy = RetryPolicies.limitRetries[F](2) - val isWorthRetrying: Either[Throwable, String] => Boolean = { - case Right(string) => string == "one more time" - case Left(cause) => cause == error - case _ => false - } + val isWorthRetrying: String => Boolean = _ == "one more time" - val finalResult = retryingOnSomeErrors(policy, isWorthRetrying, onError) { - attempts = attempts + 1 - - attempts match { - case 1 => EitherT.leftT[ErrorOr, String]("one more time") - case 2 => EitherT[ErrorOr, String, String](Left(error)) - case _ => EitherT.leftT[ErrorOr, String]("one more time") + val finalResult = + retryingOnSomeErrors(policy, isWorthRetrying, onMtlError) { + attempts = attempts + 1 + EitherT.leftT[ErrorOr, String]("one more time") } - } assert(finalResult.value == Right(Left("one more time"))) assert(attempts == 3) assert( - errors.toList == List( - Right("one more time"), - Left(error), - Right("one more time") - ) + errors.toList == List("one more time", "one more time", "one more time") ) assert(gaveUp) } @@ -122,22 +99,20 @@ class PackageObjectSpec extends AnyFlatSpec { def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) } - val error = new RuntimeException("Boom!") val policy = RetryPolicies.constantDelay[F](1.second) - val finalResult = retryingOnAllErrors(policy, onError) { + val finalResult = retryingOnAllErrors(policy, onMtlError) { attempts = attempts + 1 - attempts match { - case 1 => EitherT.leftT[ErrorOr, String]("one more time") - case 2 => EitherT[ErrorOr, String, String](Left(error)) - case _ => EitherT.pure[ErrorOr, String]("yay") - } + if (attempts < 3) + EitherT.leftT[ErrorOr, String]("one more time") + else + EitherT.pure[ErrorOr, String]("yay") } assert(finalResult.value == Right(Right("yay"))) assert(attempts == 3) - assert(errors.toList == List(Right("one more time"), Left(error))) + assert(errors.toList == List("one more time", "one more time")) assert(!gaveUp) } @@ -147,39 +122,29 @@ class PackageObjectSpec extends AnyFlatSpec { def sleep(delay: FiniteDuration): F[Unit] = EitherT.pure(()) } - val error = new RuntimeException("Boom!") val policy = RetryPolicies.limitRetries[F](2) - val finalResult = retryingOnAllErrors(policy, onError) { + val finalResult = retryingOnAllErrors(policy, onMtlError) { attempts = attempts + 1 - - attempts match { - case 1 => EitherT.leftT[ErrorOr, String]("one more time") - case 2 => EitherT[ErrorOr, String, String](Left(error)) - case _ => EitherT.leftT[ErrorOr, String]("one more time") - } + EitherT.leftT[ErrorOr, String]("one more time") } assert(finalResult.value == Right(Left("one more time"))) assert(attempts == 3) assert( - errors.toList == List( - Right("one more time"), - Left(error), - Right("one more time") - ) + errors.toList == List("one more time", "one more time", "one more time") ) assert(gaveUp) } private class TestContext { var attempts = 0 - val errors = ArrayBuffer.empty[Either[Throwable, String]] + val errors = ArrayBuffer.empty[String] val delays = ArrayBuffer.empty[FiniteDuration] var gaveUp = false - def onError( - error: Either[Throwable, String], + def onMtlError( + error: String, details: RetryDetails ): F[Unit] = { errors.append(error) diff --git a/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala b/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala index 22d99290..b889c4ab 100644 --- a/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala +++ b/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala @@ -5,6 +5,7 @@ import cats.data.EitherT.catsDataMonadErrorFForEitherT import cats.instances.either._ import cats.mtl.instances.handle._ import org.scalatest.flatspec.AnyFlatSpec +import retry.syntax.all._ import retry.mtl.syntax.all._ import retry.{RetryDetails, RetryPolicies, RetryPolicy, Sleep} @@ -15,7 +16,7 @@ class SyntaxSpec extends AnyFlatSpec { type ErrorOr[A] = Either[Throwable, A] type F[A] = EitherT[ErrorOr, String, A] - behavior of "retryingOnSomeErrors" + behavior of "retryingOnSomeErrorsMtl" it should "retry until the action succeeds" in new TestContext { implicit val sleepForEither: Sleep[F] = @@ -26,12 +27,6 @@ class SyntaxSpec extends AnyFlatSpec { val error = new RuntimeException("Boom!") val policy: RetryPolicy[F] = RetryPolicies.constantDelay[F](1.second) - val isWorthRetrying: Either[Throwable, String] => Boolean = { - case Right(string) => string == "one more time" - case Left(cause) => cause == error - case _ => false - } - def action: F[String] = { attempts = attempts + 1 @@ -42,8 +37,9 @@ class SyntaxSpec extends AnyFlatSpec { } } - val finalResult: F[String] = - action.retryingOnSomeErrors[String](isWorthRetrying, policy, onError) + val finalResult: F[String] = action + .retryingOnSomeErrors(_ == error, policy, onError) + .retryingOnSomeErrorsMtl[String](_ == "one more time", policy, onMtlError) assert(finalResult.value == Right(Right("yay"))) assert(attempts == 3) @@ -60,12 +56,6 @@ class SyntaxSpec extends AnyFlatSpec { val error = new RuntimeException("Boom!") val policy: RetryPolicy[F] = RetryPolicies.constantDelay[F](1.second) - val isWorthRetrying: Either[Throwable, String] => Boolean = { - case Right(string) => string == "one more time" - case Left(cause) => cause == error - case _ => false - } - def action: F[String] = { attempts = attempts + 1 @@ -76,8 +66,9 @@ class SyntaxSpec extends AnyFlatSpec { } } - val finalResult = - action.retryingOnSomeErrors[String](isWorthRetrying, policy, onError) + val finalResult: F[String] = action + .retryingOnSomeErrors(_ == error, policy, onError) + .retryingOnSomeErrorsMtl[String](_ == "one more time", policy, onMtlError) assert(finalResult.value == Right(Left("nope"))) assert(attempts == 3) @@ -94,12 +85,6 @@ class SyntaxSpec extends AnyFlatSpec { val error = new RuntimeException("Boom!") val policy: RetryPolicy[F] = RetryPolicies.limitRetries[F](2) - val isWorthRetrying: Either[Throwable, String] => Boolean = { - case Right(string) => string == "one more time" - case Left(cause) => cause == error - case _ => false - } - def action: F[String] = { attempts = attempts + 1 @@ -110,22 +95,24 @@ class SyntaxSpec extends AnyFlatSpec { } } - val finalResult: F[String] = - action.retryingOnSomeErrors(isWorthRetrying, policy, onError) + val finalResult: F[String] = action + .retryingOnSomeErrors(_ == error, policy, onError) + .retryingOnSomeErrorsMtl[String](_ == "one more time", policy, onMtlError) assert(finalResult.value == Right(Left("one more time"))) - assert(attempts == 3) + assert(attempts == 4) assert( errors.toList == List( Right("one more time"), Left(error), + Right("one more time"), Right("one more time") ) ) assert(gaveUp) } - behavior of "retryingOnAllErrors" + behavior of "retryingOnAllErrorsMtl" it should "retry until the action succeeds" in new TestContext { implicit val sleepForEither: Sleep[F] = @@ -146,7 +133,9 @@ class SyntaxSpec extends AnyFlatSpec { } } - val finalResult: F[String] = action.retryingOnAllErrors(policy, onError) + val finalResult: F[String] = action + .retryingOnAllErrors(policy, onError) + .retryingOnAllErrorsMtl[String](policy, onMtlError) assert(finalResult.value == Right(Right("yay"))) assert(attempts == 3) @@ -173,14 +162,17 @@ class SyntaxSpec extends AnyFlatSpec { } } - val finalResult = action.retryingOnAllErrors(policy, onError) + val finalResult: F[String] = action + .retryingOnAllErrors(policy, onError) + .retryingOnAllErrorsMtl[String](policy, onMtlError) assert(finalResult.value == Right(Left("one more time"))) - assert(attempts == 3) + assert(attempts == 4) assert( errors.toList == List( Right("one more time"), Left(error), + Right("one more time"), Right("one more time") ) ) @@ -193,7 +185,13 @@ class SyntaxSpec extends AnyFlatSpec { val delays = ArrayBuffer.empty[FiniteDuration] var gaveUp = false - def onError( + def onError(error: Throwable, details: RetryDetails): F[Unit] = + onErrorInternal(Left(error), details) + + def onMtlError(error: String, details: RetryDetails): F[Unit] = + onErrorInternal(Right(error), details) + + private def onErrorInternal( error: Either[Throwable, String], details: RetryDetails ): F[Unit] = { From 10a8cb3af9806944b5686cb5a56997e94fffed62 Mon Sep 17 00:00:00 2001 From: Maksim Ochenashko Date: Tue, 14 Jan 2020 13:23:42 +0200 Subject: [PATCH 3/4] Add documentation --- build.sbt | 2 +- modules/docs/src/main/mdoc/docs/index.md | 1 + .../src/main/mdoc/docs/mtl-combinators.md | 205 ++++++++++++++++++ .../main/resources/microsite/data/menu.yml | 2 + 4 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 modules/docs/src/main/mdoc/docs/mtl-combinators.md diff --git a/build.sbt b/build.sbt index 057f294f..d8d72068 100644 --- a/build.sbt +++ b/build.sbt @@ -124,7 +124,7 @@ val mtlJS = mtlRetry.js val docs = project .in(file("modules/docs")) - .dependsOn(coreJVM, alleycatsJVM) + .dependsOn(coreJVM, alleycatsJVM, mtlJVM) .enablePlugins(MicrositesPlugin, BuildInfoPlugin) .settings(moduleSettings) .settings( diff --git a/modules/docs/src/main/mdoc/docs/index.md b/modules/docs/src/main/mdoc/docs/index.md index 70cda140..c450ce97 100644 --- a/modules/docs/src/main/mdoc/docs/index.md +++ b/modules/docs/src/main/mdoc/docs/index.md @@ -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) diff --git a/modules/docs/src/main/mdoc/docs/mtl-combinators.md b/modules/docs/src/main/mdoc/docs/mtl-combinators.md new file mode 100644 index 00000000..e728dd56 --- /dev/null +++ b/modules/docs/src/main/mdoc/docs/mtl-combinators.md @@ -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, just chain the methods: +```scala +fa + .retryingOnAllErrors(policy, onError = retry.noop[F, Throwable]) + .retryingOnAllErrorsMtl[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 `ApplicatieHandle[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.retryingOnAllErrorsMtl[AppError](policy, logMtlError) + + // evaluates retry on errors produced by MonadError and ApplicativeHandle + def findCoolCatGifRetryAll(policy: RetryPolicy[F]): F[String] = + findCoolCatGif + .retryingOnAllErrors(policy, logError) + .retryingOnAllErrorsMtl[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) +``` diff --git a/modules/docs/src/main/resources/microsite/data/menu.yml b/modules/docs/src/main/resources/microsite/data/menu.yml index 0a78f4fd..c4921cd8 100644 --- a/modules/docs/src/main/resources/microsite/data/menu.yml +++ b/modules/docs/src/main/resources/microsite/data/menu.yml @@ -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 From 0e9fdf802587830770ba3e5a5f42e18a37f97678 Mon Sep 17 00:00:00 2001 From: Maksim Ochenashko Date: Tue, 14 Jan 2020 15:38:00 +0200 Subject: [PATCH 4/4] Rename retryingOnAllErrorsMtl -> retryingOnAllMtlErrors. Update docs --- build.sbt | 2 ++ modules/docs/src/main/mdoc/docs/mtl-combinators.md | 10 +++++----- .../src/main/scala/retry/mtl/syntax/retry.scala | 4 ++-- .../src/test/scala/retry/mtl/SyntaxSpec.scala | 14 +++++++------- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/build.sbt b/build.sbt index d8d72068..8906854e 100644 --- a/build.sbt +++ b/build.sbt @@ -163,6 +163,8 @@ val root = project coreJS, alleycatsJVM, alleycatsJS, + mtlJVM, + mtlJS, docs ) .settings(commonSettings) diff --git a/modules/docs/src/main/mdoc/docs/mtl-combinators.md b/modules/docs/src/main/mdoc/docs/mtl-combinators.md index e728dd56..db341341 100644 --- a/modules/docs/src/main/mdoc/docs/mtl-combinators.md +++ b/modules/docs/src/main/mdoc/docs/mtl-combinators.md @@ -28,11 +28,11 @@ MTL retry works independently from `retry.retryingOnSomeErrors`. The operations `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, just chain the methods: +If you want to retry in case of any error, you can chain the methods: ```scala fa .retryingOnAllErrors(policy, onError = retry.noop[F, Throwable]) - .retryingOnAllErrorsMtl[AppError](policy, onError = retry.noop[F, AppError]) + .retryingOnAllMtlErrors[AppError](policy, onError = retry.noop[F, AppError]) ``` ## `retryingOnSomeErrors` @@ -94,7 +94,7 @@ retry.mtl ## `retryingOnAllErrors` -This is useful when you are working with a `ApplicatieHandle[M, E]` and you want to +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: @@ -164,13 +164,13 @@ class Service[F[_]: Timer](client: util.FlakyHttpClient)(implicit F: Sync[F], AH // evaluates retry exclusively on errors produced by ApplicativeHandle. def findCoolCatGifRetryMtl(policy: RetryPolicy[F]): F[String] = - findCoolCatGif.retryingOnAllErrorsMtl[AppError](policy, logMtlError) + 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) - .retryingOnAllErrorsMtl[AppError](policy, logMtlError) + .retryingOnAllMtlErrors[AppError](policy, logMtlError) private def findCoolCatGif: F[String] = for { diff --git a/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala index 0b0e664b..ef63727e 100644 --- a/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala +++ b/modules/mtl/shared/src/main/scala/retry/mtl/syntax/retry.scala @@ -15,7 +15,7 @@ final class RetryingMtlErrorOps[M[_], A](action: => M[A])( implicit M: Monad[M] ) { - def retryingOnAllErrorsMtl[E]( + def retryingOnAllMtlErrors[E]( policy: RetryPolicy[M], onError: (E, RetryDetails) => M[Unit] )(implicit S: Sleep[M], AH: ApplicativeHandle[M, E]): M[A] = @@ -24,7 +24,7 @@ final class RetryingMtlErrorOps[M[_], A](action: => M[A])( onError = onError )(action) - def retryingOnSomeErrorsMtl[E]( + def retryingOnSomeMtlErrors[E]( isWorthRetrying: E => Boolean, policy: RetryPolicy[M], onError: (E, RetryDetails) => M[Unit] diff --git a/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala b/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala index b889c4ab..298c8161 100644 --- a/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala +++ b/modules/mtl/shared/src/test/scala/retry/mtl/SyntaxSpec.scala @@ -16,7 +16,7 @@ class SyntaxSpec extends AnyFlatSpec { type ErrorOr[A] = Either[Throwable, A] type F[A] = EitherT[ErrorOr, String, A] - behavior of "retryingOnSomeErrorsMtl" + behavior of "retryingOnSomeMtlErrors" it should "retry until the action succeeds" in new TestContext { implicit val sleepForEither: Sleep[F] = @@ -39,7 +39,7 @@ class SyntaxSpec extends AnyFlatSpec { val finalResult: F[String] = action .retryingOnSomeErrors(_ == error, policy, onError) - .retryingOnSomeErrorsMtl[String](_ == "one more time", policy, onMtlError) + .retryingOnSomeMtlErrors[String](_ == "one more time", policy, onMtlError) assert(finalResult.value == Right(Right("yay"))) assert(attempts == 3) @@ -68,7 +68,7 @@ class SyntaxSpec extends AnyFlatSpec { val finalResult: F[String] = action .retryingOnSomeErrors(_ == error, policy, onError) - .retryingOnSomeErrorsMtl[String](_ == "one more time", policy, onMtlError) + .retryingOnSomeMtlErrors[String](_ == "one more time", policy, onMtlError) assert(finalResult.value == Right(Left("nope"))) assert(attempts == 3) @@ -97,7 +97,7 @@ class SyntaxSpec extends AnyFlatSpec { val finalResult: F[String] = action .retryingOnSomeErrors(_ == error, policy, onError) - .retryingOnSomeErrorsMtl[String](_ == "one more time", policy, onMtlError) + .retryingOnSomeMtlErrors[String](_ == "one more time", policy, onMtlError) assert(finalResult.value == Right(Left("one more time"))) assert(attempts == 4) @@ -112,7 +112,7 @@ class SyntaxSpec extends AnyFlatSpec { assert(gaveUp) } - behavior of "retryingOnAllErrorsMtl" + behavior of "retryingOnAllMtlErrors" it should "retry until the action succeeds" in new TestContext { implicit val sleepForEither: Sleep[F] = @@ -135,7 +135,7 @@ class SyntaxSpec extends AnyFlatSpec { val finalResult: F[String] = action .retryingOnAllErrors(policy, onError) - .retryingOnAllErrorsMtl[String](policy, onMtlError) + .retryingOnAllMtlErrors[String](policy, onMtlError) assert(finalResult.value == Right(Right("yay"))) assert(attempts == 3) @@ -164,7 +164,7 @@ class SyntaxSpec extends AnyFlatSpec { val finalResult: F[String] = action .retryingOnAllErrors(policy, onError) - .retryingOnAllErrorsMtl[String](policy, onMtlError) + .retryingOnAllMtlErrors[String](policy, onMtlError) assert(finalResult.value == Right(Left("one more time"))) assert(attempts == 4)