Skip to content
LogoLogo

Schedule cookbook

These recipes use ordinary functions as repeatable programs. Each recipe bounds its attempts or checks, and a successful request stops retrying immediately. Schedule values and duration calculations work exactly as in Effect.

Honoring server retry hints

// run: provide TestClock.layer() { const worker = fork download(); TestClock.adjust("5 seconds"); Console.log(join worker) }
import { TestClock } from "effect/testing"
 
error Throttled {
  retryAfter: Duration.Duration
}
 
function download() {
  let calls = 0
  const policy = pipe(
    Schedule.exponential("500 millis"),
    Schedule.setInputType<Throttled>(),
    Schedule.modifyDelay(({ duration, input }) =>
      duration.max(input.retryAfter)),
    Schedule.upTo({ times: 3 })
  )
  return retry(policy) {
    calls++
    if (calls < 3) {
      throw new Throttled({ retryAfter: Duration.seconds(2) })
    }
    return "archive ready"
  }
}
// string!Throttled!
// Output: archive ready

Parse the response's retry hint into a duration at the HTTP boundary. The policy compares that duration with the backoff for the same failure, so the first two waits above are two seconds each. Three permitted retries mean at most four requests, and exhausting them propagates the last Throttled failure.

Polling with response-dependent delays

// run: provide TestClock.layer() { const worker = fork waitForArchive(); TestClock.adjust("1 second"); Console.log(join worker) }
type Status =
  | { _tag: "Pending"; pollAfter: Duration.Duration }
  | { _tag: "Ready"; path: string }
 
error PollLimit
 
function waitForArchive() {
  let checks = 0
  function check(): Status!! {
    checks++
    return checks < 3
      ? { _tag: "Pending", pollAfter: Duration.millis(checks * 300) }
      : { _tag: "Ready", path: "/archives/week.csv" }
  }
  const delay = pipe(
    Schedule.recurs(5),
    Schedule.setInputType<Status>(),
    Schedule.modifyDelay(({ input }) =>
      input._tag === "Pending" ? input.pollAfter : Duration.zero)
  )
  const result = repeat(check(), {
    schedule: delay,
    until: (status) => status._tag === "Ready"
  })
  if (result._tag === "Pending") throw new PollLimit()
  return result.path
}
// string!PollLimit!
// Output: /archives/week.csv

A pending response is a success, so it feeds repeat rather than retry. This simulation waits 300 milliseconds, then 600 milliseconds, and returns the path on its third check. The count limit permits at most six checks; reaching it with another pending result becomes PollLimit.

Bounding the whole retry operation

// run: provide TestClock.layer() { const worker = fork Effect.result(() => readWithDeadline()); TestClock.adjust("1 second"); const result = join worker; if (result.isFailure()) Console.log(result.failure._tag) }
error Offline
 
function readWithDeadline() {
  return pipe(
    () => { throw new Offline() },
    retry(Schedule.exponential("80 millis").upTo({ times: 5 })),
    timeout("350 millis")
  )
}
// Output: TimeoutError

The outer timeout covers the first attempt, every wait, and every retry. Placing a timeout inside the retried program instead gives each attempt a separate budget. A schedule's elapsed-duration limit is checked only at its steps and cannot interrupt an attempt already in progress; a timeout interrupts the work and waits for its cleanup.

Switching from rapid to slower retries

// run: provide TestClock.layer() { const worker = fork reconnect(); TestClock.adjust("5 seconds"); Console.log(join worker) }
error Disconnected
 
function reconnect() {
  let attempts = 0
  const phases = Schedule.spaced("200 millis").upTo({ times: 2 }).concat(Schedule.spaced("4 seconds").upTo({ times: 3 }))
  return retry(phases) {
    attempts++
    if (attempts < 4) throw new Disconnected()
    return "connected"
  }
}
// Output: connected

The first two retries use short waits; later retries use four second waits. This simulation succeeds on attempt four, after entering the slow phase. If every attempt fails, the two phases allow five retries plus the initial attempt.

Recording retry decisions

// run: provide TestClock.layer() { const worker = fork observedRequest(); TestClock.adjust("1 second"); Console.log(join worker) }
error Unavailable {
  status: number
}
 
function observedRequest() {
  let attempts = 0
  const policy = pipe(
    Schedule.exponential("250 millis"),
    Schedule.setInputType<Unavailable>(),
    Schedule.upTo({ times: 3 }),
    Schedule.tap(({ attempt, duration, input }) => {
      Console.log("retry planned", {
        retry: attempt,
        status: input.status,
        delayMs: duration.toMillis()
      })
    })
  )
  return retry(policy) {
    attempts++
    if (attempts < 3) throw new Unavailable({ status: 503 })
    return "accepted"
  }
}
// Output: retry planned { retry: 1, status: 503, delayMs: 250 }
// Output: retry planned { retry: 2, status: 503, delayMs: 500 }
// Output: accepted

Put tap after the limit so it records accepted retries only. The retry number starts at one and describes the next planned attempt, before its wait begins. Telemetry failures also fail the schedule, so handle an export failure inside the callback when it must not stop the request.


The matching Effect page: effect.website/docs/v4/scheduling/cookbook