Retry policies
A retry policy is a function that takes in a RetryStatus
and returns a
PolicyDecision
in a monoidal context:
case class RetryPolicy[M[_]](decideNextRetry: RetryStatus => M[PolicyDecision])
The policy decision can be one of two things:
- we should delay for some amount of time, possibly zero, and then retry
- we should stop retrying and give up
Built-in policies
There are a number of policies available in retry.RetryPolicies
, including:
constantDelay
(retry forever, with a fixed delay between retries)limitRetries
(retry up to N times, with no delay between retries)exponentialBackoff
(double the delay after each retry)fibonacciBackoff
(delay(n) = (delay(n - 2) + delay(n - 1)
)fullJitter
(randomised exponential backoff)
Policy transformers
There are also a few combinators to transform policies, including:
capDelay
(set an upper bound on the delay between retries)limitRetriesByDelay
(give up when the delay between retries reaches a certain limit)limitRetriesByCumulativeDelay
(give up when the total delay reaches a certain limit)
Composing policies
cats-retry
offers several ways of composing policies together.
join
First up is the join
operation, it has the following semantics:
- If either of the policies wants to give up, the combined policy gives up.
- If both policies want to delay and retry, the longer of the two delays is chosen.
This way of combining policies implies:
- That combining two identical policies result in this same policy.
- That the order you combine policies doesn’t affect the resulted policy.
That is to say, join
is associative, commutative and idempotent, which makes it a Semilattice
.
Furthermore, it also forms a BoundedSemilattice
, as there is also a neutral element for combining with join
, which is a simple policy that retries with no delay and never gives up.
This makes it very useful for combining two policies with a lower bounded delay.
For an example of composing policies like this, we can use join
to create a policy that retries up to 5 times, starting with a 10 ms delay and increasing
exponentially:
import cats._
import cats.effect.IO
import cats.implicits._
import scala.concurrent.duration._
import retry.RetryPolicy
import retry.RetryPolicies._
val policy = limitRetries[IO](5) join exponentialBackoff[IO](10.milliseconds)
meet
The next operation is meet
, it is the dual of join
and has the following semantics:
- If both of the policies wants to give up, the combined policy gives up.
- If both policies want to delay and retry, the shorter of the two delays is chosen.
Just like join
, meet
is also associative, commutative and idempotent, which implies:
- That combining two identical policies result in this same policy.
- That the order you combine policies doesn’t affect the resulted policy.
You can use meet
to compose policies where you want an upper bound on the delay.
As an example the capDelay
combinator is implemented using meet
:
def capDelay[M[_]: Applicative](cap: FiniteDuration, policy: RetryPolicy[M]): RetryPolicy[M] =
policy meet constantDelay[M](cap)
val neverAbove5Minutes = capDelay(5.minutes, exponentialBackoff[IO](10.milliseconds))
Retry policies form a distributive lattice, as meet
and join
both distribute over each other.
As we feel that the join
operation is more common,
we use it as the canonical BoundedSemilattice
instance found in the companion object.
This means you can use it with the standard Cats semigroup syntax like this:
limitRetries[IO](5) |+| constantDelay[IO](100.milliseconds)
followedBy
There is also an operator followedBy
to sequentially compose policies, i.e. if the first one wants to give up, use the second one.
As an example, we can retry with a 100ms delay 5 times and then retry every minute:
val retry5times100millis = constantDelay[IO](100.millis) |+| limitRetries[IO](5)
// retry5times100millis: RetryPolicy[IO] = RetryPolicy(
// decideNextRetry = retry.RetryPolicy$$Lambda$12416/1489436889@366ce56
// )
retry5times100millis.followedBy(constantDelay[IO](1.minute))
// res1: RetryPolicy[IO] = RetryPolicy(
// decideNextRetry = retry.RetryPolicy$$Lambda$12420/143800397@5ad38446
// )
followedBy
is an associative operation and forms a Monoid
with a policy that always gives up as its identity:
// This is equal to just calling constantDelay[IO](200.millis)
constantDelay[IO](200.millis).followedBy(alwaysGiveUp)
Currently we don’t provide such an instance, as it would clash with the BoundedSemilattice
instance described earlier.
mapDelay, flatMapDelay
The mapDelay
and flatMapDelay
operators work just like map
and flatMap
, but allow you to map on the FiniteDuration
that represents the retry delay.
As a simple example, it allows us to add a specific delay of 10ms on top of an existing policy:
fibonacciBackoff[IO](200.millis).mapDelay(_ + 10.millis)
// res3: RetryPolicy[IO] = RetryPolicy(
// decideNextRetry = retry.RetryPolicy$$Lambda$12426/736433689@6056ad4a
// )
Furthermore, flatMapDelay
also allows us to depend on certain effects to evaluate how to modify the delay:
def determineDelay: IO[FiniteDuration] = ???
fibonacciBackoff[IO](200.millis).flatMapDelay { currentDelay =>
if (currentDelay < 500.millis) currentDelay.pure[IO]
else determineDelay
}
// res4: RetryPolicy[IO] = RetryPolicy(
// decideNextRetry = retry.RetryPolicy$$Lambda$12428/1788193976@2bfdb154
// )
mapK
If you’ve defined a RetryPolicy[F]
, but you need a RetryPolicy
for another effect type G[_]
, you can use mapK
to convert from one to the other.
For example, you might have defined a custom RetryPolicy[cats.effect.IO]
and for another part of the app you might need a RetryPolicy[Kleisli[IO]]
:
import cats.effect.LiftIO
import cats.data.Kleisli
val customPolicy: RetryPolicy[IO] =
limitRetries[IO](5).join(constantDelay[IO](100.milliseconds))
// customPolicy: RetryPolicy[IO] = RetryPolicy(
// decideNextRetry = retry.RetryPolicy$$Lambda$12416/1489436889@1e5cce03
// )
customPolicy.mapK[Kleisli[IO, String, *]](LiftIO.liftK[Kleisli[IO, String, *]])
// res5: RetryPolicy[Kleisli[IO, String, γ$0$]] = RetryPolicy(
// decideNextRetry = retry.RetryPolicy$$Lambda$12430/214300656@4bb33575
// )
Writing your own policy
The easiest way to define a custom retry policy is to use RetryPolicy.lift
,
specifying the monad you need to work in:
import retry.{RetryPolicy, PolicyDecision}
import java.time.{LocalDate, DayOfWeek}
val onlyRetryOnTuesdays = RetryPolicy.lift[IO] { _ =>
if (LocalDate.now().getDayOfWeek() == DayOfWeek.TUESDAY) {
PolicyDecision.DelayAndRetry(delay = 100.milliseconds)
} else {
PolicyDecision.GiveUp
}
}
// onlyRetryOnTuesdays: RetryPolicy[IO] = RetryPolicy(
// decideNextRetry = retry.RetryPolicy$$$Lambda$12432/1484634964@3367c6d6
// )