Skip to content
Effect Days 2026 Get your ticket
Docs menu / Schedule cookbook

Schedule cookbook

This page contains complete examples of common retrying and scheduling problems. Each recipe explains when the program stops.

For an introduction to Effect.repeat, Effect.schedule, and Effect.retry, start with Using schedules. To choose, limit, or combine schedules, see Choosing and combining schedules.

Several examples use TestClock to advance time without waiting. They fork the program while it sleeps, advance the clock, then join the fiber to get its result.

Follow Retry-After

A rate-limited server can return a different Retry-After value with every failed request. The client must compare that value with the backoff for the same retry and wait for whichever is longer.

Schedule.max compares delays produced by two schedules. Retry-After is not a second schedule. It is data carried by the current RateLimited error. Schedule.modifyDelay provides both values to its callback: duration is the delay selected by the backoff, and input.retryAfter is the server’s delay.

Example (Respecting the server’s retry delay)

An application downloads a report. The simulated server returns Retry-After: 2 on the first three requests, then allows the download.

import { Clock, Data, Duration, Effect, Fiber, Schedule } from "effect"
import { TestClock } from "effect/testing"
class RateLimited extends Data.TaggedError("RateLimited")<{
readonly retryAfter: Duration.Duration
}> {}
// Simulated HTTP responses
const requestTimes: Array<number> = []
const downloadReport = Effect.gen(function* () {
requestTimes.push(yield* Clock.currentTimeMillis)
if (requestTimes.length <= 3) {
return yield* Effect.fail(
new RateLimited({ retryAfter: Duration.seconds(2) }),
)
}
return "downloaded"
})
const retryPolicy = Schedule.exponential("1 second").pipe(
// Give the callback's input the type of the request error.
Schedule.setInputType<RateLimited>(),
Schedule.modifyDelay(({ duration, input }) =>
Effect.succeed(Duration.max(duration, input.retryAfter)),
),
Schedule.upTo({ times: 4 }),
)
const program = downloadReport.pipe(Effect.retry(retryPolicy))
// Run with a test clock to verify the request times.
const test = Effect.gen(function* () {
const fiber = yield* Effect.forkChild(program)
yield* TestClock.adjust("8 seconds")
const result = yield* Fiber.join(fiber)
requestTimes // => [0, 2000, 4000, 8000]
return result
})
const result = await Effect.runPromise(Effect.provide(test, TestClock.layer()))
result // => "downloaded"

For the three retries in this example, the selected delays are:

RetryBackoffServer delaySelected delay
11 s2 s2 s
22 s2 s2 s
34 s2 s4 s

Schedule.upTo({ times: 4 }) allows at most five requests, including the initial one. Success stops the retries. If all five requests are rate-limited, the Effect fails with the last RateLimited error.

Parse Retry-After when handling the HTTP response and store the resulting duration in the error. The header can contain a number of seconds or an HTTP date. The schedule only needs the duration.

Poll at a server-directed interval

Like Retry-After, a polling response can tell the client how long to wait. Here, however, Pending is a successful response. Effect.repeat passes it to the schedule, and Schedule.modifyDelay uses its pollAfter value for the next wait.

Example (Waiting for an export to finish)

An application polls an existing export. The simulated server asks it to wait one second, then two seconds, before returning a download URL.

import { Clock, Duration, Effect, Fiber, Schedule } from "effect"
import { TestClock } from "effect/testing"
interface Ready {
readonly _tag: "Ready"
readonly downloadUrl: string
}
type ExportStatus =
{ readonly _tag: "Pending"; readonly pollAfter: Duration.Duration } | Ready
// Simulated export status responses
const checkTimes: Array<number> = []
const getExportStatus = Effect.gen(
function* (): Effect.gen.Return<ExportStatus> {
checkTimes.push(yield* Clock.currentTimeMillis)
if (checkTimes.length === 1) {
return { _tag: "Pending", pollAfter: Duration.seconds(1) }
}
if (checkTimes.length === 2) {
return { _tag: "Pending", pollAfter: Duration.seconds(2) }
}
return { _tag: "Ready", downloadUrl: "/exports/orders.csv" }
},
)
const pollingDelay = Schedule.recurs(9).pipe(
Schedule.setInputType<ExportStatus>(),
Schedule.modifyDelay(({ input }) =>
Effect.succeed(input._tag === "Pending" ? input.pollAfter : Duration.zero),
),
)
const program = getExportStatus.pipe(
Effect.repeat({
schedule: pollingDelay,
until: (status) => status._tag === "Ready",
}),
Effect.filterOrFail(
(status): status is Ready => status._tag === "Ready",
() => "Export did not become ready",
),
)
// Run with a test clock to verify the polling intervals.
const test = Effect.gen(function* () {
const fiber = yield* Effect.forkChild(program)
yield* TestClock.adjust("3 seconds")
const result = yield* Fiber.join(fiber)
checkTimes // => [0, 1000, 3000]
return result
})
const ready = await Effect.runPromise(Effect.provide(test, TestClock.layer()))
ready.downloadUrl // => "/exports/orders.csv"

The first check runs immediately. Each Pending response supplies the next delay. The first Ready response stops polling without another wait.

Schedule.recurs(9) allows at most ten checks, including the initial one. If the result is still Pending at that point, Effect.filterOrFail turns it into a failure.

Set a deadline for the entire retry operation

A schedule decides whether another retry may start. Schedule.upTo({ duration }) checks elapsed time only when the schedule prepares the next retry, so it cannot interrupt an attempt or scheduled wait already in progress.

Wrap the complete retrying Effect in Effect.timeout when the initial attempt, scheduled waits, and retries must share a hard deadline.

Example (Limiting an inventory lookup to 250 milliseconds)

An application retries an unavailable inventory service, but stops waiting after 250 milliseconds.

import { Data, Effect, Schedule } from "effect"
class ServiceUnavailable extends Data.TaggedError("ServiceUnavailable") {}
// Simulate an inventory service that remains unavailable.
const loadInventory = Effect.fail(new ServiceUnavailable())
const retryPolicy = Schedule.exponential("100 millis").pipe(
Schedule.upTo({ times: 4 }),
)
const program = loadInventory.pipe(
Effect.retry(retryPolicy),
// Apply the timeout to the whole operation, including waits.
Effect.timeout("250 millis"),
)
const error = await Effect.runPromise(Effect.flip(program))
error._tag // => "TimeoutError"

A successful response ends the program. Otherwise, it fails when the timeout expires or the retry limit is reached, whichever happens first. In this example, the timeout expires during the wait for another retry.

The position of Effect.timeout determines what it limits:

PlacementTime budget applies to
After Effect.retryThe entire operation, including waits
Before Effect.retryEach individual attempt

Effect.timeout interrupts the retrying Effect when the deadline expires. See Timing out for details.

Retry quickly, then slow down

A service may recover quickly from a brief interruption, but frequent retries should not continue during a longer outage. Use Schedule.concat to retry quickly a few times, then switch to a slower interval.

Example (Reconnecting after a database restart)

An application reconnects while its database restarts. The simulated connection fails four times, so the successful attempt uses the slower phase.

import { Data, Effect, Schedule } from "effect"
// Simulated connection attempts
import { Clock, Fiber } from "effect"
import { TestClock } from "effect/testing"
class ConnectionError extends Data.TaggedError("ConnectionError") {}
const attemptTimes: Array<number> = []
const connect = Effect.gen(function* () {
attemptTimes.push(yield* Clock.currentTimeMillis)
if (attemptTimes.length < 5) {
return yield* Effect.fail(new ConnectionError())
}
return "connected"
})
const quickRetries = Schedule.spaced("100 millis").pipe(
Schedule.upTo({ times: 3 }),
)
const slowRetries = Schedule.spaced("5 seconds").pipe(
Schedule.upTo({ times: 10 }),
)
const reconnect = quickRetries.pipe(Schedule.concat(slowRetries))
const program = connect.pipe(Effect.retry(reconnect))
// Run with a test clock to verify the switch to the slower phase.
const test = Effect.gen(function* () {
const fiber = yield* Effect.forkChild(program)
yield* TestClock.adjust("5300 millis")
const result = yield* Fiber.join(fiber)
attemptTimes // => [0, 100, 200, 300, 5300]
return result
})
const result = await Effect.runPromise(Effect.provide(test, TestClock.layer()))
result // => "connected"

After the initial immediate attempt, the policy allows these retries:

PhaseMaximum retriesWait before each retry
Quick3100 ms
Slow105 s

This is the same policy used in Choosing and combining schedules. It allows at most fourteen connection attempts in total. A successful connection stops immediately. If every attempt fails, the Effect fails with the last ConnectionError.

Record scheduled retries

Use Schedule.tap to record a retry after the policy accepts it and before the wait begins. Place it after the retry limit so it records only permitted retries, together with the error and selected delay.

Example (Recording retries of an invoice request)

A simulated invoice request fails twice with HTTP 502, then succeeds. An array collects the retry events so you can inspect their contents.

import { Data, Duration, Effect, Schedule } from "effect"
class GatewayError extends Data.TaggedError("GatewayError")<{
readonly status: number
}> {}
// Simulated invoice requests
let attempts = 0
const fetchInvoice = Effect.suspend(() => {
attempts++
return attempts < 3
? Effect.fail(new GatewayError({ status: 502 }))
: Effect.succeed({ id: "inv_123", status: "Paid" })
})
const events: Array<{ retry: number; status: number; delayMs: number }> = []
const observedRetries = Schedule.exponential("100 millis").pipe(
Schedule.setInputType<GatewayError>(),
Schedule.upTo({ times: 4 }),
Schedule.tap(({ attempt, duration, input }) =>
Effect.sync(() => {
events.push({
retry: attempt,
status: input.status,
delayMs: Duration.toMillis(duration),
})
}),
),
)
const program = fetchInvoice.pipe(Effect.retry(observedRetries))
await Effect.runPromise(program)
events[0] // => { retry: 1, status: 502, delayMs: 100 }
events[1] // => { retry: 2, status: 502, delayMs: 200 }
events.length // => 2

attempt counts retries starting at one. The event for retry 1 therefore precedes the second execution of fetchInvoice. The callback runs before the wait, so an event describes a planned retry, not a request that has already run.

The policy permits four retries, for at most five requests. Success stops the retries. If all requests fail, the Effect fails with the last GatewayError.

Replace the array with the application’s logger or metrics service. A failure in the tap callback also fails the retrying Effect. Handle telemetry errors inside the callback if they must not stop the request.