README
retry
Retries with exponential backoff and jitter, retry budgets and Retry-After hints, a circuit breaker, and token bucket and sliding window rate limiters for SSPUR.
The runners never call the system clock directly: they sleep and read the time through a small clock effect. run_now and run_hinted_now handle it with the real clock (sleep_ms, now_ms), and simulate and simulate_hinted with a virtual clock that only moves when the runner sleeps, so a test of a retry loop with a 30 second backoff finishes at once and gives the same delays every time. The jitter is pure (the builtin seeded rand), seeded from Policy.seed or from the clock at the start of the run. The circuit breaker and the limiters are pure state machines that take the current time as an argument, so they are deterministic too and fit any concurrency model.
An effect was chosen over passing sleep and now functions because a fake clock needs state that moves as the code sleeps, and a handler holds that state in one place, around any code, including operations that read the clock themselves.
Example
use retry.{FullJitter, simulate_hinted, exponential, breaker, default_breaker_config, call, Ran, Rejected}
type HttpError = Unavailable{retry_after_ms: Opt[Int]} | BadRequest
fn fetch(attempt: Int) -> Res[Str, HttpError] ! log
= do
log("attempt {attempt}")
if attempt == 1 then err(Unavailable{retry_after_ms: some(1500)}) else if attempt < 4 then err(Unavailable{retry_after_ms: none}) else ok("payload")
fn hint(e: HttpError) -> Opt[Int]
= match e
| Unavailable{retry_after_ms} => retry_after_ms
| BadRequest => none
fn main() -> Unit ! log
= do
p = exponential(200, 5) with jitter := FullJitter, seed := some(7), budget_ms := some(10000)
(o, end) = simulate_hinted(0, p, fetch, e => e != BadRequest, hint)
log("{o.result} after {o.attempts} attempts, slept {o.delays} = {o.waited_ms} ms, {o.stop}, virtual clock at {end}")
b = breaker(default_breaker_config() with failure_threshold := 2, cooldown_ms := 1000)
(r1, b1) = call(b, 0, () => err("down"))
(r2, b2) = call(b1, 10, () => err("down"))
(r3, _) = call(b2, 20, () => ok("up"))
log("{r1} {r2} {r3} {b2.state}")
Output (the same natively and with --interp; the virtual clock means nothing waits):
attempt 1
attempt 2
attempt 3
attempt 4
ok("payload") after 4 attempts, slept [1500, 6, 721] = 2227 ms, Succeeded, virtual clock at 2227
Ran{result: err("down")} Ran{result: err("down")} Rejected{wait_ms: 990} Open{until_ms: 1010}
A handler of your own decides what sleeping means, for example to record the delays:
use retry.{clock, run, constant}
fn main() -> Unit ! log
= do
var slept = []
o = handle run(constant(250, 3), n => if n < 3 then err("busy") else ok(n))
| retry_sleep(ms) => do
slept := slept.push(ms)
resume()
| retry_now() => resume(0)
log("{o.result} after sleeping {slept}")
ok(3) after sleeping [250, 250]
API
Policies
Policy: {max_attempts, base_ms, multiplier, max_delay_ms, jitter, budget_ms: Opt[Int], seed: Opt[Int]}; change fields with with (p with jitter := NoJitter, budget_ms := some(5000)).Jitter: NoJitter | FullJitter | EqualJitter | DecorrelatedJitter, as in the AWS Architecture Blog's "Exponential Backoff And Jitter".default_policy(): 3 attempts, 100 ms doubling to 10 s, full jitter.constant(delay_ms, attempts), exponential(base_ms, attempts) (doubling to 30 s, no jitter), no_retry().base_delay(p, attempt): the delay before jitter, base_ms * multiplier ** (attempt - 1) capped at max_delay_ms, without overflow.next_delay(p, attempt, prev_ms, seed): one jittered delay and the next seed.schedule(p): every delay of a run that always fails, from p.seed (or 0).worst_case_ms(p): the most a run can sleep.
Running
run(p, op), run_when(p, op, retryable), run_hinted(p, op, retryable, after): op(attempt) returns a Res; retryable(e) says whether an error is worth retrying; after(e) may give the delay the error asks for (an HTTP Retry-After), which replaces the computed one. They perform clock plus op's effects.run_now(p, op), run_hinted_now(p, op, retryable, after): the same on the real clock (! time).simulate(start_ms, p, op), simulate_hinted(start_ms, p, op, retryable, after): the same on a virtual clock; they give the outcome and the virtual time at the end.Outcome[T, E]: {result, attempts, delays, waited_ms, stop}, with Stop = Succeeded | Exhausted | NotRetryable | BudgetExceeded.clock: the effect, with retry_sleep(ms) and retry_now().
A run stops at the first success, at an error retryable rejects, after max_attempts attempts, or when the next delay would end past budget_ms from the start (the budget counts the time the operation itself takes, as the clock sees it).
Circuit breaker
BreakerConfig: {failure_threshold, failure_rate, min_calls, window_ms, cooldown_ms, half_open_trials}. The breaker opens when the last window_ms hold failure_threshold failures, or at least min_calls calls of which a failure_rate share failed; it stays open cooldown_ms, then lets half_open_trials trial calls through: if they all succeed it closes, and any failure opens it again.default_breaker_config(): 5 failures or half of 20 calls in 60 s, 30 s open, one trial.breaker(config), Breaker, BreakerState (Closed | Open{until_ms} | HalfOpen{started, succeeded}).allow(b, now), record_success(b, now), record_failure(b, now): the state machine, for code that makes the call itself.call(b, now, op), call_when(b, now, op, counts): run a call through the breaker; Called[T, E] is Rejected{wait_ms} | Ran{result}; with counts only some errors count as failures.current_state(b, now), ms_until_allowed(b, now), window_counts(b, now) (calls and failures in the window).
Rate limiting
Bucket, bucket(capacity, per_sec, now): a full token bucket refilled continuously.bucket_take(b, now, n), bucket_tokens(b, now), bucket_wait_ms(b, now, n) (none when n exceeds the capacity).Window, window_limiter(limit, window_ms): at most limit calls in any window_ms.window_take(w, now), window_remaining(w, now), window_wait_ms(w, now).
A time before the last one seen refills nothing, so a clock that steps back never creates tokens.
Out of scope
- Shared state across threads or processes: the breaker and limiters are values; keep one per caller, or behind an
Atomic, a channel or a database row. - Bulkheads, hedged requests and timeouts around a single attempt (SSPUR's
http.req has timeout_ms and retries of its own for HTTP). - Generic
with_system_clock(f) and simulate(start, f) that take any thunk: a compiler bug currently stops a package from using its own effect in the function types of its parameters, so write the six-line handler shown above for custom cases.
Install
sspur add retry
MIT licensed.
API
pub effect clock
retry_sleep(ms: Int)
retry_now() -> Int
The clock the runners use. retry_sleep(ms) waits, retry_now() is the time in milliseconds. The _now runners handle it with the real clock and simulate with a virtual one; a handler of your own can do anything else (see the README).
pub type Jitter = NoJitter | FullJitter | EqualJitter | DecorrelatedJitter derive Eq, Show
How the delay before each retry is randomized (see the AWS Architecture Blog's "Exponential Backoff And Jitter"): none, uniform in [0, d] (full), d/2 plus uniform in [0, d/2] (equal), or uniform in [base, 3 * previous delay] (decorrelated).
pub type Policy = {max_attempts: Int where _ >= 1, base_ms: Int where _ >= 0, multiplier: F64 where _ >= 1.0, max_delay_ms: Int where _ >= 0, jitter: Jitter, budget_ms: Opt[Int], seed: Opt[Int]} derive Eq, Show
A retry policy. Attempt n (from 1) is followed, when it fails and may be retried, by a delay of base_ms * multiplier ** (n - 1) capped at max_delay_ms, then jittered. budget_ms bounds the total time from the first attempt: a retry whose delay would end past it is not made. seed seeds the jitter; none takes the clock's time at the start.
pub type Stop = Succeeded | Exhausted | NotRetryable | BudgetExceeded derive Eq, Show
pub type Outcome[T, E] = {result: Res[T, E], attempts: Int, delays: List[Int], waited_ms: Int, stop: Stop} derive Eq, Show
The result of a run: the last result, the number of attempts made, each delay slept, their sum, and why it stopped.
fn default_policy() -> Policy
The default policy: 3 attempts, 100 ms doubling up to 10 s, full jitter, no budget.
fn constant(delay_ms: Int where _ >= 0, attempts: Int where _ >= 1) -> Policy
attempts attempts with the same delay_ms between them and no jitter.
fn exponential(base_ms: Int where _ >= 0, attempts: Int where _ >= 1) -> Policy
attempts attempts with delays doubling from base_ms up to 30 s, without jitter.
fn no_retry() -> Policy
One attempt and no retries.
fn base_delay(p: Policy, attempt: Int where _ >= 1) -> Int
The delay after failed attempt attempt before jitter: base_ms * multiplier ** (attempt - 1) capped at max_delay_ms (never overflowing).
fn next_delay(p: Policy, attempt: Int where _ >= 1, prev_ms: Int, seed: Int) -> (Int, Int)
The jittered delay after failed attempt attempt, given the previous delay (base_ms before the first retry; only decorrelated jitter uses it) and a seed. Gives the delay and the next seed; the same inputs always give the same delay, which is never above max_delay_ms.
fn schedule(p: Policy) -> List[Int]
The delays a run sleeps when every attempt fails: one per retry, max_attempts - 1 in all, from seed (or 0 when the policy has none). Ignores the budget.
fn worst_case_ms(p: Policy) -> Int
The longest total time a run can sleep: the sum of the delays before jitter (decorrelated jitter can reach max_delay_ms on every retry, so it counts that).
fn run_hinted[T, E, e](p: Policy, op: Int -> Res[T, E] ! e, retryable: E -> Bool, after: E -> Opt[Int]) -> Outcome[T, E] ! clock, e
Runs op(attempt) (attempts count from 1) until it succeeds, retryable says an error is final, the attempts run out or the budget would be exceeded. after(e) may give a delay the error asks for, such as an HTTP Retry-After, which replaces the computed one (negative values count as 0); it is still bounded by the budget.
fn run_when[T, E, e](p: Policy, op: Int -> Res[T, E] ! e, retryable: E -> Bool) -> Outcome[T, E] ! clock, e
run_hinted without delay hints.
fn run[T, E, e](p: Policy, op: Int -> Res[T, E] ! e) -> Outcome[T, E] ! clock, e
run_hinted retrying every error, without delay hints.
fn run_hinted_now[T, E, e](p: Policy, op: Int -> Res[T, E] ! e, retryable: E -> Bool, after: E -> Opt[Int]) -> Outcome[T, E] ! time, e
run_hinted on the real clock: it sleeps with sleep_ms and reads now_ms.
fn run_now[T, E, e](p: Policy, op: Int -> Res[T, E] ! e) -> Outcome[T, E] ! time, e
fn simulate_hinted[T, E, e](start_ms: Int, p: Policy, op: Int -> Res[T, E] ! e, retryable: E -> Bool, after: E -> Opt[Int]) -> (Outcome[T, E], Int) ! e
run_hinted on a virtual clock that starts at start_ms and moves only when the runner sleeps, so nothing really waits. Gives the outcome and the virtual time at the end.
fn simulate[T, E, e](start_ms: Int, p: Policy, op: Int -> Res[T, E] ! e) -> (Outcome[T, E], Int) ! e
run on a virtual clock that starts at start_ms (see simulate_hinted).
pub type BreakerConfig = {failure_threshold: Int where _ >= 1, failure_rate: F64 where _ > 0.0 and _ <= 1.0, min_calls: Int where _ >= 1, window_ms: Int where _ >= 1, cooldown_ms: Int where _ >= 0, half_open_trials: Int where _ >= 1} derive Eq, Show
When a breaker trips and how it recovers. It opens when, among the calls of the last window_ms, there are failure_threshold failures, or at least min_calls calls of which a failure_rate share or more failed. It stays open for cooldown_ms, then lets half_open_trials calls through: all of them succeeding closes it, any failure opens it again.
pub type BreakerState = Closed | Open{until_ms: Int} | HalfOpen{started: Int, succeeded: Int} derive Eq, Show
A breaker's state: closed (calls go through), open until a time, or half open with the number of trial calls let through and the number that succeeded.
pub type Breaker = {config: BreakerConfig, state: BreakerState, events: List[(Int, Bool)]} derive Eq, Show
A circuit breaker: its configuration, its state and the recent calls (time_ms, succeeded).
pub type Called[T, E] = Rejected{wait_ms: Int} | Ran{result: Res[T, E]} derive Eq, Show
What call did: refused the call (the breaker is open; a call may go through in wait_ms), or ran it with this result.
fn default_breaker_config() -> BreakerConfig
The default breaker configuration: 5 failures, or half of at least 20 calls, within 60 s; open for 30 s; one trial call.
fn breaker(config: BreakerConfig) -> Breaker
A closed breaker with no history.
fn window_counts(b: Breaker, now: Int) -> (Int, Int)
The calls and failures the breaker counts at now, those of the last window_ms.
fn current_state(b: Breaker, now: Int) -> BreakerState
The state at now: an open breaker whose cool-down has passed reads as half open.
fn ms_until_allowed(b: Breaker, now: Int) -> Int
How long until a call may go through: 0 when it may now. While every half-open trial is in flight their results decide, and it gives cooldown_ms, the wait if one of them fails.
fn allow(b: Breaker, now: Int) -> (Bool, Breaker)
Asks to make a call at now: whether it may go through, and the breaker after counting it (an open breaker past its cool-down becomes half open and starts a trial).
fn record_success(b: Breaker, now: Int) -> Breaker
Records a successful call at now. In the half-open state the last needed trial success closes the breaker and clears its history.
fn record_failure(b: Breaker, now: Int) -> Breaker
Records a failed call at now: a closed breaker opens when the failures reach the limits, a half-open one opens again at once.
fn call_when[T, E, e](b: Breaker, now: Int, op: () -> Res[T, E] ! e, counts: E -> Bool) -> (Called[T, E], Breaker) ! e
Runs op through the breaker at now, counting the errors for which counts(e) holds as failures and every other result as a success (so a "not found" need not trip it).
fn call[T, E, e](b: Breaker, now: Int, op: () -> Res[T, E] ! e) -> (Called[T, E], Breaker) ! e
call_when counting every error as a failure.
pub type Bucket = {capacity: Int where _ >= 1, per_sec: F64 where _ > 0.0, tokens: F64, updated_ms: Int} derive Eq, Show
A token bucket: up to capacity tokens, refilled continuously at per_sec tokens a second; tokens were left at updated_ms.
pub type Window = {limit: Int where _ >= 1, window_ms: Int where _ >= 1, hits: List[Int]} derive Eq, Show
A sliding window limiter: at most limit calls in any window_ms; hits are the times of the calls still in the window.
fn bucket(capacity: Int where _ >= 1, per_sec: F64 where _ > 0.0, now: Int) -> Bucket
A full bucket of capacity tokens at now.
fn bucket_tokens(b: Bucket, now: Int) -> F64
The tokens available at now (a time before updated_ms refills nothing).
fn bucket_take(b: Bucket, now: Int, n: Int where _ >= 0) -> (Bool, Bucket)
Takes n tokens at now if they are there: whether it did, and the bucket after.
fn bucket_wait_ms(b: Bucket, now: Int, n: Int where _ >= 0) -> Opt[Int]
How long until n tokens are there: 0 when they are now, none when n is more than the capacity (they never will be).
fn window_limiter(limit: Int where _ >= 1, window_ms: Int where _ >= 1) -> Window
An empty sliding window limiter.
fn window_remaining(w: Window, now: Int) -> Int
How many more calls the window allows at now.
fn window_take(w: Window, now: Int) -> (Bool, Window)
Counts a call at now if the window allows it: whether it did, and the limiter after.
fn window_wait_ms(w: Window, now: Int) -> Int
How long until the window allows a call: 0 when it does now.