Skip to content

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.retry re-runs an effect when it fails. The schedule’s Input is the error, so you can inspect the failure to decide whether to keep going.
  • Effect.repeat re-runs an effect when it succeeds. The schedule’s Input is 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.

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.

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.

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

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.

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 / retryOrElse return the effect’s success value A — the schedule only governs when to re-run on failure.
  • repeat / schedule / scheduleFrom return the schedule’s final Output (the recurrence count for Schedule.recurs, the last Duration for 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.

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 = 0
const 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)))
)

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

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 = 0
const counter = Effect.sync(() => ++n)
const b = Effect.repeat(counter, { until: (value) => value >= 3 })
Effect.runPromise(b).then(console.log)
// => runs until `counter` returns 3

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 = 0
const 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)

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)

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 = 0
const 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 schedule

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 base Schedule to drive the recurrence.
  • times — cap the number of additional runs (like Schedule.recurs).
  • while — keep going while the predicate holds (true).
  • until — stop as soon as the predicate holds (true); the inverse of while.

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.