Skip to content

Fallback & Retry

Recovering from an error doesn’t always mean producing a value — often you want to try again, fall back to an alternative, or give up after a deadline. Effect ships these resilience patterns as composable combinators built on top of Schedule.

import { Effect, Schedule, Schema } from "effect"
class FlakyError extends Schema.TaggedErrorClass<FlakyError>()("FlakyError", {
attempt: Schema.Number
}) {}
declare const callFlakyApi: Effect.Effect<string, FlakyError>
const resilient = callFlakyApi.pipe(
// Retry up to 3 times with exponential backoff starting at 100ms.
Effect.retry({
schedule: Schedule.exponential("100 millis"),
times: 3
}),
// If every retry is exhausted, fall back to a cached value.
Effect.orElseSucceed(() => "cached response"),
// Bound the whole thing: if it isn't done in 5 seconds, run a fallback.
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.succeed("timed out, using default")
})
)

Effect.retry re-runs an effect when it fails. The simplest form just bounds the number of attempts; pass a Schedule to control the timing.

import { Effect, Schedule } from "effect"
declare const request: Effect.Effect<string, Error>
// Retry a fixed number of times, immediately.
const fixed = request.pipe(Effect.retry({ times: 3 }))
// Retry with exponential backoff — the idiomatic policy for network calls.
const backoff = request.pipe(
Effect.retry(Schedule.exponential("200 millis"))
)
// Retry only while a condition holds (e.g. only on transient errors).
const conditional = request.pipe(
Effect.retry({
times: 5,
while: (error) => error.message.includes("timeout")
})
)

retry accepts either a Schedule directly or an options object. The options object has these keys, all optional:

  • schedule — a Schedule controlling timing and stopping. Combine with the others to layer a recurrence limit or predicate on top of a delay policy.
  • times — a maximum number of retries after the initial attempt (the source effect always runs once first; { times: 3 } means up to four total runs).
  • while — keep retrying only while this predicate returns true for the error. As soon as it returns false, the current failure propagates. May return a boolean or an Effect<boolean>.
  • until — the inverse: stop retrying once this predicate returns true. Also accepts an Effect<boolean>.
import { Effect, Schedule } from "effect"
class RateLimited { readonly _tag = "RateLimited" }
class Fatal { readonly _tag = "Fatal" }
declare const call: Effect.Effect<string, RateLimited | Fatal>
// Combine a delay policy with a recurrence cap and a predicate.
const tuned = call.pipe(
Effect.retry({
schedule: Schedule.exponential("100 millis"),
times: 5,
// Stop immediately on a fatal error; keep retrying rate limits.
until: (error) => error._tag === "Fatal"
})
)

When all retries are exhausted, the effect fails with the last error. To run a recovery effect instead, use Effect.retryOrElse. Its orElse callback receives the final error and the schedule’s last output:

import { Effect, Schedule } from "effect"
declare const request: Effect.Effect<string, Error>
const withFallback = request.pipe(
Effect.retryOrElse(
Schedule.recurs(3),
// (error, scheduleOutput) => fallback effect, run once retries are exhausted.
(error, _out) => Effect.succeed(`giving up: ${error.message}`)
)
)

For the full menu of timing policies (jitter, fixed windows, intersecting and unioning schedules), see Schedule and Repetition & Retry.

There is no bare Effect.orElse / Effect.orElseFail in v4. The two fallback primitives are orElseSucceed (replace failure with a constant) and firstSuccessOf (try a list of effects in order). To run another effect on failure, use Effect.catch and return the alternative effect from the handler — that is the idiomatic “try plan B”.

Replaces any failure with a constant success value, guaranteeing the effect completes. The fallback is evaluated lazily.

import { Effect } from "effect"
declare const loadSetting: Effect.Effect<number, Error>
// Always yields a number; on failure, falls back to 8080.
const port = loadSetting.pipe(Effect.orElseSucceed(() => 8080))
// => Effect<number, never>

Runs a sequence of effects and returns the first that succeeds, short-circuiting. If all fail, it fails with the error of the last effect.

import { Effect } from "effect"
declare const fromCache: Effect.Effect<string, Error>
declare const fromPrimary: Effect.Effect<string, Error>
declare const fromBackup: Effect.Effect<string, Error>
// Try cache, then primary, then backup — stops at the first success.
const value = Effect.firstSuccessOf([fromCache, fromPrimary, fromBackup])
// => Effect<string, Error>

Effect.catch — return an alternative effect

Section titled “Effect.catch — return an alternative effect”

When the fallback is itself effectful (or you want to inspect the error), catch the failure and return the next effect to try.

import { Effect } from "effect"
declare const fromPrimary: Effect.Effect<string, Error>
declare const fromBackup: Effect.Effect<string, Error>
// On any failure of the primary, switch to the backup effect.
const value = fromPrimary.pipe(
Effect.catch((error) => {
console.log(`primary failed: ${error.message}`)
return fromBackup
})
)
// => Effect<string, Error>

See Catching Errors for the full family (catch, catchTag, catchTags, catchCause, plus ignore / eventually).

Effect.repeat is the mirror image of retry: it re-runs an effect that succeeds, according to a schedule, and stops on the first failure. It’s the basis for polling and periodic work. Like retry, it accepts a Schedule or an options object with the same { schedule, times, while, until } shape (the predicates apply to the success value here, not the error).

import { Effect, Schedule } from "effect"
declare const poll: Effect.Effect<string, Error>
// Run `poll`, then repeat it 5 more times, spaced 1 second apart.
const polled = poll.pipe(
Effect.repeat({ schedule: Schedule.spaced("1 second"), times: 5 })
)
// Repeat only while the value is still "pending".
const untilReady = poll.pipe(
Effect.repeat({
schedule: Schedule.spaced("500 millis"),
while: (status) => status === "pending"
})
)

Effect.repeatOrElse is the success-side analogue of retryOrElse: it runs a fallback if repetition fails before the schedule completes. For more on building repetition policies, see Repetition & Retry.

Effect.timeout fails with a Cause.TimeoutError if the effect doesn’t finish in time, interrupting the in-flight work.

import { Effect } from "effect"
declare const slowQuery: Effect.Effect<string, Error>
// Adds `Cause.TimeoutError` to the error channel.
const bounded = slowQuery.pipe(Effect.timeout("2 seconds"))
// => Effect<string, Error | Cause.TimeoutError>

Two variants change what happens on timeout:

Succeeds with Option.none() instead of failing, modelling the timeout as absence rather than an error. A failure before the deadline is still preserved.

import { Effect } from "effect"
declare const slowQuery: Effect.Effect<string, Error>
// None means "timed out"; Some(value) means it finished in time.
const maybe = slowQuery.pipe(Effect.timeoutOption("2 seconds"))
// => Effect<Option<string>, Error>

Runs a fallback effect when the deadline is reached. The source effect is interrupted before the lazily-created fallback runs.

import { Effect } from "effect"
declare const slowQuery: Effect.Effect<string, Error>
const withFallback = slowQuery.pipe(
Effect.timeoutOrElse({
duration: "2 seconds",
orElse: () => Effect.succeed("cached result")
})
)
// => Effect<string, Error>

These combinators measure or shift an effect in time. The durations come from Clock, never from Date.now, so they’re deterministic under TestClock.

Pairs an effect’s result with how long it took to run. The original success, failure, or interruption is preserved; only the success value is wrapped.

import { Duration, Effect } from "effect"
declare const work: Effect.Effect<string, Error>
const measured = Effect.gen(function* () {
const [duration, value] = yield* Effect.timed(work)
// duration: Duration.Duration, value: string
console.log(`took ${Duration.toMillis(duration)}ms`)
return value
})
// Effect.timed(work) => Effect<[Duration, string], Error>

Delays the start of an effect by a duration, then runs it normally.

import { Console, Effect } from "effect"
const program = Console.log("hi").pipe(Effect.delay("1 second"))
// => waits 1 second, then logs "hi"

Suspends the current fiber for a duration without blocking a JavaScript thread — just a wait, with no value.

import { Console, Effect } from "effect"
const program = Effect.gen(function* () {
yield* Console.log("Start")
yield* Effect.sleep("2 seconds")
yield* Console.log("End") // 2 seconds later
})

These combinators compose freely. A common production shape is retry with backoff, bounded by an overall timeout, with a final fallback — exactly the resilient example at the top of this page. Build resilience in layers, from the innermost operation outward.