Skip to content

feat: RetrySettings #32687

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 2, 2025
Merged

feat: RetrySettings #32687

merged 10 commits into from
Apr 2, 2025

Conversation

aludwiko
Copy link
Contributor

I would like to discuss the shape of things and names, before I add tests, docs, etc.


object RetrySettings {

case class RetrySettingsBuilder(attempts: Int) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More aligned with other Akka library settings: Skip the builder and instead have factory create/apply methods with overloads. If there is ambiguity, we can have factory methods with more clear names for those (like RetrySettings.withFixedDelay(times, delay) perhaps).

The concrete RetrySettings then has the with methods to allow changing details after the construction.

Also: no case classes here unless internal API/hidden from users, highly problematic for bin comp evolvability with the copy method and traits it automatically extends.

case class DynamicRetrySettings(
attempts: Int,
delayFunction: Int => Option[FiniteDuration],
shouldRetry: Throwable => Boolean = _ => true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still hope for this, or something like it, to be the only implementation and that we can have a single concrete RetrySettings class and avoid having an ADT.

The only value the ADT brings as far as I can see is making it possible to inspect (lambdas would hide the backoff) and possibly change for example just the minBackoff after the fact, but I think we can live without those aspects.

For debuggability/logging we can make the predefined delay functions (exponential backoff, fixed delay, not sure there is anything more) concrete classes with good toString and concrete fields for parameters etc, rather than inline lambdas, while still keeping it completely generic here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, fair point, not sure about the last part:
For debuggability/logging we can make the predefined delay functions (exponential backoff, fixed delay, not sure there is anything more) concrete classes with good toString and concrete fields for parameters etc, rather than inline lambdas, while still keeping it completely generic here.

Do you think about sth like:

def apply(
      attempts: Int,
      minBackoff: FiniteDuration,
      maxBackoff: FiniteDuration,
      randomFactor: Double): RetrySettings = {
    new RetrySettings(
      attempts,
      attempted => Some(calculateExponentialBackoffDelay(attempted, minBackoff, maxBackoff, randomFactor))) {
      override def toString: String = {
        s"RetrySettings(attempts=$attempts, minBackoff=$minBackoff, maxBackoff=$maxBackoff, randomFactor=$randomFactor)"
      }
    }
  }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost, not subclassing the settings but having a concrete function class (could ofc be inlined as well):

class ExponentialBackoffFunction[R](min, max, etc) extends Function0[Future[R]] {
  def apply(): Future[R] = ... actual backoff ...
  override def toString = "ExponentialBackoff(min, max, etc)"
}

And then a good toString on RetrySettings including the delayFunction

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aaaa, ok, let me try this out

@@ -251,6 +249,21 @@ trait RetrySupport {
delayFunction: Int => Option[FiniteDuration])(implicit ec: ExecutionContext, scheduler: Scheduler): Future[T] = {
RetrySupport.retry(attempt, attempts, delayFunction, attempted = 0, shouldRetry)
}

def retry[T](attempt: () => Future[T], retrySettings: RetrySettings)(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe: Feels natural that it'd be a separate parameter list in Scala, for this kind of usage:

retry(settings) { () => do it }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I flipped the order, but it's not consistent with existing methods.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's a good argument against, in streams which are newer than the APIs here we have a few that are more like my suggestion here,

def withBackoff[T](settings: RestartSettings)(sourceFactory: () => Source[T, _]): Source[T, NotUsed] =

Look at that btw, another case of pre-existing restart settings :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's ok to flip the ordering for this specific case even if now aligned with the other methods.

It's a much better API for Scala (like in streams)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, done

@aludwiko aludwiko changed the title feat: RetrySettings builder feat: RetrySettings Mar 28, 2025
// Random factor can scale slightly with attempts to add more jitter
val randomFactor: Double = 0.1 + (attempts * 0.05).min(1.0) // cap at 1.0

apply(attempts, minBackoff, maxBackoff, randomFactor)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this is a good idea for a default behavior

s"RetrySettings(attempts=$attempts, delayFunction=$delayFunction)"
}

def withDelay(delayFunction: Int => Option[FiniteDuration]): RetrySettings = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should expose exponential backoff as a separate and more direct:

def withExponentialBackoff(minBackoff: FiniteDuration, maxBackoff: FiniteDuration, randomFactor: Double): RetrySettings

Then make ExponentialBackoffFunction internal.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added, but keep in mind that we have a sort of creation dualism here:

RetrySettings(attempts, minBackoff, maxBackoff, randomFactor)

or

RetrySettings(attempts). withExponentialBackoff(minBackoff, maxBackoff, randomFactor)

maybe that's perfectly fine, but just wanted to align with the existing Akka conventions.

}
}

final class FixedDelayFunction(delay: FiniteDuration) extends Function1[Int, Option[FiniteDuration]] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is needed. For this simple case the ordinary withDelay function is enough.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the record it was probably added because I insisted on a single RetrySettings class over an ADT of different retry settings, and to regain some debuggability by having concrete function impls with toStrings that would help get something useful out when logging/printing the retry settings (compared to an opaque lambda).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anyhow, if we keep the functions they should be internal

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's private now

@aludwiko aludwiko marked this pull request as ready for review April 1, 2025 11:56
@aludwiko aludwiko requested review from patriknw and johanandren April 1, 2025 11:56
import scala.concurrent.duration.FiniteDuration
import scala.jdk.DurationConverters.JavaDurationOps

final case class RetrySettings(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must not be a case class, for bincomp evolvability, we usually do that with an internal/private handcoded copy, see here for example

We likely will want the constructor private as well, and have it created through factories, because if not we will have to add chains of constructors calling each other every time we add a field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh I always forget about bincomp


}

private[akka] final case class ExponentialBackoffFunction(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these could be private inside the RestartSettings to be properly hidden from Java, if not they need /** INTERNAL API */ @InternalApi

retrySettings.delayFunction,
attempted = 0,
retrySettings.shouldRetry)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember, do we have something clever that will get classic scheduler from a typed actor system? If not we'd want a method with implicit ClassicActorSystemProvider so it can be used for typed as well (can probably not be an overload, but just having the provider version seems more convenient anyway).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a method with the implicit ClassicActorSystemProvider was added to Patterns, here I don't see any existing method with that, do you think it's worth adding it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't it won't be easy to use it from Scala based Akka typed apps, so better have that than the existing one (note that there is one classic Scheduler and one typed).

maxRetries: Int,
minBackoff: FiniteDuration,
maxBackoff: FiniteDuration,
randomFactor: Double): RetrySettings =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keep in mind that we have a sort of creation dualism here

yeah, seems unnecessary to have both factory methods and builder methods. Then we are back to zillions of overloaded methods again. I'd remove the factory methods (apply/create) and only leave the one with single parameter maxRetries: Int

Then used as:

RetrySettings(5).with...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the one with config can also be kept

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

* @param shouldRetry function to determine if a failure should be retried
* @return Updated settings
*/
def withJavaDecider(shouldRetry: java.util.function.Function[Throwable, Boolean]): RetrySettings =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this must be java.lang.Boolean for Java because it's a generic type

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok


object RetrySettings {

private[akka] final class ExponentialBackoffFunction(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should still have the full internal api markers, because it's public for java

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

Copy link
Contributor

@patriknw patriknw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Contributor

@johanandren johanandren left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@johanandren johanandren merged commit 4691401 into main Apr 2, 2025
5 checks passed
@johanandren johanandren deleted the retry-settings branch April 2, 2025 17:21
@johanandren johanandren added this to the 2.10.3 milestone Apr 2, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants