Combinators

The library offers a few slightly different ways to wrap your operations with retries.

Cheat sheet

Combinator Context bound Handles
retryingOnFailures Temporal Failures
retryingOnErrors Temporal Errors
retryingOnFailuresAndErrors Temporal Failures and errors

More information on each combinator is provided below.

The context bound for all the combinators is Cats Effect Temporal, because we need the ability to sleep between retries. This implies that your effect monad needs to be Cats Effect IO or something similar.

Failures vs errors

We make a distinction between “failures” and “errors”, which deserves some explanation.

An action with type IO[HttpResponse], when executed, can result in one of three things:

  • Success, meaning it returned an HTTP response that we are happy with, e.g. a 200 response
  • Failure, meaning it returned an HTTP response but we are not happy with it, e.g. a 404 response
  • Error, meaning it raised an exception, e.g. using IO.raiseError(...)

cats-retry lets you choose whether you want to retry on failures, on errors, or both.

Unrecoverable errors

Some errors are worth retrying, while others are so serious that it’s not worth retrying.

For example, if an HTTP request failed with a 500 response, it’s probably worth retrying, but if the server responded with a 401 Unauthorized, it’s probably not. Retrying the same request would just result in another 401 response.

When an action raises an error, cats-retry lets you inspect the error and decide whether you want to retry or bail out. See the retryingOnErrors combinator for more details.

retryingOnFailures

To use retryingOnFailures, you pass in a value handler that decides whether you are happy with the result or you want to retry.

The API looks like this:

def retryingOnFailures[F[_]: Temporal, A](
    action: F[A]
)(
    policy: RetryPolicy[F],
    valueHandler: ValueHandler[F, A]
): F[Either[A, A]]

The inputs are:

  • the operation that you want to wrap with retries
  • a retry policy, which determines the maximum number of retries and how long to delay after each attempt
  • a handler that decides whether the operation was successful, and does any necessary logging

The return value is one of:

  • the successful value returned by the final attempt, wrapped in a Right(...) to indicate success
  • the failed value returned by the final attempt before giving up, wrapped in a Left(...) to indicate failure
  • an error raised in F, if either the action or the value handler raised an error

Semantics

Example

For example, let’s keep rolling a die until we get a six, using IO.

import cats.effect.IO
import cats.effect.unsafe.implicits.global
import retry.*

import scala.concurrent.duration.*

val loadedDie = util.LoadedDie(2, 5, 4, 1, 3, 2, 6)

val io = retryingOnFailures(loadedDie.roll)(
  policy = RetryPolicies.constantDelay(10.milliseconds),
  valueHandler = (value: Int, details: RetryDetails) =>
    value match
      case 6 =>
        IO.pure(HandlerDecision.Stop)   // successful result, stop retrying
      case failedValue =>
        IO(println(s"Rolled a $failedValue, retrying ..."))
          .as(HandlerDecision.Continue) // keep trying, as long as the retry policy allows it
)
io.unsafeRunSync()
// Rolled a 2, retrying ...
// Rolled a 5, retrying ...
// Rolled a 4, retrying ...
// Rolled a 1, retrying ...
// Rolled a 3, retrying ...
// Rolled a 2, retrying ...
// res0: Either[Int, Int] = Right(value = 6)

There is also a helper for lifting a predicate into a ValueHandler:

val io = retryingOnFailures(loadedDie.roll)(
  policy = RetryPolicies.constantDelay(10.milliseconds),
  valueHandler = ResultHandler.retryUntilSuccessful(_ == 6, log = ResultHandler.noop)
)
io.unsafeRunSync()
// res1: Either[Int, Int] = Right(value = 6)

retryingOnErrors

This is useful when you want to retry on some or all errors raised in your effect monad’s error channel.

To use retryingOnErrors, you pass in a handler that decides whether a given error is worth retrying.

The API looks like this:

def retryingOnErrors[F[_]: Temporal, A](
    action: F[A]
)(
    policy: RetryPolicy[F],
    errorHandler: ErrorHandler[F, A]
): F[A]

The inputs are:

  • the operation that you want to wrap with retries
  • a retry policy, which determines the maximum number of retries and how long to delay after each attempt
  • a handler that decides whether a given error is worth retrying, and does any necessary logging

The return value is either:

  • the value returned by the action, or
  • an error raised in F, if
    • the action raised an error that the error handler judged to be unrecoverable
    • the action repeatedly raised errors and we ran out of retries
    • the error handler raised an error

Semantics

Example

For example, let’s make a request for a cat gif using our flaky HTTP client, retrying only if we get an IOException.

import java.io.IOException

val httpClient = util.FlakyHttpClient()
val flakyRequest: IO[String] = httpClient.getCatGif

val io = retryingOnErrors(flakyRequest)(
  policy = RetryPolicies.limitRetries(5),
  errorHandler = (e: Throwable, retryDetails: RetryDetails) =>
    e match
      case _: IOException =>
        IO.pure(HandlerDecision.Continue) // worth retrying
      case _ =>
        IO.pure(HandlerDecision.Stop)     // not worth retrying
)
io.unsafeRunSync()
// res2: String = "cute cat gets sleepy and falls asleep"

There is also a helper for the common case where you want to retry on all errors:

val io = retryingOnErrors(flakyRequest)(
  policy = RetryPolicies.limitRetries(5),
  errorHandler = ResultHandler.retryOnAllErrors(log = ResultHandler.noop)
)
io.unsafeRunSync()
// res3: String = "cute cat gets sleepy and falls asleep"

retryingOnFailuresAndErrors

This is a combination of retryingOnFailures and retryingOnErrors. It allows you to specify failure conditions for both the results and errors that can occur.

To use retryingOnFailuresAndErrors, you need to pass in a handler that decides whether a given result is a success, a failure, an error that’s worth retrying, or an unrecoverable error.

The API looks like this:

def retryingOnFailuresAndErrors[F[_]: Temporal, A](
    action: F[A]
)(
    policy: RetryPolicy[F],
    errorOrValueHandler: ErrorOrValueHandler[F, A]
): F[Either[A, A]]

The inputs are:

  • the operation that you want to wrap with retries
  • a retry policy, which determines the maximum number of retries and how long to delay after each attempt
  • a handler that inspects the action’s return value or error and decides whether to retry, and does any necessary logging

The return value is one of:

  • the successful value returned by the final attempt, wrapped in a Right(...) to indicate success
  • the failed value returned by the final attempt before giving up, wrapped in a Left(...) to indicate failure
  • an error raised in F, if
    • the action raised an error that the error handler judged to be unrecoverable
    • the action repeatedly raised errors and we ran out of retries
    • the error handler raised an error

ErrorOrValueHandler

Note that the behaviour of the ErrorOrValueHandler is quite subtle.

The interpretation of its decision (a HandlerDecision) depends on whether the action returned a value or raised an error

If the action returned a value, the handler will be given a Right(someValue) to inspect.

  • If the handler decides the result of the action was successful, it should return Stop, meaning there is no need to keep retrying. cats-retry will stop retrying, and return the successful result.
  • On the other hand, if the handler decides that action was not successful, it should return Continue, meaning the action has not succeeded yet so we should keep retrying. cats-retry will then consult the retry policy. If the policy agrees to keep retrying, cats-retry will do so. Otherwise it will return the failed value.

If the action raised an error, the handler will be given a Left(someThrowable) to inspect.

  • If the handler decides the error is worth retrying, it should return Continue. cats-retry will then consult the retry policy. If the policy agrees to keep retrying, cats-retry will do so. Otherwise it will re-raise the error.
  • If the handler decides the error is not worth retrying, it should return Stop. cats-retry will re-raise the error.

Semantics

Example

For example, let’s make a request to an API to retrieve details for a record, which we will only retry if:

  • A timeout exception occurs
  • The record’s details are incomplete pending future operations
import java.util.concurrent.TimeoutException

val httpClient = util.FlakyHttpClient()
val flakyRequest: IO[String] = httpClient.getRecordDetails("foo")

val errorOrValueHandler: ErrorOrValueHandler[IO, String] =
  (result: Either[Throwable, String], retryDetails: RetryDetails) =>
    result match
      case Left(_: TimeoutException) =>
        IO.pure(HandlerDecision.Continue) // worth retrying
      case Left(_) =>
        IO.pure(HandlerDecision.Stop)     // not worth retrying
      case Right("pending") =>
        IO.pure(HandlerDecision.Continue) // failure, retry
      case Right(_) =>
        IO.pure(HandlerDecision.Stop)     // success

val io = retryingOnFailuresAndErrors(flakyRequest)(
  policy = RetryPolicies.limitRetries(5),
  errorOrValueHandler = errorOrValueHandler
)
io.unsafeRunSync()
// res4: Either[String, String] = Right(value = "got some sweet details")

Syntactic sugar

The cats-retry API is also available as extension methods.

You need to opt into this using an import:

import retry.syntax.*

Examples:

// To retry until you get a value you like
loadedDie.roll.retryingOnFailures(
  policy = RetryPolicies.limitRetries(2),
  valueHandler = ResultHandler.retryUntilSuccessful(_ == 6, log = ResultHandler.noop)
)

val httpClient = util.FlakyHttpClient()

// To retry on some or all errors
httpClient.getCatGif.retryingOnErrors(
  policy = RetryPolicies.limitRetries(2),
  errorHandler = ResultHandler.retryOnAllErrors(log = ResultHandler.noop)
)

// To retry on failures and some or all errors
httpClient.getRecordDetails("foo").retryingOnFailuresAndErrors(
  policy = RetryPolicies.limitRetries(2),
  errorOrValueHandler = errorOrValueHandler
)