Package

retry

Retries with exponential backoff and jitter, retry budgets, a circuit breaker and rate limiters, on a clock effect that tests run virtually

0.1.1 MIT

Install

sspur add retry@^0.1.1

or in sspur.toml:

retry = { version = "^0.1.1" }

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

clock effect

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).

Jitter type

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).

Policy type

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.

Stop type

pub type Stop = Succeeded | Exhausted | NotRetryable | BudgetExceeded derive Eq, Show

Why a run stopped.

Outcome type

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.

default_policy fn

fn default_policy() -> Policy

The default policy: 3 attempts, 100 ms doubling up to 10 s, full jitter, no budget.

constant fn

fn constant(delay_ms: Int where _ >= 0, attempts: Int where _ >= 1) -> Policy

attempts attempts with the same delay_ms between them and no jitter.

exponential fn

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.

no_retry fn

fn no_retry() -> Policy

One attempt and no retries.

base_delay fn

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).

next_delay fn

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.

schedule fn

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.

worst_case_ms fn

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).

run_hinted fn

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.

run_when fn

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.

run fn

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.

run_hinted_now fn

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.

run_now fn

fn run_now[T, E, e](p: Policy, op: Int -> Res[T, E] ! e) -> Outcome[T, E] ! time, e

run on the real clock.

simulate_hinted fn

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.

simulate fn

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).

BreakerConfig type

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.

BreakerState type

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.

Breaker type

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).

Called type

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.

default_breaker_config fn

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.

breaker fn

fn breaker(config: BreakerConfig) -> Breaker

A closed breaker with no history.

window_counts fn

fn window_counts(b: Breaker, now: Int) -> (Int, Int)

The calls and failures the breaker counts at now, those of the last window_ms.

current_state fn

fn current_state(b: Breaker, now: Int) -> BreakerState

The state at now: an open breaker whose cool-down has passed reads as half open.

ms_until_allowed fn

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.

allow fn

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).

record_success fn

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.

record_failure fn

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.

call_when fn

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).

call fn

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.

Bucket type

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.

Window type

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.

bucket fn

fn bucket(capacity: Int where _ >= 1, per_sec: F64 where _ > 0.0, now: Int) -> Bucket

A full bucket of capacity tokens at now.

bucket_tokens fn

fn bucket_tokens(b: Bucket, now: Int) -> F64

The tokens available at now (a time before updated_ms refills nothing).

bucket_take fn

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.

bucket_wait_ms fn

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).

window_limiter fn

fn window_limiter(limit: Int where _ >= 1, window_ms: Int where _ >= 1) -> Window

An empty sliding window limiter.

window_remaining fn

fn window_remaining(w: Window, now: Int) -> Int

How many more calls the window allows at now.

window_take fn

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.

window_wait_ms fn

fn window_wait_ms(w: Window, now: Int) -> Int

How long until the window allows a call: 0 when it does now.

Versions

VersionPublishedCommitSourceEffects
0.1.1 2026-10-189f1a40bb4esnapshot
0.1.0 2026-10-1631c0b0cdd7snapshot