prelude.retry
Use providers for failure recovery, exponential / forever / attended, plus the normalization boundary attempt.
A group of providers for failure recovery. Each is an ordinary
use provider that re-executes the
following block (the continuation) on failure; this is also the first code in this reference to
call its continuation more than once. They are written in plain Katari on top of durable
time.sleep and the two error channels; no new
reactor is involved. Integration is one line:
data flaky_error(message: string)
agent retried() -> string with io | prelude.throw[flaky_error] {
use retry.exponential(initial_delay_milliseconds = 100.0, factor = 2.0, max_attempts = 5.0)
flaky_step() // The rest of the block is re-executed until it succeeds or the budget is exhausted
}
agent flaky_step() -> string with prelude.throw[flaky_error] {
prelude.throw(error = flaky_error(message = "still warming up"))
}Design core: a single normalization boundary, and panics that cannot be re-thrown
Katari has two failure channels: the typed throw[Error] a program raises, and the panic the
runtime raises. Retry must treat both as "that attempt failed", and the catch for both is
concentrated in one place, attempt (below). Each provider is written purely as a match on
the succeeded | failed sum type; no counter comparison appears anywhere.
The catch discharges throw[Error] for a generic Error, so a caught throw is reified as
thrown[Error] with its type preserved. use retry.exponential(...) wrapped around http.fetch
re-throws throw[http.fetch_error] (not an erased throw[unknown]) even when the budget is
exhausted.
A second constraint shapes the design: a caught panic cannot be re-thrown from Katari (a
program cannot perform panic). So no provider can be written that holds onto a failure and
re-raises it once the budget is exhausted; the way exponential handles exhaustion (running the
final attempt unwrapped) is a consequence of this constraint.
Types
retry.thrown / retry.panicked / retry.failure
data thrown[Error](error: Error)
data panicked(message: string)
type failure[Error] = thrown[Error] | panickedA sum type that normalizes the failure of one attempt across both channels. thrown holds the
payload of a caught throw together with its type; panicked carries the message of a caught
panic. The two are kept distinct because an anticipated typed error and a broken invariant mean
different things, and a handler may want to react to them differently.
retry.succeeded / retry.failed / retry.outcome
data succeeded[R](value: R)
data failed[Error](failure: failure[Error])
type outcome[R, Error] = succeeded[R] | failed[Error]The result of running the continuation once: a value, or a normalized failure.
Agents
retry.attempt
agent attempt[R, Error, effect E](
run: agent (value: null) -> R with {...E, prelude.throw[Error]},
) -> outcome[R, Error] with ERuns run once and reifies the outcome: a value becomes succeeded; a caught throw or panic
becomes failed. This is the only boundary where a failure is caught; all three providers
below are composed on top of it. run's effect is the overwrite row
{...E, prelude.throw[Error]} rather than the union E | throw[Error], because this handler
discharges the throw (a discharged request always belongs in the overwrite row; see the row
section of Effects for detail). run's other
effect E passes through unchanged.
retry.exponential
agent exponential[R, Error, effect E](
initial_delay_milliseconds: number,
factor: number,
max_attempts: number,
continuation: agent (value: null) -> R with {...E, prelude.throw[Error]},
) -> R with E | io | prelude.throw[Error]Re-executes the rest of the block with exponential backoff, up to max_attempts times. On the
n-th (1-based) failure (a throw or a panic), if n < max_attempts, it durably sleeps for
initial_delay_milliseconds × factor^(n-1) and re-executes.
Exhaustion branches on structure, not on a count. A for runs the catchable attempts
(max_attempts - 1 of them), and its then clause runs the final attempt outside attempt,
as a bare continuation(...). So whatever the final attempt raises, throw or panic, propagates
unchanged (a throw re-throws to the caller with its type preserved; a panic re-panics). There
is no branch asking "is this the last attempt?" based on n. Given that a caught panic cannot be
re-thrown, this is the only possible representation of exhaustion. An attempt always runs at least
once, even with a max_attempts below 1. The budget is capped by array.range's ten-million-
element limit; a max_attempts off by orders of magnitude panics loudly at entry. Use forever,
not a large budget, for unbounded retry.
- Throws the continuation's
throw[Error](the one from the exhausted final attempt, unchanged and with its type preserved).
retry.forever
agent forever[R, Error, effect E](
initial_delay_milliseconds: number,
factor: number,
max_delay_milliseconds: number,
continuation: agent (value: null) -> R with {...E, prelude.throw[Error], done[R], backoff},
) -> R with E | ioRe-executes the rest of the block forever. Every failure (throw or panic) is caught and
retried, and the failure value is discarded (deliberately: it is never re-raised, and forever
never returns on its own). Backoff grows by a factor of factor, capped at
max_delay_milliseconds. This is the shape for a daemon that must survive transient failures and
restarts.
The loop uses the forever { ... } syntax:
a single iterating thread whose settled attempt frames are reclaimed, so durable state stays flat
regardless of the number of failures. A daemon that has retried a million times occupies the
same number of rows as one that has retried once. done[R] and backoff in the continuation's
overwrite row are an internal protocol discharged by the provider's own handler; they never reach
application code.
Composition with time.watch is the headline pattern for this release. A failure in
deliver_to kills time.watch, by design.
retry.forever catches that, backs off, and re-executes watch. Because watch re-arms from the
persisted next occurrence, the cron / daemon recovers across transient failures.
data delivery_failed(scheduled: number)
agent flaky_deliver(time: number) -> null with prelude.throw[delivery_failed] {
prelude.throw(error = delivery_failed(scheduled = time))
}
agent daemon() -> never with io {
use retry.forever(initial_delay_milliseconds = 100.0, factor = 2.0, max_delay_milliseconds = 5000.0)
time.watch(schedule = time.interval(milliseconds = 1000), deliver_to = flaky_deliver)
}retry.attended
request attention[Error](failure: failure[Error]) -> null
agent attended[R, Error, effect E](
continuation: agent (value: null) -> R with {...E, prelude.throw[Error], done[R]},
) -> R with E | attention[Error]Retry with a human in the loop: on every failure (throw or panic), it performs attention
carrying the normalized failure (a typed error keeps its type), and re-executes the block once
attention resolves.
- Unhandled: escalate, park, re-execute on answer.
attentionis an ordinary user-facing request, so with no handler it becomes a durable open question as an escalation, and the run parks untilkatari answer(or the console) answers it. Answering re-executes the block. This is the standard re-authentication pattern: a revoked token throws,attendedturns that into a notification, a human re-authorizes and answers, and the block re-executes against the restored credential. - Can be intercepted. Placing an application-defined
attentionhandler aboveuse retry.attended()intercepts it (for example, posting to Discord and waiting there).next nulltriggers a retry;break valuegives up and exits with a value. Escalation (the default) and interception use the same mechanism; which one happens depends only on whether a handler is in scope. There is no branch onattended's side.
As with forever, the loop uses the forever { ... } syntax, so durable state stays flat no
matter how many times the failure-then-answer cycle repeats.
data token_revoked(message: string)
agent call_api() -> string with prelude.throw[token_revoked] {
prelude.throw(error = token_revoked(message = "re-authorize and answer to retry"))
}
agent resilient_call() -> string with retry.attention[token_revoked] {
use retry.attended()
call_api() // Every failure raises attention, and answering re-executes it
}Choosing a provider
| provider | Where a failure goes | How it ends |
|---|---|---|
exponential | Durable sleep, then re-execute | Success, or the original failure passes through unchanged when the budget is exhausted |
forever | Durable sleep (capped), then re-execute; failure value discarded | Success only (never fails on its own); for daemons |
attended | attention request, park or intercept | Success, or break from an intercepting handler |
If some error should not be retried, catch it inside the block, with a use handler closer
than the provider, so it never reaches the provider. There is no filter knob.
Related
prelude.time
Durable wall-clock time, now / sleep / sleep_until, plus watch for observing a schedule.
Providers (use)
The use statement introduces a capability, a single application form spanning handler literals up through provider agents that take arguments.
Effects
request and use handler, escalation (parking as an open question), and the throw / panic split.
Syntax
Module declarations, let / match / for / parallel / use / finally, the partial application hole _, and reserved words.