Browse documentation

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] | panicked

A 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 E

Runs 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 | io

Re-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. attention is an ordinary user-facing request, so with no handler it becomes a durable open question as an escalation, and the run parks until katari answer (or the console) answers it. Answering re-executes the block. This is the standard re-authentication pattern: a revoked token throws, attended turns 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 attention handler above use retry.attended() intercepts it (for example, posting to Discord and waiting there). next null triggers a retry; break value gives 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 on attended'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

providerWhere a failure goesHow it ends
exponentialDurable sleep, then re-executeSuccess, or the original failure passes through unchanged when the budget is exhausted
foreverDurable sleep (capped), then re-execute; failure value discardedSuccess only (never fails on its own); for daemons
attendedattention request, park or interceptSuccess, 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.