Policies
Retry.policy pairs a Schedule with a classifier for expected errors. Retry.run calls f. When an attempt fails with an error the classifier accepts and the schedule allows another attempt, the current fiber sleeps for the next delay and calls f again. Otherwise the attempt's error propagates unchanged.
type AttemptLog = {
mut offsets: Vector(Int);
}
let epochepoch: Instant = must Instant.parse_rfc3339parse_rfc3339: pure fn(string: String) -> Instant, exn [ :parse(String) ]("2026-01-01T00:00:00Z""2026-01-01T00:00:00Z": String);
let is_busyis_busy: fn([> :busy('a) ]) -> Bool = function {
| :busy(_) -> TrueTrue: Bool
| _ -> FalseFalse: Bool
};
let attemptattempt: fn(log: Book.Concurrency.Retry.AttemptLog, start: Clock.MonotonicInstant, failures: Int) -> String, exn [ :busy(Int) ] = fn(loglog: Book.Concurrency.Retry.AttemptLog, startstart: Clock.MonotonicInstant, failuresfailures: Int) {
let offsetoffset: Int = Duration.to_millisto_millis: pure fn!(duration: Duration) -> Int(Clock.elapsed_sinceelapsed_since: fn!(mark: Clock.MonotonicInstant) -> Duration(startstart: Clock.MonotonicInstant));
log.offsets := Vector.pushpush: pure fn!('a)(items: Vector(Int), item: Int) -> Vector(Int)(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int), offsetoffset: Int);
let countcount: Int = Vector.lengthlength: pure fn!('a)(items: Vector(Int)) -> Int(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int));
if countcount: Int <= failuresfailures: Int {
error :busy(countcount: Int);
}
"done""done": String
};
let attempt_offsetsattempt_offsets: fn(schedule: Schedule, failures: Int) -> (String, Vector(Int)) = fn(scheduleschedule: Schedule, failuresfailures: Int) {
let loglog: Book.Concurrency.Retry.AttemptLog = AttemptLog { offsets = []; };
let policypolicy: Retry.Policy = Retry.policypolicy: pure fn!(~schedule: Schedule, ~when: (pure fn!(failure: [> ]) -> Bool) @ global) -> Retry.Policy(schedule = scheduleschedule: Schedule, when = is_busyis_busy: fn([> :busy('a) ]) -> Bool);
provide { Clock = Clock.virtualvirtual: pure fn!(start: Instant) -> Service.Provider(Clock)(epochepoch: Instant); } {
let startstart: Clock.MonotonicInstant = Clock.markmark: fn!() -> Clock.MonotonicInstant();
let resultresult: String = try Retry.runrun: fn!(~policy: Retry.Policy, ~f: fn() -> String) -> String, eff [ Clock | Random ](
policy = policypolicy: Retry.Policy,
f = () => attemptattempt: fn(log: Book.Concurrency.Retry.AttemptLog, start: Clock.MonotonicInstant, failures: Int) -> String, exn [ :busy(Int) ](loglog: Book.Concurrency.Retry.AttemptLog, startstart: Clock.MonotonicInstant, failuresfailures: Int),
) {
| :busy(countcount: Int) -> "busy ""busy ": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(countcount: Int)
};
(resultresult: String, loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int))
}
};
Each attempt records its start offset in milliseconds. exponential doubles the delay after every failure.
Exponential Backoff
let scheduleschedule: Schedule = Schedule.exponentialexponential: pure fn!(base: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int))
|> Schedule.limit_retrieslimit_retries: pure fn!(schedule: Schedule, count: Int) -> Schedule(33: Int);
expect ✓attempt_offsetsattempt_offsets: fn(schedule: Schedule, failures: Int) -> (String, Vector(Int))(scheduleschedule: Schedule, 33: Int) == ("done""done": String, [00: Int, 10001000: Int, 30003000: Int, 70007000: Int]);limit_retries counts retries after the first attempt. Exhausting it propagates the last attempt's error.
Exhausted Retries
let scheduleschedule: Schedule = Schedule.spacedspaced: pure fn!(delay: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int))
|> Schedule.limit_retrieslimit_retries: pure fn!(schedule: Schedule, count: Int) -> Schedule(22: Int);
expect ✓attempt_offsetsattempt_offsets: fn(schedule: Schedule, failures: Int) -> (String, Vector(Int))(scheduleschedule: Schedule, 55: Int) == ("busy 3""busy 3": String, [00: Int, 10001000: Int, 20002000: Int]);A policy as a stage
A policy is also a Drive value, so >> attaches it to a function. Every call then runs the function's body as Retry.run runs f. The stage keeps the body's errors and adds Clock and Random to its effects. A body that opens a transaction retries the whole region.
let reservereserve: fn(log: Book.Concurrency.Retry.AttemptLog, failures: Int) -> String, exn [ 'a | :busy(Int) ], eff 'b = fn(loglog: Book.Concurrency.Retry.AttemptLog: AttemptLog, failuresfailures: Int: Int) {
log.offsets := Vector.pushpush: pure fn!('a)(items: Vector(Int), item: Int) -> Vector(Int)(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int), 00: Int);
let countcount: Int = Vector.lengthlength: pure fn!('a)(items: Vector(Int)) -> Int(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int));
if countcount: Int <= failuresfailures: Int {
error :busy(countcount: Int);
}
"reserved""reserved": String
} >> Retry.policypolicy: pure fn!(~schedule: Schedule, ~when: (pure fn!(failure: [> ]) -> Bool) @ global) -> Retry.Policy(schedule = Schedule.recursrecurs: pure fn!(count: Int) -> Schedule(33: Int), when = is_busyis_busy: fn([> :busy(Int) ]) -> Bool);
Policy Stage
✓reservereserve: fn(log: Book.Concurrency.Retry.AttemptLog, failures: Int) -> String, exn [ :busy(Int) ]: fn(log: AttemptLog, failures: Int) -> String,
exn [ :busy(Int) ],
eff [ Clock | Random ];
let loglog: Book.Concurrency.Retry.AttemptLog = AttemptLog { offsets = []; };
expect ✓must reservereserve: fn(log: Book.Concurrency.Retry.AttemptLog, failures: Int) -> String, exn [ :busy(Int) ](loglog: Book.Concurrency.Retry.AttemptLog, 22: Int) == "reserved""reserved": String;
expect ✓Vector.lengthlength: pure fn!('a)(items: Vector(Int)) -> Int(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int)) == 33: Int;Fixed and spaced delays
spaced waits its full delay after each failure. fixed keeps a cadence: it waits whatever remains of the delay since the failed attempt started. Attempts may sleep too; they run on the same fiber.
let slow_attemptslow_attempt: fn(log: Book.Concurrency.Retry.AttemptLog, start: Clock.MonotonicInstant, failures: Int) -> String, exn [ :busy(Int) ] = fn(loglog: Book.Concurrency.Retry.AttemptLog, startstart: Clock.MonotonicInstant, failuresfailures: Int) {
Clock.sleepsleep: fn!(duration: Duration) -> Unit, eff Clock(Duration.secondsseconds: pure fn!(value: Int) -> Duration(44: Int));
attemptattempt: fn(log: Book.Concurrency.Retry.AttemptLog, start: Clock.MonotonicInstant, failures: Int) -> String, exn [ :busy(Int) ](loglog: Book.Concurrency.Retry.AttemptLog, startstart: Clock.MonotonicInstant, failuresfailures: Int)
};
let slow_offsetsslow_offsets: fn(schedule: Schedule) -> (String, Vector(Int)) = fn(scheduleschedule: Schedule) {
let loglog: Book.Concurrency.Retry.AttemptLog = AttemptLog { offsets = []; };
let policypolicy: Retry.Policy = Retry.policypolicy: pure fn!(~schedule: Schedule, ~when: (pure fn!(failure: [> ]) -> Bool) @ global) -> Retry.Policy(schedule = scheduleschedule: Schedule, when = is_busyis_busy: fn([> :busy('a) ]) -> Bool);
provide { Clock = Clock.virtualvirtual: pure fn!(start: Instant) -> Service.Provider(Clock)(epochepoch: Instant); } {
let startstart: Clock.MonotonicInstant = Clock.markmark: fn!() -> Clock.MonotonicInstant();
let resultresult: String = must Retry.runrun: fn!(~policy: Retry.Policy, ~f: fn() -> String) -> String, eff [ Clock | Random ](
policy = policypolicy: Retry.Policy,
f = () => slow_attemptslow_attempt: fn(log: Book.Concurrency.Retry.AttemptLog, start: Clock.MonotonicInstant, failures: Int) -> String, exn [ :busy(Int) ](loglog: Book.Concurrency.Retry.AttemptLog, startstart: Clock.MonotonicInstant, 22: Int),
);
(resultresult: String, loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int))
}
};
Fixed And Spaced
let fixedfixed: Schedule = Schedule.fixedfixed: pure fn!(delay: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(1010: Int))
|> Schedule.limit_retrieslimit_retries: pure fn!(schedule: Schedule, count: Int) -> Schedule(22: Int);
let spacedspaced: Schedule = Schedule.spacedspaced: pure fn!(delay: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(1010: Int))
|> Schedule.limit_retrieslimit_retries: pure fn!(schedule: Schedule, count: Int) -> Schedule(22: Int);
expect ✓slow_offsetsslow_offsets: fn(schedule: Schedule) -> (String, Vector(Int))(fixedfixed: Schedule) == ("done""done": String, [40004000: Int, 1400014000: Int, 2400024000: Int]);
expect ✓slow_offsetsslow_offsets: fn(schedule: Schedule) -> (String, Vector(Int))(spacedspaced: Schedule) == ("done""done": String, [40004000: Int, 1800018000: Int, 3200032000: Int]);Jitter, caps, and budgets
jitter(factor = f) scales each delay by a uniform draw from [1 - f, 1 + f) through the ambient Random service. Random.sequence(values) replays fixed draws in tests; Random.SystemRandom draws from the host.
Jitter Draws From Random
let scheduleschedule: Schedule = Schedule.spacedspaced: pure fn!(delay: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(1010: Int))
|> Schedule.jitterjitter: pure fn!(schedule: Schedule, ~factor: Float) -> Schedule(factor = 0.50.5: Float)
|> Schedule.limit_retrieslimit_retries: pure fn!(schedule: Schedule, count: Int) -> Schedule(22: Int);
provide { Random = Random.sequencesequence: pure fn!(values: Vector(Float)) -> Service.Provider(Random)([0.00.0: Float, 0.750.75: Float]); } {
expect ✓attempt_offsetsattempt_offsets: fn(schedule: Schedule, failures: Int) -> (String, Vector(Int))(scheduleschedule: Schedule, 22: Int) == ("done""done": String, [00: Int, 50005000: Int, 1750017500: Int]);
};cap_delay bounds every delay after jitter.
Capped Backoff
let scheduleschedule: Schedule = Schedule.exponentialexponential: pure fn!(base: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int))
|> Schedule.cap_delaycap_delay: pure fn!(schedule: Schedule, max: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(33: Int))
|> Schedule.limit_retrieslimit_retries: pure fn!(schedule: Schedule, count: Int) -> Schedule(44: Int);
expect ✓attempt_offsetsattempt_offsets: fn(schedule: Schedule, failures: Int) -> (String, Vector(Int))(scheduleschedule: Schedule, 44: Int) == ("done""done": String, [00: Int, 10001000: Int, 30003000: Int, 60006000: Int, 90009000: Int]);max_elapsed measures from the first attempt with the provided Clock. A retry whose delay would end past the budget does not start. Without limit_retries, the budget alone bounds the attempts.
Elapsed Budget
let scheduleschedule: Schedule = Schedule.exponentialexponential: pure fn!(base: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int))
|> Schedule.max_elapsedmax_elapsed: pure fn!(schedule: Schedule, max: Duration) -> Schedule(Duration.secondsseconds: pure fn!(value: Int) -> Duration(55: Int));
expect ✓attempt_offsetsattempt_offsets: fn(schedule: Schedule, failures: Int) -> (String, Vector(Int))(scheduleschedule: Schedule, 1010: Int) == ("busy 3""busy 3": String, [00: Int, 10001000: Int, 30003000: Int]);Classifying errors
The classifier sees each failure. Rejected errors propagate after one attempt.
let fatal_attemptsfatal_attempts: fn(log: Book.Concurrency.Retry.AttemptLog) -> String, exn [ :fatal(String) ] = fn(loglog: Book.Concurrency.Retry.AttemptLog) {
log.offsets := Vector.pushpush: pure fn!('a)(items: Vector(Int), item: Int) -> Vector(Int)(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int), 00: Int);
error :fatal("disk full""disk full": String);
"unreachable""unreachable": String
};
Fatal Errors Stop
let loglog: Book.Concurrency.Retry.AttemptLog = AttemptLog { offsets = []; };
let policypolicy: Retry.Policy = Retry.policypolicy: pure fn!(~schedule: Schedule, ~when: (pure fn!(failure: [> ]) -> Bool) @ global) -> Retry.Policy(schedule = Schedule.recursrecurs: pure fn!(count: Int) -> Schedule(33: Int), when = is_busyis_busy: fn([> :busy('a) ]) -> Bool);
let resultresult: String = try Retry.runrun: fn!(~policy: Retry.Policy, ~f: fn() -> String) -> String, eff [ Clock | Random ](policy = policypolicy: Retry.Policy, f = () => fatal_attemptsfatal_attempts: fn(log: Book.Concurrency.Retry.AttemptLog) -> String, exn [ :fatal(String) ](loglog: Book.Concurrency.Retry.AttemptLog)) {
| :fatal(reasonreason: String) -> reasonreason: String
};
expect ✓resultresult: String == "disk full""disk full": String;
expect ✓Vector.lengthlength: pure fn!('a)(items: Vector(Int)) -> Int(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int)) == 11: Int;recurs retries without delay. A schedule touches Clock only for delays and budgets, and Random only for jitter.
Immediate Retries
let loglog: Book.Concurrency.Retry.AttemptLog = AttemptLog { offsets = []; };
let policypolicy: Retry.Policy = Retry.policypolicy: pure fn!(~schedule: Schedule, ~when: (pure fn!(failure: [> ]) -> Bool) @ global) -> Retry.Policy(schedule = Schedule.recursrecurs: pure fn!(count: Int) -> Schedule(22: Int), when = is_busyis_busy: fn([> :busy('a) ]) -> Bool);
let flakyflaky: fn() -> String, exn [ :busy(Int) ] = fn() {
log.offsets := Vector.pushpush: pure fn!('a)(items: Vector(Int), item: Int) -> Vector(Int)(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int), 00: Int);
if Vector.lengthlength: pure fn!('a)(items: Vector(Int)) -> Int(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int)) < 33: Int {
error :busy(Vector.lengthlength: pure fn!('a)(items: Vector(Int)) -> Int(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int)));
}
"done""done": String
};
let resultresult: String = must Retry.runrun: fn!(~policy: Retry.Policy, ~f: fn() -> String) -> String, eff [ Clock | Random ](policy = policypolicy: Retry.Policy, f = flakyflaky: fn() -> String, exn [ :busy(Int) ]);
expect ✓resultresult: String == "done""done": String;
expect ✓Vector.lengthlength: pure fn!('a)(items: Vector(Int)) -> Int(loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int)) == 33: Int;Waiting is a timer
A retry delay parks only the retrying fiber. Interrupting that fiber cancels the pending delay, so no further attempt starts.
Interrupt Stops Retrying
let loglog: Book.Concurrency.Retry.AttemptLog: AttemptLog @ global = AttemptLog { offsets = []; };
provide { Clock = Clock.virtualvirtual: pure fn!(start: Instant) -> Service.Provider(Clock)(epochepoch: Instant); } {
let startstart: Clock.MonotonicInstant = Clock.markmark: fn!() -> Clock.MonotonicInstant();
let ownerowner: Async.Task(String) = spawn {
let _retrying_retrying: Async.Task(String) = spawn {
let policypolicy: Retry.Policy = Retry.policypolicy: pure fn!(~schedule: Schedule, ~when: (pure fn!(failure: [> ]) -> Bool) @ global) -> Retry.Policy(
schedule = Schedule.spacedspaced: pure fn!(delay: Duration) -> Schedule(Duration.hourshours: pure fn!(value: Int) -> Duration(11: Int))
|> Schedule.limit_retrieslimit_retries: pure fn!(schedule: Schedule, count: Int) -> Schedule(55: Int),
when = is_busyis_busy: fn([> :busy('a) ]) -> Bool,
);
try Retry.runrun: fn!(~policy: Retry.Policy, ~f: fn() -> String) -> String, eff [ Clock | Random ](
policy = policypolicy: Retry.Policy,
f = () => attemptattempt: fn(log: Book.Concurrency.Retry.AttemptLog, start: Clock.MonotonicInstant, failures: Int) -> String, exn [ :busy(Int) ](loglog: Book.Concurrency.Retry.AttemptLog, startstart: Clock.MonotonicInstant, 1010: Int),
) {
| :busy(countcount: Int) -> "busy ""busy ": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(countcount: Int)
}
};
Clock.sleepsleep: fn!(duration: Duration) -> Unit, eff Clock(Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int));
"owner done""owner done": String
};
expect ✓await ownerowner: Async.Task(String) == "owner done""owner done": String;
expect ✓Clock.elapsed_sinceelapsed_since: fn!(mark: Clock.MonotonicInstant) -> Duration(startstart: Clock.MonotonicInstant) == Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int);
};
expect ✓loglog: Book.Concurrency.Retry.AttemptLog.offsetsoffsets: Vector(Int) == [00: Int];