com.permutive.pubsub.http.util.RefreshableEffect.scala Maven / Gradle / Ivy
package com.permutive.pubsub.http.util
import cats.MonadError
import cats.effect.concurrent.Ref
import cats.effect.syntax.concurrent._
import cats.effect.{CancelToken, Concurrent, Resource, Sync, Timer}
import cats.syntax.applicativeError._
import cats.syntax.flatMap._
import cats.syntax.functor._
import fs2.Stream
import scala.concurrent.duration.FiniteDuration
/**
* Represents a value of type `A` with effects in `F` which is refreshed.
*
* Refreshing can be cancelled by evaluating `cancelToken`.
*
* Implementation is backed by a `cats-effect` `Ref` so evaluating the value is fast.
*
*/
final class RefreshableEffect[F[_], A] private (val value: F[A], val cancelToken: CancelToken[F])
object RefreshableEffect {
/**
* Create a refreshable effect which exposes the result of `refresh`, retries
* if refreshing the value fails.
*
* @param refreshInterval how frequently to refresh the value
* @param onRefreshError what to do if refreshing the value fails, error is always rethrown
* @param onRefreshSuccess what to do when the value is successfully refresh, errors are ignored
* @param retryDelay duration of delay before the first retry
* @param retryNextDelay what value to delay before the next retry
* @param retryMaxAttempts how many attempts to make before failing with last error
* @param onRetriesExhausted what to do if retrying to refresh the value fails, up to user handle failing their service
*/
def createRetryResource[F[_]: Concurrent: Timer, A](
refresh: F[A],
refreshInterval: FiniteDuration,
onRefreshSuccess: F[Unit],
onRefreshError: PartialFunction[Throwable, F[Unit]],
retryDelay: FiniteDuration,
retryNextDelay: FiniteDuration => FiniteDuration,
retryMaxAttempts: Int,
onRetriesExhausted: PartialFunction[Throwable, F[Unit]],
): Resource[F, RefreshableEffect[F, A]] = {
val updateRef: Ref[F, A] => F[Unit] =
ref =>
retry(
updateUnhandled(refresh, ref, onRefreshSuccess).onError(onRefreshError),
retryDelay,
retryNextDelay,
retryMaxAttempts,
).onError(onRetriesExhausted).attempt.void // Swallow errors entirely, retry will loop around again
Resource.make(createAndSchedule(refresh, refreshInterval, updateRef))(_.cancelToken)
}
private def createAndSchedule[F[_]: Concurrent: Timer, A](
refresh: F[A],
refreshInterval: FiniteDuration,
updateRef: Ref[F, A] => F[Unit],
): F[RefreshableEffect[F, A]] =
for {
initial <- refresh
ref <- Ref.of(initial)
fiber <- scheduleRefresh(updateRef(ref), refreshInterval).start
} yield new RefreshableEffect[F, A](ref.get, fiber.cancel)
private def scheduleRefresh[F[_]: Sync: Timer, A](
refreshEffect: F[Unit],
refreshInterval: FiniteDuration,
): F[Unit] =
Stream
.fixedRate(refreshInterval) // Same frequency regardless of time to evaluate refresh
.evalMap(_ => refreshEffect)
.compile
.drain
private def updateUnhandled[F[_], A](refresh: F[A], ref: Ref[F, A], onRefreshSuccess: F[Unit])(
implicit ME: MonadError[F, Throwable]
): F[Unit] =
for {
refreshed <- refresh
_ <- ref.set(refreshed)
_ <- onRefreshSuccess.attempt // Ignore exceptions in success callback
} yield ()
private def retry[F[_]: Sync: Timer, A](
refreshEffect: F[Unit],
retryDelay: FiniteDuration,
retryNextDelay: FiniteDuration => FiniteDuration,
retryMaxAttempts: Int,
): F[Unit] =
if (retryMaxAttempts < 1)
refreshEffect
else
Stream
.retry(
refreshEffect,
retryDelay,
retryNextDelay,
retryMaxAttempts,
)
.compile
.lastOrError
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy