Skip to content

Proposal: Retries #4396

@biochimia

Description

@biochimia

This is a proposal to introduce retry functionality in Cats Effect. The intent here is to focus the discussion on the interface, before moving on to implementation in a dedicated pull request. The ideas in this proposal have been experimented with in a free-standing implementation available at https://github.com/biochimia/scala-retry.

Proposal

The Error Type

type E = Throwable

Retries are offered for monads with Throwable errors.

While it would be possible to abstract the proposal to work with a generic MonadError[A, E], some parts of the proposal prescribe the error type:

  • the use of Resource to manage the lifecycle of a retryable operation;
  • the ability to communicate an out-of-retries condition, while still wrapping the observed error, which is done via OutOfRetriesException.

Retry Policy

trait RetryPolicy[F[_]] {
  def shouldRetry: Boolean
  def redeem(e: Throwable): F[Unit]
}

The retry policy bears some relation to a plain Iterator[F[Unit]], but is given a dedicated interface for expressivity and flexibility. Many interesting retry policies can be expressed that ignore the redeemed error.

This retry policy is not primarily responsible for determining which errors to retry on, we’ll get back to that when we look at the retry loop. The policy does decide whether to retry an error that was separately deemed to be retryable.

Retryable errors are redeemed effectfully. This allows retry policies to implement various behaviors such as logging of errors, and back off strategies.

Lifecycle of a Retry Policy

import cats.effect.kernel.MonadCancelThrow
import cats.effect.kernel.Resource

type F
implicit val F: MonadCancelThrow[F]
implicit val retryPolicy: Resource[F, RetryPolicy[F]]

The retry policy is meant to be instantiated on each use, so that it can keep track of failed attempts for each use. To this end, policies are made available wrapped in a Resource that manages their lifetime.

The use of Resource gives retry policy implementations additional hooks to customize behavior, and allows policies to maintain state–if desired.

The OutOfRetriesException

final case class OutOfRetriesException(cause: Throwable) extends Exception

OutOfRetriesException is used to wrap retryable errors when the retry policy precludes further attempts.

With this, a retryable operation can have one of the following outcomes:

  • it may succeed;
  • it may fail with a non-retryable error, which gets propagated as is;
  • it may fail with a retryable error, wrapped by OutOfRetriesException;
  • it may be canceled.

Determining Retryable Errors

PartialFunction[Throwable, ?]

The interface prescribes the use of partial functions to determine whether an error is retryable. This is aligned with the use of PartialFunction to deal with errors throughout the interface of MonadError: adaptError, onError, recover, and recoverWith.

Proposed Interface

import scala.reflect.{ClassTag, classTag}

trait MonadCancelThrow[F] {

  def retry[A](fa: F[A])(pf: PartialFunction[Throwable, Unit])(implicit
      P: Resource[F, RetryPolicy[F]]
  ): F[A] =
    retryWith(fa)(pf.andThen(_ => F.unit))

  def retryNarrow[A, EE <: Throwable: ClassTag](fa: F[A])(implicit
      P: Resource[F, RetryPolicy[F]]
  ): F[A] =
    retryWith(fa) { case e if classTag[EE].runtimeClass.isInstance(e) => () }

  def retryWith[A](fa: F[A])(pf: PartialFunction[Throwable, F[Unit]])(implicit
      P: Resource[F, RetryPolicy[F]]
  ): F[A]

}

The methods retry and retryWith closely align with the existing recover and recoverWith methods. They allow for the identification of retryable errors, and also allow authors to introduce custom error handling, outside the retry policy.

retryNarrow provides a shorthand notation to retry errors of a single exception class. This aligns with attemptNarrow.

Syntax

fa.retry {
  case e: SomeRetryableError if someCondition(e) =>
    //
    ()
}
fa.retryNarrow[SomeRetryableError]
fa.retryWith {
  case e: SomeRetryableError if someCondition(e) =>
    //
    F.unit
}

The Retry Loop

type F[A]
implicit val F: MonadCancelThrow[F]
implicit val P: Resource[F, RetryPolicy[F]]

def retryWith[A](fa: F[A])(
    pf: PartialFunction [Throwable, Unit]
)(implicit P: Resource[F, RetryPolicy[F]]): F[A] =
  P.use { policy =>
    def attempt: F[A] =
      fa.recoverWith {
        case error if pf.isDefinedAt(error) =>
          if (policy.shouldRetry)
            F.flatMap(pf(error)) { _ =>
               F.flatMap(policy.redeem(error)) { _ =>
                 attempt
               }
            }
          else
            F.raiseError(OutOfRetriesException(error))
      }

    attempt
  }

Prior Art

(This section does not intend to be exhaustive, but still reference previous attempts to introduce retry functionality in Cats Effect.)

Retry functionality is offered in cats-retry, the inclusion of this functionality in Cats Effect has been proposed before in a couple of issues and in concrete pull requests:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions