// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// How much randomness goes into a delay.
///
/// A retry with no jitter makes every client that failed together try again
/// together, which is how one outage becomes a second one. The three named
/// strategies are the ones AWS's "Exponential Backoff and Jitter" measures:
///
/// | | |
/// |:--:|:--|
/// | `Rigid` | the computed delay, unchanged. Reproducible, and it synchronises clients |
/// | `Full` | uniform over `[0, cap]`. The one that spreads clients furthest, and what gRPC sleeps |
/// | `Equal` | half the cap plus uniform over `[0, cap/2]`. Keeps a floor under the wait |
/// | `Decorrelated` | uniform over `[base, previous × 3]`. Grows from where it last landed rather than from the attempt number |
pub(all) enum Jitter {
Rigid
Full
Equal
Decorrelated
} derive(Eq, Debug)
///|
pub extend Jitter with Debug::{to_repr}
///|
pub extend Jitter with Eq::{not_equal, equal}
///|
/// When to try again, and how long to wait first.
///
/// The preset is [`backoff`]. Build another with [`Backoff::new`], or update one in
/// place with `{ ..backoff, attempts: 5 }`.
pub(all) struct Backoff {
base : @moondate.Span
factor : Double
cap : @moondate.Span
attempts : Int
jitter : Jitter
} derive(Eq, Debug)
///|
pub extend Backoff with Debug::{to_repr}
///|
pub extend Backoff with Eq::{not_equal, equal}
///|
/// The preset: three attempts, a hundred milliseconds doubling to a one-second
/// ceiling, spread by full jitter.
///
/// Those are gRPC's documented defaults for a retry policy — `maxAttempts` 3,
/// `initialBackoff` 0.1s, `maxBackoff` 1s, `backoffMultiplier` 2 — and gRPC sleeps
/// a value drawn uniformly from `[0, cap]`, which is full jitter.
pub let backoff : Backoff = {
base: @moondate.Span::new(millis=100L),
factor: 2.0,
cap: @moondate.Span::new(seconds=1L),
attempts: 3,
jitter: Full,
}
///|
/// A backoff by name, every knob with the preset's value.
pub fn Backoff::new(
base? : @moondate.Span = backoff.base,
factor? : Double = backoff.factor,
cap? : @moondate.Span = backoff.cap,
attempts? : Int = backoff.attempts,
jitter? : Jitter = backoff.jitter,
) -> Backoff {
{ base, factor, cap, attempts, jitter, }
}
///|
/// Whether a call that has made `made` attempts may make another.
///
/// The first attempt is 1, so a policy of three attempts allows two retries.
pub fn Backoff::more(self : Backoff, made : Int) -> Bool {
made < self.attempts && made >= 0
}
///|
/// The undelayed ceiling before the `retry`-th retry, the first retry being 1:
/// `min(base × factor^(retry-1), cap)`.
///
/// This is what a jitter-free client sleeps and what every other strategy draws
/// within, so it is public: a caller comparing this library against a policy
/// written elsewhere compares this.
pub fn Backoff::ceiling(self : Backoff, retry : Int) -> @moondate.Span {
if retry < 1 {
return @moondate.Span::new()
}
let mut nanos = self.base.nanos.to_double()
for i = 1; i < retry; i = i + 1 {
nanos = nanos * self.factor
if nanos >= self.cap.nanos.to_double() {
return self.cap
}
}
if nanos >= self.cap.nanos.to_double() {
self.cap
} else {
{ nanos: nanos.to_int64(), }
}
}
///|
/// How long to wait before the `retry`-th retry.
///
/// `random` is a value in `[0, 1)` that the caller draws; passing the same one
/// twice gives the same delay twice, which is what makes a retry sequence testable.
/// `previous` is what the last delay turned out to be, which only `Decorrelated`
/// reads — it grows from where it landed rather than from the attempt number.
pub fn Backoff::delay(
self : Backoff,
retry : Int,
random? : Double = 0.0,
previous? : @moondate.Span = @moondate.Span::new(),
) -> @moondate.Span {
let ceiling = self.ceiling(retry)
match self.jitter {
Rigid => ceiling
Full => { nanos: (ceiling.nanos.to_double() * clamp(random)).to_int64(), }
Equal => {
let half = ceiling.nanos / 2L
{ nanos: half + (half.to_double() * clamp(random)).to_int64(), }
}
Decorrelated => {
let low = self.base.nanos
let high = if previous.nanos <= 0L {
self.base.nanos * 3L
} else {
previous.nanos * 3L
}
let top = if high > self.cap.nanos { self.cap.nanos } else { high }
if top <= low {
{ nanos: low, }
} else {
{ nanos: low + ((top - low).to_double() * clamp(random)).to_int64(), }
}
}
}
}
///|
/// Every delay a run of retries would wait, for a caller that wants the whole
/// sequence rather than one step — a test, or a log line explaining a plan.
///
/// `randoms` supplies the draws; when it runs short the remaining steps use zero,
/// which is the low end of whatever strategy is in force.
pub fn Backoff::schedule(
self : Backoff,
randoms? : ArrayView[Double] = [][:],
) -> Array[@moondate.Span] {
let out : Array[@moondate.Span] = []
let mut previous = @moondate.Span::new()
for retry = 1; retry < self.attempts; retry = retry + 1 {
let random = if retry - 1 < randoms.length() {
randoms[retry - 1]
} else {
0.0
}
let d = self.delay(retry, random~, previous~)
out.push(d)
previous = d
}
out
}
///|
/// A draw held to `[0, 1)`, so a caller passing something outside it gets a delay
/// inside the policy rather than one outside it.
fn clamp(v : Double) -> Double {
if v < 0.0 {
0.0
} else if v >= 1.0 {
0.999999999
} else {
v
}
}