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.