-
Notifications
You must be signed in to change notification settings - Fork 557
Description
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: