Repetition & Retry
A Schedule is inert on its own — it only describes a
recurrence policy. To put one to work you hand it to an operator that re-runs an
effect according to that policy. The two core operators mirror each other:
Effect.retryre-runs an effect when it fails. The schedule’sInputis the error, so you can inspect the failure to decide whether to keep going.Effect.repeatre-runs an effect when it succeeds. The schedule’sInputis the success value, so you can repeat until some condition is met.
In both cases the effect runs once first, then the schedule decides whether to go again.
import { Effect, Random, Schema, Schedule } from "effect"
class HttpError extends Schema.TaggedErrorClass<HttpError>()("HttpError", { message: Schema.String, status: Schema.Number, retryable: Schema.Boolean}) {}
// A realistic request that sometimes returns a retryable 5xx and sometimes a// fatal 4xx.const fetchUser = Effect.fn("fetchUser")(function* (userId: string) { const roll = yield* Random.next const status = roll > 0.7 ? 200 : roll > 0.3 ? 503 : 401 if (status !== 200) { return yield* new HttpError({ message: `request for ${userId} failed`, status, retryable: status >= 500 }) } return { id: userId, name: "Ada Lovelace" } as const})
// Capped exponential backoff with jitter, but only retry retryable failures and// cap the number of attempts.const policy = Schedule.exponential("250 millis").pipe( Schedule.either(Schedule.spaced("10 seconds")), Schedule.jittered, Schedule.both(Schedule.recurs(6)), Schedule.setInputType<HttpError>(), Schedule.while(({ input }) => input.retryable))
const loadUser = fetchUser("user-123").pipe( Effect.retry(policy), // If every attempt is exhausted, escalate the typed error to a defect. Effect.orDie)Because policy declares its Input as HttpError, the while predicate can
read input.retryable. A 401 is non-retryable, so the policy stops
immediately even though attempts remain — fatal errors fail fast, transient ones
back off and retry.
Repeating successful effects
Section titled “Repeating successful effects”Effect.repeat keeps re-running an effect while it succeeds, stopping the moment
it fails or the schedule completes. This is the building block for polling and
periodic jobs.
import { Effect, Schedule } from "effect"
// Poll a health endpoint every 5 seconds, forever, until it fails.const poll = Effect.gen(function* () { const healthy = yield* checkHealth yield* Effect.log(`health: ${healthy ? "ok" : "degraded"}`)}).pipe(Effect.repeat(Schedule.spaced("5 seconds")))
declare const checkHealth: Effect.Effect<boolean>The schedule’s Output becomes the result of the whole expression. With
Schedule.recurs(n), repeat returns the number of recurrences; with a
Duration-producing schedule it returns the last delay. If you don’t need a
custom schedule input, Effect.schedule(effect, policy) is a thin alias for
Effect.repeat that always seeds the schedule with undefined.
Inline options for the common cases
Section titled “Inline options for the common cases”You don’t always need a full Schedule. Both retry and repeat accept an
options object covering the most common needs: a schedule, a times cap, and
while / until predicates.
import { Effect } from "effect"
declare const request: Effect.Effect<string, RequestError>class RequestError { readonly _tag = "RequestError" constructor(readonly retryable: boolean) {}}
// Retry at most 3 times, but only while the error says it is retryable.const withOptions = request.pipe( Effect.retry({ times: 3, while: (error) => error.retryable }))while keeps going as long as the predicate is true; until is its inverse,
stopping as soon as the predicate becomes true. Both accept a boolean or an
Effect<boolean>. For repeat, the predicate receives the success value instead
of the error.
Falling back when the policy is exhausted
Section titled “Falling back when the policy is exhausted”retryOrElse and repeatOrElse let you recover instead of propagating the final
failure. The fallback receives the last error and the schedule’s output (its
recurrence count), so you can log, serve a cached value, or degrade gracefully.
import { Effect, Schedule } from "effect"
declare const networkRequest: Effect.Effect<string, NetworkError>class NetworkError { readonly _tag = "NetworkError"}
const withFallback = networkRequest.pipe( Effect.retryOrElse( Schedule.recurs(2), (error, attempts) => Effect.gen(function* () { yield* Effect.logWarning(`giving up after ${attempts} retries`) return "cached-data" // graceful fallback value }) ))Testing scheduled effects
Section titled “Testing scheduled effects”Schedules realize their delays through the Clock, so a
test can advance simulated time with TestClock.adjust instead of waiting in
real time. A retry policy spanning minutes of backoff verifies in microseconds.
See Clock for the full pattern.
Operator reference
Section titled “Operator reference”Every operator below consumes a Schedule (or schedule options) and threads the
schedule’s Error and Env (requirements) into the resulting effect. Two
return-value rules apply throughout:
retry/retryOrElsereturn the effect’s success valueA— the schedule only governs when to re-run on failure.repeat/schedule/scheduleFromreturn the schedule’s finalOutput(the recurrence count forSchedule.recurs, the lastDurationfor duration-based schedules, etc.) — the effect ran successfully, so its value is not what’s interesting.
A schedule typed Schedule<Output, Input, Error, Env> contributes Error to
the result’s error channel and Env to its requirements channel; if you write a
schedule with no extra failures or requirements those are never.
Effect.retry
Section titled “Effect.retry”Re-runs self on each typed failure until the schedule stops; the schedule’s
Input is the error. Accepts a Schedule, a schedule-builder function, or a
Retry.Options object ({ schedule?, times?, while?, until? }). Returns the
effect’s success value. (Effect.ts:4027)
import { Effect, Schedule } from "effect"
let attempts = 0const flaky = Effect.suspend(() => ++attempts < 3 ? Effect.fail("boom") : Effect.succeed("ok"))
// Schedule form: retry up to 5 extra times, 100ms apart.const a = Effect.retry(flaky, Schedule.spaced("100 millis").pipe(Schedule.both(Schedule.recurs(5))))Effect.runPromise(a).then(console.log)// => "ok" (succeeds on the 3rd attempt)
// Options form: cap attempts and guard with a predicate on the error.const b = Effect.retry(flaky, { times: 5, while: (e) => e === "boom" })// => Effect<string, string> (retries only while the error is "boom")
// Builder form: `$` pins the schedule Input to the effect's error type.const c = flaky.pipe( Effect.retry(($) => $(Schedule.spaced("1 second")).pipe(Schedule.recurs(3))))Effect.retryOrElse
Section titled “Effect.retryOrElse”Like retry, but when the retry schedule is exhausted and the effect is still
failing, it runs a fallback orElse(error, scheduleOutput) instead of
propagating the failure. The fallback receives the last error and the schedule’s
final output (e.g. the recurrence count). (Effect.ts:4106)
import { Effect, Schedule } from "effect"
const request: Effect.Effect<string, "timeout"> = Effect.fail("timeout")
const program = request.pipe( Effect.retryOrElse( Schedule.recurs(2), (error, count) => Effect.succeed(`fell back after ${count} retries (${error})`) ))Effect.runPromise(program).then(console.log)// => "fell back after 2 retries (timeout)"Effect.repeat
Section titled “Effect.repeat”Re-runs self on each success until the schedule stops or the effect fails;
the schedule’s Input is the success value. Accepts a Schedule, a
schedule-builder, or a Repeat.Options object ({ schedule?, times?, while?, until? }) whose while/until receive the success value. Returns the
schedule’s final Output. (Effect.ts:7534)
import { Effect, Schedule } from "effect"
const tick = Effect.sync(() => Date.now())
// Schedule form: run, then repeat twice more (3 executions total).const a = Effect.repeat(tick, Schedule.recurs(2))Effect.runPromise(a).then(console.log)// => 2 (the recurrence count returned by Schedule.recurs)
// Options form: repeat until a success value satisfies a predicate.let n = 0const counter = Effect.sync(() => ++n)const b = Effect.repeat(counter, { until: (value) => value >= 3 })Effect.runPromise(b).then(console.log)// => runs until `counter` returns 3Effect.repeatOrElse
Section titled “Effect.repeatOrElse”Like repeat, but if the effect (or a schedule step) fails before the schedule
completes, it runs a fallback orElse(error, option). The second argument is an
Option<Output>: Some once at least one schedule step has run, None if it
failed on the very first attempt. The fallback must produce the schedule’s
Output type, so the whole expression still resolves to a single result type.
(Effect.ts:7599)
import { Console, Effect, Option, Schedule } from "effect"
let attempt = 0const task = Effect.suspend(() => { attempt++ return attempt <= 2 ? Effect.fail(`error ${attempt}`) : Effect.succeed("done")})
const program = Effect.repeatOrElse( task, Schedule.recurs(3), (error, count) => // The fallback must return the schedule Output (a number for `recurs`). Console.log( `stopped: ${error}, steps=${Option.getOrElse(count, () => 0)}` ).pipe(Effect.as(0)))Effect.runPromise(program).then(console.log)// => 0 (fails on the first run, so count is None and the fallback returns 0)Effect.schedule
Section titled “Effect.schedule”A thin alias for repeat that seeds the schedule with undefined (its Input
is unknown), so the schedule’s decisions don’t depend on the effect’s value.
Returns the schedule’s final Output. Use it when you just want “run this on a
cadence” without wiring up an input type. (Effect.ts:7724)
import { Effect, Schedule } from "effect"
const job = Effect.log("running scheduled job")
const program = Effect.schedule(job, Schedule.recurs(2))Effect.runPromise(program).then(console.log)// => 2 (logs three times total, returns the recurrence count)Effect.scheduleFrom
Section titled “Effect.scheduleFrom”Like schedule, but initial seeds the schedule before the first run, and
each success value is then fed back as the next schedule Input. This lets the
schedule’s continuation decision depend on what the effect produced. Returns the
schedule’s final Output. (Effect.ts:7774)
import { Effect, Schedule } from "effect"
let value = 0const produce = Effect.sync(() => ++value)
// Keep going while the latest produced value (the schedule Input) is below 3;// seed the schedule with 0 before the first run.const program = Effect.scheduleFrom( produce, 0, Schedule.forever.pipe( Schedule.setInputType<number>(), Schedule.while(({ input }) => input < 3) ))Effect.runPromise(program).then(console.log)// => runs until `produce` yields 3, feeding each value back into the scheduleInline options
Section titled “Inline options”Both retry and repeat accept an options object instead of a full Schedule,
covering the common cases without building a policy by hand:
schedule— a baseScheduleto drive the recurrence.times— cap the number of additional runs (likeSchedule.recurs).while— keep going while the predicate holds (true).until— stop as soon as the predicate holds (true); the inverse ofwhile.
For retry the predicates receive the error; for repeat they receive the
success value. Each predicate may return a boolean or an Effect<boolean>,
so the decision can itself be effectful.
import { Effect } from "effect"
declare const fetchPage: (n: number) => Effect.Effect<{ page: number; last: boolean }>
// Repeat paging until the server says it returned the last page.const drain = fetchPage(0).pipe( Effect.repeat({ until: (result) => result.last }))// => Effect<{ page: number; last: boolean }, never>For schedule construction itself (backoff, combinators, predicates) see
Schedule. For time-based scheduling primitives see
Clock and Cron, and for the
typed-failure vs defect distinction that governs what retry re-runs, see
Error Management.