diff --git a/README.md b/README.md index 5f29d85c..2b4c1b79 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ A library for retrying actions that can fail. -Designed to work with [cats](https://typelevel.org/cats/) and (optionally) -[cats-effect](https://typelevel.org/cats-effect/) or [Monix](https://monix.io/). +Designed to work with [cats](https://typelevel.org/cats/) and [cats-effect](https://typelevel.org/cats-effect/) or [Monix](https://monix.io/). Inspired by the [retry Haskell package](https://hackage.haskell.org/package/retry). diff --git a/build.sbt b/build.sbt index 216d2a9a..02c8b4b3 100644 --- a/build.sbt +++ b/build.sbt @@ -37,7 +37,6 @@ val commonSettings = Seq( ) val moduleSettings = commonSettings ++ Seq( - moduleName := s"cats-retry-${name.value}", scalacOptions ++= Seq( "-Xfuture", "-Ywarn-dead-code", @@ -58,6 +57,7 @@ val moduleSettings = commonSettings ++ Seq( ) val catsVersion = "2.0.0" +val catsEffectVersion = "2.0.0" val scalatestVersion = "3.1.0" val scalaTestPlusVersion = "3.1.0.0-RC2" val scalacheckVersion = "1.14.2" @@ -67,9 +67,13 @@ val core = crossProject(JVMPlatform, JSPlatform) .in(file("modules/core")) .settings(moduleSettings) .settings( + name := "cats-retry", crossScalaVersions := scalaVersions, libraryDependencies ++= Seq( "org.typelevel" %%% "cats-core" % catsVersion, + "org.typelevel" %%% "cats-effect" % catsEffectVersion, + "org.scalatest" %%% "scalatest" % scalatestVersion % Test, + "org.scalacheck" %%% "scalacheck" % scalacheckVersion % Test, "org.typelevel" %%% "cats-laws" % catsVersion % Test, "org.scalatest" %%% "scalatest" % scalatestVersion % Test, "org.scalatestplus" %%% "scalatestplus-scalacheck" % scalaTestPlusVersion % Test, @@ -80,42 +84,30 @@ val core = crossProject(JVMPlatform, JSPlatform) val coreJVM = core.jvm val coreJS = core.js -val catsEffect = crossProject(JVMPlatform, JSPlatform) - .in(file("modules/cats-effect")) +val alleycatsRetry = crossProject(JVMPlatform, JSPlatform) + .in(file("modules/alleycats")) .jvmConfigure(_.dependsOn(coreJVM)) .jsConfigure(_.dependsOn(coreJS)) .settings(moduleSettings) .settings( + name := "alleycats-retry", crossScalaVersions := scalaVersions, - name := "cats-effect", - libraryDependencies ++= Seq( - "org.typelevel" %%% "cats-effect" % "2.0.0", - "org.scalatest" %%% "scalatest" % scalatestVersion % Test, - "org.scalacheck" %%% "scalacheck" % scalacheckVersion % Test - ) - ) -val catsEffectJVM = catsEffect.jvm -val catsEffectJS = catsEffect.js - -val monix = crossProject(JVMPlatform, JSPlatform) - .in(file("modules/monix")) - .jvmConfigure(_.dependsOn(coreJVM)) - .jsConfigure(_.dependsOn(coreJS)) - .settings(moduleSettings) - .settings( - crossScalaVersions := List(scalaVersion212, scalaVersion211), libraryDependencies ++= Seq( - "io.monix" %%% "monix" % "3.1.0", - "org.scalatest" %%% "scalatest" % scalatestVersion % Test, - "org.scalacheck" %%% "scalacheck" % scalacheckVersion % Test + "org.scalatest" %%% "scalatest" % scalatestVersion % Test, + "org.scalacheck" %%% "scalacheck" % scalacheckVersion % Test, + "org.typelevel" %%% "cats-laws" % catsVersion % Test, + "org.scalatest" %%% "scalatest" % scalatestVersion % Test, + "org.scalatestplus" %%% "scalatestplus-scalacheck" % scalaTestPlusVersion % Test, + "org.typelevel" %%% "discipline-scalatest" % disciplineVersion % Test, + "org.scalacheck" %%% "scalacheck" % scalacheckVersion % Test ) ) -val monixJVM = monix.jvm -val monixJS = monix.js +val alleycatsJVM = alleycatsRetry.jvm +val alleycatsJS = alleycatsRetry.js val docs = project .in(file("modules/docs")) - .dependsOn(coreJVM, catsEffectJVM, monixJVM) + .dependsOn(coreJVM, alleycatsJVM) .enablePlugins(MicrositesPlugin, BuildInfoPlugin) .settings(moduleSettings) .settings( @@ -125,6 +117,9 @@ val docs = project addCompilerPlugin( "org.typelevel" %% "kind-projector" % "0.11.0" cross CrossVersion.full ), + libraryDependencies ++= Seq( + "io.monix" %%% "monix" % "3.1.0" + ), crossScalaVersions := Nil, buildInfoPackage := "retry", publishArtifact := false, @@ -148,10 +143,8 @@ val root = project .aggregate( coreJVM, coreJS, - catsEffectJVM, - catsEffectJS, - monixJVM, - monixJS, + alleycatsJVM, + alleycatsJS, docs ) .settings(commonSettings) diff --git a/modules/core/jvm/src/main/scala/retry/Sleep.scala b/modules/alleycats/jvm/src/main/scala/retry/alleycats/instances.scala similarity index 82% rename from modules/core/jvm/src/main/scala/retry/Sleep.scala rename to modules/alleycats/jvm/src/main/scala/retry/alleycats/instances.scala index f29a2dcb..3991ccd2 100644 --- a/modules/core/jvm/src/main/scala/retry/Sleep.scala +++ b/modules/alleycats/jvm/src/main/scala/retry/alleycats/instances.scala @@ -1,20 +1,12 @@ package retry - -import java.util.concurrent.Executors +package alleycats import cats.{Eval, Id} - import scala.concurrent.duration.FiniteDuration import scala.concurrent.{Future, Promise} -import java.util.concurrent.ThreadFactory - -trait Sleep[M[_]] { - def sleep(delay: FiniteDuration): M[Unit] -} - -object Sleep { - def apply[M[_]](implicit sleep: Sleep[M]): Sleep[M] = sleep +import java.util.concurrent.{ThreadFactory, Executors} +object instances { implicit val threadSleepId: Sleep[Id] = new Sleep[Id] { def sleep(delay: FiniteDuration): Id[Unit] = Thread.sleep(delay.toMillis) } diff --git a/modules/core/jvm/src/test/scala/retry/PackageObjectLazinessSpec.scala b/modules/alleycats/jvm/src/test/scala/retry/alleycats/PackageObjectLazinessSpec.scala similarity index 95% rename from modules/core/jvm/src/test/scala/retry/PackageObjectLazinessSpec.scala rename to modules/alleycats/jvm/src/test/scala/retry/alleycats/PackageObjectLazinessSpec.scala index fcafed87..2e231b23 100644 --- a/modules/core/jvm/src/test/scala/retry/PackageObjectLazinessSpec.scala +++ b/modules/alleycats/jvm/src/test/scala/retry/alleycats/PackageObjectLazinessSpec.scala @@ -1,7 +1,9 @@ -package retry +package retry.alleycats import cats.instances.future._ import org.scalatest.flatspec.AnyFlatSpec +import retry._ +import retry.alleycats.instances._ import scala.collection.mutable.ArrayBuffer import scala.concurrent.duration._ diff --git a/modules/alleycats/shared/src/main/scala/retry/alleycats/syntax.scala b/modules/alleycats/shared/src/main/scala/retry/alleycats/syntax.scala new file mode 100644 index 00000000..94fa6f07 --- /dev/null +++ b/modules/alleycats/shared/src/main/scala/retry/alleycats/syntax.scala @@ -0,0 +1,19 @@ +package retry +package alleycats + +import cats.{Id, Monad} + +object syntax { + def retrying[A]( + policy: RetryPolicy[Id], + wasSuccessful: A => Boolean, + onFailure: (A, RetryDetails) => Unit + )( + action: => A + )( + implicit + M: Monad[Id], + S: Sleep[Id] + ): A = + retryingM[A][Id](policy, wasSuccessful, onFailure)(action) +} diff --git a/modules/core/js/src/main/scala/retry/Sleep.scala b/modules/core/js/src/main/scala/retry/Sleep.scala deleted file mode 100644 index 0d761e46..00000000 --- a/modules/core/js/src/main/scala/retry/Sleep.scala +++ /dev/null @@ -1,11 +0,0 @@ -package retry - -import scala.concurrent.duration.FiniteDuration - -trait Sleep[M[_]] { - def sleep(delay: FiniteDuration): M[Unit] -} - -object Sleep { - def apply[M[_]](implicit sleep: Sleep[M]): Sleep[M] = sleep -} diff --git a/modules/cats-effect/shared/src/main/scala/retry/CatsEffect.scala b/modules/core/shared/src/main/scala/retry/Sleep.scala similarity index 64% rename from modules/cats-effect/shared/src/main/scala/retry/CatsEffect.scala rename to modules/core/shared/src/main/scala/retry/Sleep.scala index 6891874d..0dcfbd4b 100644 --- a/modules/cats-effect/shared/src/main/scala/retry/CatsEffect.scala +++ b/modules/core/shared/src/main/scala/retry/Sleep.scala @@ -1,14 +1,17 @@ package retry +import cats.effect.Timer import scala.concurrent.duration.FiniteDuration -import cats.effect.Timer +trait Sleep[M[_]] { + def sleep(delay: FiniteDuration): M[Unit] +} + +object Sleep { + def apply[M[_]](implicit sleep: Sleep[M]): Sleep[M] = sleep -trait CatsEffect { implicit def sleepUsingTimer[F[_]](implicit timer: Timer[F]): Sleep[F] = new Sleep[F] { def sleep(delay: FiniteDuration): F[Unit] = timer.sleep(delay) } } - -object CatsEffect extends CatsEffect diff --git a/modules/core/shared/src/main/scala/retry/package.scala b/modules/core/shared/src/main/scala/retry/package.scala index 9de54ce3..35497fbd 100644 --- a/modules/core/shared/src/main/scala/retry/package.scala +++ b/modules/core/shared/src/main/scala/retry/package.scala @@ -5,19 +5,6 @@ import cats.syntax.flatMap._ import scala.concurrent.duration.FiniteDuration package object retry { - def retrying[A]( - policy: RetryPolicy[Id], - wasSuccessful: A => Boolean, - onFailure: (A, RetryDetails) => Unit - )( - action: => A - )( - implicit - M: Monad[Id], - S: Sleep[Id] - ): A = - retryingM[A][Id](policy, wasSuccessful, onFailure)(action) - def retryingM[A] = new RetryingPartiallyApplied[A] private[retry] class RetryingPartiallyApplied[A] { diff --git a/modules/docs/src/main/mdoc/docs/combinators.md b/modules/docs/src/main/mdoc/docs/combinators.md index 388ceb49..aae4a17c 100644 --- a/modules/docs/src/main/mdoc/docs/combinators.md +++ b/modules/docs/src/main/mdoc/docs/combinators.md @@ -8,58 +8,12 @@ title: Combinators The library offers a few slightly different ways to wrap your operations with retries. -## `retrying` - -This is useful when you are not working in a monadic context. You have an -operation that simply returns a value of some type `A`, and you want to retry -until it returns a value that you are happy with. - -To use `retrying`, you pass in a predicate that decides whether you are -happy with the result or you want to retry. - -The API looks like this: - -```scala -def retrying[A](policy: RetryPolicy[Id], - wasSuccessful: A => Boolean, - onFailure: (A, RetryDetails) => Unit) - (action: => A): A -``` - -You need to pass in: - -* a retry policy -* a predicate that decides whether the operation was successful -* a failure handler, often used for logging -* the operation that you want to wrap with retries - -For example, let's keep rolling a die until we get a six. - -```scala mdoc:reset-class -import cats.Id -import retry._ -import scala.concurrent.duration._ - -val policy = RetryPolicies.constantDelay[Id](10.milliseconds) - -val predicate = (_: Int) == 6 - -def onFailure(failedValue: Int, details: RetryDetails): Unit = { - println(s"Rolled a $failedValue, retrying ...") -} - -val loadedDie = util.LoadedDie(2, 5, 4, 1, 3, 2, 6) - -retrying(policy, predicate, onFailure){ - loadedDie.roll() -} -``` - ## `retryingM` -This is similar to `retrying`, but is useful when you are working in an -arbitrary `Monad` that is not a `MonadError`. Your operation doesn't throw -errors, but you want to retry until it returns a value that you are happy with. +To use `retryingM`, you pass in a predicate that decides whether you are +happy with the result or you want to retry. +It is useful when you are working in an arbitrary `Monad` that is not a `MonadError`. +Your operation doesn't throw errors, but you want to retry until it returns a value that you are happy with. The API (modulo some type-inference trickery) looks like this: @@ -84,7 +38,6 @@ import cats.effect.{ContextShift, IO, Timer} import scala.concurrent.duration._ import scala.concurrent.ExecutionContext.global import retry._ -import retry.CatsEffect._ // We need an implicit cats.effect.Timer implicit val timer: Timer[IO] = IO.timer(global) diff --git a/modules/docs/src/main/mdoc/docs/index.md b/modules/docs/src/main/mdoc/docs/index.md index bdd7de7f..70cda140 100644 --- a/modules/docs/src/main/mdoc/docs/index.md +++ b/modules/docs/src/main/mdoc/docs/index.md @@ -35,10 +35,7 @@ println( s""" |``` |val catsRetryVersion = "${retry.BuildInfo.version.replaceFirst("\\+.*", "")}" - |libraryDependencies ++= Seq( - | "com.github.cb372" %% "cats-retry-core" % catsRetryVersion, - | "com.github.cb372" %% "cats-retry-cats-effect" % catsRetryVersion - |) + |libraryDependencies += "com.github.cb372" %% "cats-retry" % catsRetryVersion, |``` |""".stripMargin.trim ) @@ -98,8 +95,6 @@ import cats.effect.Timer import scala.concurrent.ExecutionContext.global implicit val timer: Timer[IO] = IO.timer(global) -// This is so we can use that Timer to perform delays between retries -import retry.CatsEffect._ val flakyRequestWithRetry: IO[String] = retryingOnAllErrors[String]( diff --git a/modules/docs/src/main/mdoc/docs/sleep.md b/modules/docs/src/main/mdoc/docs/sleep.md index ab8c4353..d2e8cc94 100644 --- a/modules/docs/src/main/mdoc/docs/sleep.md +++ b/modules/docs/src/main/mdoc/docs/sleep.md @@ -17,45 +17,76 @@ trait Sleep[M[_]] { } ``` -Out of the box, the core module provides instances for `Id` and `Future` that -simply do a `Thread.sleep(...)`. +Out of the box, the core module provides instances for any type with an implicit cats-effect +[`Timer`](https://typelevel.org/cats-effect/datatypes/timer.html) in scope. -Note: these instances are not provided if you are using Scala.js, as -`Thread.sleep` doesn't make any sense in JavaScript. +For example using `cats.effect.IO`: ```scala mdoc:silent:reset-class import retry.Sleep -import cats.Id +import cats.effect.{IO, Timer} import scala.concurrent.duration._ +import scala.concurrent.ExecutionContext.global -Sleep[Id].sleep(10.milliseconds) +implicit val timer: Timer[IO] = IO.timer(global) + +Sleep[IO].sleep(10.milliseconds) ``` +Or if you're using an abstract `F[_]`: + ```scala mdoc:silent:reset-class import retry.Sleep -import scala.concurrent.Future +import cats.effect.Timer import scala.concurrent.duration._ -import scala.concurrent.ExecutionContext.Implicits.global -Sleep[Future].sleep(10.milliseconds) +def sleepWell[F[_]: Timer] = + Sleep[F].sleep(10.milliseconds) +``` + + +Being able to inject your own `Sleep` instance can be handy in tests, as you +can mock it out to avoid slowing down your unit tests. + + +## alleycats-retry + +The `alleycats-retry` module provides instances for `cats.Id`, `cats.Eval` and `Future` that +simply do a `Thread.sleep(...)`. + +You can add it to your `build.sbt` as follows: +```scala mdoc:passthrough +println( + s""" + |``` + |libraryDependencies += "com.github.cb372" %% "alleycats-retry" % "${retry.BuildInfo.version.replaceFirst("\\+.*", "")}" + |``` + |""".stripMargin.trim +) ``` -The `cats-effect` module provides an instance that uses a cats-effect -[`Timer`](https://typelevel.org/cats-effect/datatypes/timer.html). +To use them, simply import `retry.alleycats.instances._`: ```scala mdoc:silent:reset-class import retry.Sleep -import cats.effect.{IO, Timer} +import retry.alleycats.instances._ +import scala.concurrent.Future import scala.concurrent.duration._ import scala.concurrent.ExecutionContext.Implicits.global -import retry.CatsEffect._ -implicit val timer: Timer[IO] = IO.timer(global) +Sleep[Future].sleep(10.milliseconds) +``` -Sleep[IO].sleep(10.milliseconds) +```scala mdoc:silent:reset-class +import retry.Sleep +import retry.alleycats.instances._ +import cats.Id +import scala.concurrent.duration._ + +Sleep[Id].sleep(10.milliseconds) ``` -The `monix` module provides an instance that calls `Task.sleep`. -Being able to inject your own `Sleep` instance can be handy in tests, as you -can mock it out to avoid slowing down your unit tests. +Note: these instances are not provided if you are using Scala.js, as +`Thread.sleep` doesn't make any sense in JavaScript. + diff --git a/modules/docs/src/main/mdoc/index.md b/modules/docs/src/main/mdoc/index.md index 66417072..63d3e7a2 100644 --- a/modules/docs/src/main/mdoc/index.md +++ b/modules/docs/src/main/mdoc/index.md @@ -5,8 +5,7 @@ title: "cats-retry" A library for retrying actions that can fail. -Designed to work with [cats](https://typelevel.org/cats/) and (optionally) -[cats-effect](https://typelevel.org/cats-effect/) or [Monix](https://monix.io/). +Designed to work with [cats](https://typelevel.org/cats/) and [cats-effect](https://typelevel.org/cats-effect/) or [Monix](https://monix.io/). Inspired by the [retry Haskell package](https://hackage.haskell.org/package/retry). diff --git a/modules/monix/shared/src/main/scala/retry/Monix.scala b/modules/monix/shared/src/main/scala/retry/Monix.scala deleted file mode 100644 index a4f8c612..00000000 --- a/modules/monix/shared/src/main/scala/retry/Monix.scala +++ /dev/null @@ -1,14 +0,0 @@ -package retry - -import monix.eval.Task - -import scala.concurrent.duration.FiniteDuration - -trait Monix { - implicit val taskSleep: Sleep[Task] = new Sleep[Task] { - def sleep(delay: FiniteDuration): Task[Unit] = - Task.sleep(delay) - } -} - -object Monix extends Monix diff --git a/modules/monix/shared/src/test/scala/retry/MonixSpec.scala b/modules/monix/shared/src/test/scala/retry/MonixSpec.scala deleted file mode 100644 index 78395f90..00000000 --- a/modules/monix/shared/src/test/scala/retry/MonixSpec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package retry - -import retry.Monix._ - -import org.scalatest.flatspec.AnyFlatSpec -import monix.eval.Task -import monix.execution.schedulers.TestScheduler - -import scala.collection.mutable.ArrayBuffer -import scala.concurrent.duration._ -import scala.util.Success - -class MonixSpec extends AnyFlatSpec { - behavior of "retryingM" - - it should "retry until the action succeeds" in new TestContext { - val policy = RetryPolicies.constantDelay[Task](10.second) - - val finalResult = retryingM[Int][Task]( - policy, - _.toInt > 3, - onError - ) { - Task { - attempts = attempts + 1 - attempts - } - } - - val scheduler = TestScheduler() - val f = finalResult.runToFuture(scheduler) - - scheduler.tick(30.seconds) - - assert(f.value == Some(Success(4))) - assert(attempts == 4) - assert(errors.toList == List(1, 2, 3)) - assert(delays.toList == List(10.second, 10.second, 10.second)) - assert(!gaveUp) - } - - private class TestContext { - var attempts = 0 - val errors = ArrayBuffer.empty[Int] - val delays = ArrayBuffer.empty[FiniteDuration] - var gaveUp = false - - def onError(error: Int, details: RetryDetails): Task[Unit] = { - errors.append(error) - details match { - case RetryDetails.WillDelayAndRetry(delay, _, _) => delays.append(delay) - case RetryDetails.GivingUp(_, _) => gaveUp = true - } - Task(()) - } - } -}