Scheduling #
Retry, repeat, and time out work with a Schedule: a value that describes when to try again.
The problem. Many codebases contain a retry loop like this one. Each one has small errors:
async function withRetry<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
for (let i = 0; i < retries; i++) {
try {
return await fn()
} catch (e) {
await new Promise((r) => setTimeout(r, 1000))
}
}
throw new Error("gave up")
}Is retries = 3 3 attempts or 3 retries? The code does not say. The delay is fixed, so 100 clients that hit a slow server all return at the same second. The loop retries a 404 in the same way as a network error. You cannot cancel the setTimeout, so a user who left the page still causes 3 more requests.
The policy is part of the loop. You cannot test it, use it for a different function, or add a timeout without a rewrite.
The shift
Today you think of a retry as a loop that waits. In Effect, a retry policy is a value. The value describes when to try again. This value is a Schedule. A schedule knows 2 things: how long to wait before the next attempt, and if there is a next attempt at all.
A schedule is data. You build it once, you combine it with pipe, and you give it to Effect.retry or Effect.repeat. The same Schedule works for 3 tasks: retry an effect that fails, repeat an effect that succeeds, and poll a job status.
The wait goes through the Effect clock, so you can interrupt it. If you interrupt the parent fiber, the retry stops in the middle of the wait. The effect that you retry is a description, so "try again" means "run the description again". There are no closures or flags to reset. Attempt limits, backoff, jitter, and "retry only these errors" each take 1 line.
| Function | Runs the effect again when... | Use it when |
|---|---|---|
Effect.retry(effect, policy) |
It fails | The error is temporary: network, locks, rate limits |
Effect.repeat(effect, policy) |
It succeeds | You poll, you send heartbeats, or you do a task N times |
Effect.schedule(effect, schedule) |
It succeeds; the value is ignored | The work is periodic and the result does not matter |
Effect.timeout(effect, duration) |
Never; it stops the effect | The effect can hang |
In this section, each program counts attempts in a Ref and prints the count. It never prints the elapsed time, so the output is the same on each machine.
Learn #
Lesson 1. Retry with a count #
Start with the simplest policy: try again a fixed number of times. In plain TypeScript this needs a loop, a counter, and a rethrow:
let lastError
for (let i = 0; i < 3; i++) {
try { return await flaky() } catch (e) { lastError = e }
}
throw lastErrorIn Effect, Effect.retry(effect, { times: 2 }) does the same work. The name is precise: times is the number of retries after the first attempt. times: 2 means a maximum of 3 runs. If the last retry also fails, you get that failure back.
The effect that you retry is a description, so a new run needs no reset logic. The Ref in this program is only a counter. We use it to print the number of attempts.
import { Effect, Ref } from "effect"
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
// Fails on the first two calls, succeeds on the third
const flaky = Effect.gen(function* () {
const n = yield* Ref.updateAndGet(attempts, (n) => n + 1)
if (n < 3) return yield* Effect.fail("connection reset")
return "payload"
})
// times: 2 means one attempt plus up to two retries
const result = yield* flaky.pipe(Effect.retry({ times: 2 }))
console.log("result:", result)
console.log("attempts:", yield* Ref.get(attempts))
// times: 1 is one retry too few for this task
yield* Ref.set(attempts, 0)
const outcome = yield* flaky.pipe(Effect.retry({ times: 1 }), Effect.result)
console.log("with times 1:", outcome._tag, "after", yield* Ref.get(attempts), "attempts")
})
Effect.runPromise(program)
Note: times: 2 gave 3 attempts. Each count in this section works in the same way. The first run always happens. The schedule only decides about the runs after it. Change the value to times: 0: the effect runs 1 time, with no retries.
Lesson 2. A Schedule is a value #
{ times: 2 } is a short form. The full form is a Schedule. A schedule is a value that you can hold in a variable, pass to a function, and combine with other schedules. Its type is Schedule<Output, Input>. Output is the value that the schedule produces at each step, for example a count or a delay. Input is the value that the schedule examines, for example the error when you retry.
| Constructor | Waits | Stops by itself? |
|---|---|---|
Schedule.recurs(n) |
No wait | After n recurrences |
Schedule.spaced("1 second") |
The same delay each time | No |
Schedule.exponential("1 second") |
1s, 2s, 4s, 8s... | No |
Schedule.fibonacci("1 second") |
1s, 2s, 3s, 5s, 8s... | No |
Schedule.forever |
No wait | No |
Caution: a schedule that does not stop by itself can run forever. Schedule.upTo({ times: n }) puts a limit on any schedule.
Schedule.tap shows you each decision: the attempt number and the delay that the schedule computed. The delay comes from a formula in the schedule, not from a clock. That is why it is safe to print it.
import { Duration, Effect, Ref, Schedule } from "effect"
// A schedule is data. Building it runs nothing.
const backoff = Schedule.exponential("1 millis").pipe(
Schedule.upTo({ times: 3 }), // at most 3 recurrences, then stop
Schedule.tap((meta) => // observe each decision the schedule makes
Effect.sync(() => console.log("retry", meta.attempt, "after", Duration.toMillis(meta.duration), "ms"))
)
)
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const alwaysFails = Effect.gen(function* () {
yield* Ref.update(attempts, (n) => n + 1)
return yield* Effect.fail("still down")
})
const outcome = yield* alwaysFails.pipe(Effect.retry(backoff), Effect.result)
console.log(outcome._tag, "after", yield* Ref.get(attempts), "attempts")
})
Effect.runPromise(program)
The program builds the schedule at the top of the file and uses it at the bottom. Replace Schedule.exponential with Schedule.fibonacci("1 millis"): the delays become 1, 2, 3. Remove upTo: the program never ends, because an exponential schedule does not stop by itself.
Lesson 3. Combine schedules #
A real policy combines several rules. One example: use exponential backoff, and stop after 5 retries. Also add a random part to each delay, so that clients do not retry at the same moment. Each rule is a schedule. Combinators merge them.
| Combinator | Continues while... | Delay that it uses |
|---|---|---|
Schedule.max([a, b]) |
Both schedules continue | The longer one |
Schedule.min([a, b]) |
One of the schedules continues | The shorter one |
Schedule.upTo({ times, duration }) |
The limit is not reached | Not changed |
Schedule.jittered |
Not changed | Multiplied by a random number from 0.8 to 1.2 |
Use max for "backoff with a limit on attempts". An exponential schedule continues forever. recurs(2) stops after 2 recurrences. Together they stop after 2. Use min for "backoff, but never wait longer than X". Pair an exponential schedule with spaced("5 seconds"): when the exponential delay becomes longer than 5 seconds, the shorter delay wins.
A schedule is a value, so you define a policy 1 time and apply it to many tasks. The program below applies 1 policy to 2 different tasks.
import { Effect, Ref, Schedule } from "effect"
// One policy, defined once: exponential backoff, at most 2 retries, jittered
const policy = Schedule.max([
Schedule.exponential("1 millis"), // 1 ms, 2 ms, 4 ms...
Schedule.recurs(2) // ...but stop after 2 recurrences
]).pipe(Schedule.jittered) // randomize each delay so clients spread out
// A task that fails until attempt number succeedAt
const makeTask = (name: string, succeedAt: number) =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const task = Effect.gen(function* () {
const n = yield* Ref.updateAndGet(attempts, (n) => n + 1)
if (n < succeedAt) return yield* Effect.fail(name + " unavailable")
return name + " ok"
})
const outcome = yield* task.pipe(Effect.retry(policy), Effect.result)
console.log(name + ":", outcome._tag, "after", yield* Ref.get(attempts), "attempts")
})
const program = Effect.gen(function* () {
yield* makeTask("database", 99) // never recovers: policy gives up
yield* makeTask("cache", 2) // recovers on attempt 2
})
Effect.runPromise(program)
jittered changes the delays at random, but it does not change the number of attempts. That is why the output is stable. Change max to min: the policy now continues while one schedule continues. The exponential schedule never stops, so the "database" task retries forever. That is the difference between the 2 combinators.
Lesson 4. Retry only the errors that are temporary #
A retry of a 404 with backoff wastes seconds and hides a bug. A retry policy must examine the error and decide. Effect gives you 2 ways.
The options form accepts a while or until predicate on the error, next to times. Example: Effect.retry(effect, { times: 5, while: (e) => e._tag === "Unavailable" }). When the predicate returns false, the retry stops, even if attempts remain.
The schedule form uses Schedule.while. This function receives the schedule metadata. The error is in meta.input. A plain Schedule.recurs(5) does not know its input type; the type is unknown. For this reason, Effect.retry also accepts a builder function: Effect.retry(($) => $(schedule).pipe(...)). The $ helper puts the error type of the effect on the schedule, so input._tag compiles.
Tagged errors make both forms easy to read. Real code uses tagged errors in any case.
import { Data, Effect, Ref, Schedule } from "effect"
class Unavailable extends Data.TaggedError("Unavailable")<{}> {}
class NotFound extends Data.TaggedError("NotFound")<{ readonly id: number }> {}
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const fetchUser = (id: number) =>
Effect.gen(function* () {
const n = yield* Ref.updateAndGet(attempts, (n) => n + 1)
if (id === 404) return yield* new NotFound({ id }) // permanent: do not retry
if (n < 3) return yield* new Unavailable() // transient: retry
return "user-" + id
})
// Options form: keep retrying while the error is transient
const a = yield* fetchUser(1).pipe(
Effect.retry({ times: 5, while: (e) => e._tag === "Unavailable" })
)
console.log(a, "after", yield* Ref.get(attempts), "attempts")
// Schedule form: $ gives the schedule the effect's error type, so input._tag compiles
yield* Ref.set(attempts, 0)
const b = yield* fetchUser(404).pipe(
Effect.retry(($) =>
$(Schedule.recurs(5)).pipe(Schedule.while(({ input }) => input._tag === "Unavailable"))
),
Effect.result
)
console.log(b._tag, "after", yield* Ref.get(attempts), "attempt")
})
Effect.runPromise(program)
The NotFound call failed after 1 attempt. The policy permitted 5 attempts, but the predicate stopped it. Remove the while option and call fetchUser(404) with the options form: the program makes 6 attempts, and each one fails. The predicate is what makes a retry useful.
Lesson 5. Repeat: the same schedule after success #
Effect.retry runs the effect again after a failure. Effect.repeat runs the effect again after a success. Both accept the same schedule values. repeat is the tool for polls, heartbeats, and "do this 5 times".
Two rules apply. First, the count rule is the same: the effect runs 1 time, then the schedule decides about more runs. Schedule.recurs(2) means 3 runs. Second, repeat returns the output of the schedule, not the last value of the effect. For recurs and spaced, the output is the number of recurrences.
Effect.schedule is a related function for work whose result you ignore. It has 1 difference: it asks the schedule before the first run also. With Effect.schedule, Schedule.recurs(2) means exactly 2 runs. Think of repeat as "run, then possibly run again" and of schedule as "run on this timetable".
For a poll, the options form has until and while on the success value. Example: Effect.repeat(check, { until: (s) => s === "done", schedule: Schedule.spaced("1 second") }). This runs check until the status is done, and returns that final status.
import { Effect, Ref, Schedule } from "effect"
const program = Effect.gen(function* () {
// repeat: one run plus 2 recurrences. The result is the schedule's output.
const ticks = yield* Ref.make(0)
const output = yield* Ref.update(ticks, (n) => n + 1).pipe(Effect.repeat(Schedule.recurs(2)))
console.log("ran", yield* Ref.get(ticks), "times, schedule output", output)
// schedule: the timetable is consulted before the first run too, so upTo 2 means 2 runs
const heartbeat = Effect.sync(() => console.log("tick"))
yield* heartbeat.pipe(Effect.schedule(Schedule.forever.pipe(Schedule.upTo({ times: 2 }))))
// Polling: repeat until the value says stop, waiting between checks
const polls = yield* Ref.make(0)
const checkJob = Ref.updateAndGet(polls, (n) => n + 1).pipe(
Effect.map((n) => (n < 3 ? "pending" : "done"))
)
const status = yield* checkJob.pipe(
Effect.repeat({ until: (s) => s === "done", schedule: Schedule.spaced("1 millis") })
)
console.log("job", status, "after", yield* Ref.get(polls), "polls")
})
Effect.runPromise(program)
Note the 2 counts: repeat with recurs(2) ran 3 times, and schedule with upTo({ times: 2 }) ran 2 times. If the effect fails, repeat stops and returns the failure. It never retries. That is the full difference: retry reacts to a failure, repeat reacts to a success. Change until to while in the poll: it stops after the first "pending".
Lesson 6. Timeouts, and retry with a timeout #
A timeout is the opposite of a retry. It does not run the effect again; it stops the effect. When the time limit wins, Effect interrupts the effect. The effect does not continue in the background like an abandoned Promise.
| Function | On timeout | Type of the result |
|---|---|---|
Effect.timeout(d) |
Fails with TimeoutError |
Effect<A, E | TimeoutError> |
Effect.timeoutOption(d) |
Succeeds with Option.none() |
Effect<Option<A>, E> |
Effect.timeoutOrElse({ duration, orElse }) |
Runs the fallback effect | Effect<A | B, E | E2> |
TimeoutError is a tagged error. Effect.catchTag("TimeoutError", ...) catches it like any other tagged error. If you do not catch it, it stays in the error type.
When you combine a retry and a timeout, the order is important. A timeout inside the retry gives each attempt its own time limit. Effect stops an attempt that hangs and then retries. A timeout outside the retry puts 1 time limit on all attempts together. Both orders are valid, but they mean different things. The pipe order shows which one you chose.
import { Effect, Option, Ref } from "effect"
const slow = Effect.sleep("50 millis").pipe(Effect.as("slow answer"))
const fast = Effect.succeed("fast answer")
const program = Effect.gen(function* () {
console.log(yield* fast.pipe(Effect.timeout("10 millis")))
const b = yield* slow.pipe(
Effect.timeout("2 millis"),
Effect.catchTag("TimeoutError", () => Effect.succeed("timed out"))
)
console.log(b)
const c = yield* slow.pipe(Effect.timeoutOption("2 millis"))
console.log(Option.isNone(c) ? "none" : "some")
const d = yield* slow.pipe(
Effect.timeoutOrElse({ duration: "2 millis", orElse: () => Effect.succeed("cached answer") })
)
console.log(d)
// timeout INSIDE retry: every attempt gets 2 ms, then the next attempt starts
const attempts = yield* Ref.make(0)
const e = yield* Ref.update(attempts, (n) => n + 1).pipe(
Effect.andThen(slow),
Effect.timeout("2 millis"),
Effect.retry({ times: 2 }),
Effect.catchTag("TimeoutError", () => Effect.succeed("gave up"))
)
console.log(e, "after", yield* Ref.get(attempts), "attempts")
})
Effect.runPromise(program)
Move Effect.timeout("2 millis") below Effect.retry({ times: 2 }). The 2 ms limit now applies to all attempts together. Effect interrupts the first attempt, and the count becomes 1. The functions are the same, but the order makes a different program.
Lesson 7. Jitter and cron #
This lesson adds 2 tools: a random delay and a calendar.
Jitter. 100 clients lose a connection at the same second. Each client retries with the same backoff. All 100 return at the same moment, and the server fails again. The name for this is a retry storm. Schedule.jittered multiplies each delay by a random factor between 0.8 and 1.2, so the clients spread out. Jitter changes only the delay. The number of attempts, the limit, and the output of the schedule stay the same. The program prints the attempt number from Schedule.tap, not the delay, because the delay is random.
Cron. A cron expression describes points in time on a calendar, for example "02:00 every day". The Cron module parses and tests expressions. An expression has 6 fields: second, minute, hour, day of month, month, and day of week. Note: with 5 fields, the seconds field is 0. Give a time zone as the second argument. Without it, the expression uses the local time zone of the machine.
| Function | Does | Returns |
|---|---|---|
Cron.parse(text, tz) |
parses the text | Result<Cron, CronParseError> |
Cron.parseUnsafe(text, tz) |
parses the text | Cron, or it throws |
Cron.match(cron, date) |
tests 1 date | boolean |
Cron.next(cron, date) |
finds the first match after the date | Date |
Schedule.cron(cron) |
makes a schedule from the cron | Schedule<Duration> |
Schedule.cron reads the clock and waits until the next match. The delay depends on the real time, so the program does not run this schedule. It only shows that the schedule is a value. Give it to Effect.schedule in a real service.
import { Cron, Effect, Ref, Result, Schedule } from "effect"
// jittered: the schedule multiplies each delay by a random factor from 0.8 to 1.2
const policy = Schedule.exponential("1 millis").pipe(
Schedule.upTo({ times: 3 }),
Schedule.jittered,
Schedule.tap((meta) => Effect.sync(() => console.log("retry", meta.attempt)))
)
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const alwaysFails = Ref.update(attempts, (n) => n + 1).pipe(Effect.andThen(Effect.fail("down")))
const outcome = yield* alwaysFails.pipe(Effect.retry(policy), Effect.result)
console.log(outcome._tag, "after", yield* Ref.get(attempts), "attempts")
// cron: 6 fields = second minute hour day month weekday. Here: 02:00:00 every day, UTC.
const nightly = Cron.parseUnsafe("0 0 2 * * *", "UTC")
console.log("02:00 matches:", Cron.match(nightly, "2024-03-10T02:00:00Z"))
console.log("02:30 matches:", Cron.match(nightly, "2024-03-10T02:30:00Z"))
console.log("next after 10 Mar 05:00:", Cron.next(nightly, "2024-03-10T05:00:00Z").toISOString())
// A bad expression is a value too: parse returns a Result, it does not throw
console.log("parse '* * bad':", Result.isFailure(Cron.parse("* * bad")) ? "Failure" : "Success")
// Schedule.cron turns a Cron into a Schedule. Its delay is the time until the next match.
const nightlyPolicy = Schedule.cron(nightly)
console.log("schedule:", Schedule.isSchedule(nightlyPolicy))
})
Effect.runPromise(program)
Remove Schedule.jittered: the output is the same, because jitter changes only the delays. Change the expression to "0 30 2 * * 1-5", which means 02:30 on Monday to Friday. The 10 March 2024 is a Sunday, so the next match is Monday 11 March at 02:30, and the 02:00 test becomes false.
Do and don't #
| Do | Don't | Why |
|---|---|---|
Count times as the retries after the first attempt. | Do not count times as the total number of attempts. | times: 3 runs the effect 4 times, and a service with a limit of 3 calls blocks the client. |
Put a limit on spaced, exponential, and fibonacci with Schedule.upTo, or with Schedule.max and Schedule.recurs. | Do not use a delay schedule by itself. | A delay schedule does not stop, so a service that never recovers makes the program retry forever. |
Use Effect.retry to run again after a failure and Effect.repeat to run again after a success. | Do not use Effect.repeat to retry an effect that fails. | repeat stops on the first failure, so the schedule makes no decision and the error goes to the caller. |
Filter the errors with the while option or with Schedule.while, and retry only the temporary errors. | Do not retry each error. | A retry of a NotFound error wastes each attempt and hides a bug in the caller. |
Use the builder form, Effect.retry(($) => $(schedule).pipe(...)), when Schedule.while reads input. | Do not pipe Schedule.while onto a schedule that has no input type. | The input type is unknown, so input._tag does not compile. |
Catch TimeoutError with Effect.catchTag, or use Effect.timeoutOption or Effect.timeoutOrElse. | Do not declare a return type without TimeoutError after Effect.timeout. | timeout adds TimeoutError to the error channel, and the compiler rejects a type that does not include it. |
Put Effect.timeout before Effect.retry in the pipe when each attempt needs its own time limit. | Do not put the timeout after the retry when each attempt needs its own time limit. | A timeout after the retry applies to all attempts together, so Effect interrupts the first slow attempt and no retry happens. |
Test a cron expression with Cron.next on a fixed date before you give it to Schedule.cron. | Do not count the fields of a cron expression from memory. | With 6 fields the first field is the second, so 0 2 * * * * runs every hour at minute 2, not at 02:00. |
Fix it #
Each program below is broken or incomplete. Make it print the expected output with zero type errors. Use hints before the solution.
1. One attempt too many #
The service permits exactly 3 calls per request. After that, it blocks the client. This program makes 4 calls. Change the retry policy so that the task runs exactly 3 times and the program prints attempts: 3.
import { Effect, Ref } from "effect"
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const alwaysFails = Effect.gen(function* () {
yield* Ref.update(attempts, (n) => n + 1)
return yield* Effect.fail("unavailable")
})
yield* alwaysFails.pipe(Effect.retry({ times: 3 }), Effect.ignore)
console.log("attempts:", yield* Ref.get(attempts))
})
Effect.runPromise(program)
2. Wrong function #
The task fails 2 times and then succeeds. The program reports a failure after 1 attempt. The policy is correct; the function that applies it is not. Change the program so that it prints payload after 3 attempts.
import { Effect, Ref, Schedule } from "effect"
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const flaky = Effect.gen(function* () {
const n = yield* Ref.updateAndGet(attempts, (n) => n + 1)
if (n < 3) return yield* Effect.fail("connection reset")
return "payload"
})
const outcome = yield* flaky.pipe(Effect.repeat(Schedule.recurs(5)), Effect.result)
if (outcome._tag === "Success") {
console.log("payload after", yield* Ref.get(attempts), "attempts")
} else {
console.log("failed after", yield* Ref.get(attempts), "attempt")
}
})
Effect.runPromise(program)
3. The timeout that is not in the type #
loadProfile declares that it cannot fail. The timeout that it applies adds a failure, and the declared type does not include it. The file does not compile. Keep the type and the timeout. Catch the timeout so that the function returns "cached profile" when the time limit wins. The program must print exactly that text.
import { Effect } from "effect"
const fetchProfile = Effect.sleep("50 millis").pipe(Effect.as("fresh profile"))
const loadProfile = (): Effect.Effect<string> =>
fetchProfile.pipe(Effect.timeout("2 millis"))
Effect.runPromise(loadProfile()).then(console.log)
4. The schedule does not know its input #
The policy must retry only Unavailable errors. The file does not compile: inside Schedule.while, input has no known type. Change how the program passes the schedule to Effect.retry, so that the schedule gets the error type of the effect. The program must print NotFound after 1 attempt.
import { Data, Effect, Ref, Schedule } from "effect"
class Unavailable extends Data.TaggedError("Unavailable")<{}> {}
class NotFound extends Data.TaggedError("NotFound")<{ readonly id: number }> {}
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const fetchUser = (id: number) =>
Effect.gen(function* () {
const n = yield* Ref.updateAndGet(attempts, (n) => n + 1)
if (id === 404) return yield* new NotFound({ id })
if (n < 3) return yield* new Unavailable()
return "user-" + id
})
const outcome = yield* fetchUser(404).pipe(
Effect.retry(
Schedule.recurs(5).pipe(Schedule.while(({ input }) => input._tag === "Unavailable"))
),
Effect.result
)
const label = outcome._tag === "Success" ? outcome.success : outcome.failure._tag
console.log(label, "after", yield* Ref.get(attempts), "attempt")
})
Effect.runPromise(program)
5. No limit #
The policy calls the service again and again until the service answers on the 50th call. The requirement is different: use backoff, but stop after 3 retries. Change the schedule so that the program prints gave up after 4 attempts.
import { Effect, Ref, Schedule } from "effect"
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const flaky = Effect.gen(function* () {
const n = yield* Ref.updateAndGet(attempts, (n) => n + 1)
if (n < 50) return yield* Effect.fail("unavailable")
return "ok"
})
const policy = Schedule.spaced("1 millis")
const outcome = yield* flaky.pipe(Effect.retry(policy), Effect.result)
const count = yield* Ref.get(attempts)
console.log(outcome._tag === "Success" ? "ok after" : "gave up after", count, "attempts")
})
Effect.runPromise(program)
6. Timeout in the wrong place #
Each call must get its own time limit of 2 ms, with a maximum of 2 retries. That means 3 attempts before the program gives up. At the moment, only 1 attempt happens. Change the order of the pipeline so that the program prints gave up, attempts: 3.
import { Effect, Ref } from "effect"
const slow = Effect.sleep("50 millis").pipe(Effect.as("answer"))
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const result = yield* Ref.update(attempts, (n) => n + 1).pipe(
Effect.andThen(slow),
Effect.retry({ times: 2 }),
Effect.timeout("2 millis"),
Effect.catchTag("TimeoutError", () => Effect.succeed("gave up"))
)
console.log(result + ", attempts:", yield* Ref.get(attempts))
})
Effect.runPromise(program)
7. Poll until, not while #
The poll must continue until the job reports done. It stops after the first check. Change the repeat options so that the program prints job done after 3 polls.
import { Effect, Ref, Schedule } from "effect"
const program = Effect.gen(function* () {
const polls = yield* Ref.make(0)
const checkJob = Ref.updateAndGet(polls, (n) => n + 1).pipe(
Effect.map((n) => (n < 3 ? "pending" : "done"))
)
const status = yield* checkJob.pipe(
Effect.repeat({ while: (s) => s === "done", schedule: Schedule.spaced("1 millis") })
)
console.log("job", status, "after", yield* Ref.get(polls), "polls")
})
Effect.runPromise(program)
8. The report that ran every hour #
The report must run 1 time per day, at 02:00 UTC. The program prints 2 runs that are 1 hour apart. Fix the cron expression. The program must print the next run as 2024-03-11T02:00:00.000Z and the run after it as 2024-03-12T02:00:00.000Z.
import { Cron } from "effect"
// The report must run once per day at 02:00 UTC
const report = Cron.parseUnsafe("0 2 * * * *", "UTC")
const start = "2024-03-10T05:00:00Z"
const first = Cron.next(report, start)
console.log("next run:", first.toISOString())
console.log("after that:", Cron.next(report, first).toISOString())
Build it #
Write the program from the spec. The output must match exactly.
1. User fetch with a retry policy #
Build loadUser(api, id) on top of the fake API in the starter. Add a retry policy:
api(id)fails withUnavailableon the first 2 calls for any id, then succeeds with a name. For id404, it fails withNotFoundeach time.- The policy: exponential backoff that starts at
"1 millis", a maximum of 3 retries, and only forUnavailableerrors. UseSchedule.maxorSchedule.upTofor the limit. Use the$builder form ofEffect.retry(or thewhileoption) for the error filter. loadUser(api, id)runs the call with the policy, counts the attempts in its ownRef, and prints 1 line per id in this exact format. On success, print the name. On failure, print the_tagof the error.
user 1: Ada (attempts: 3)
user 404: NotFound (attempts: 1)Call loadUser(api, 1) and then loadUser(api, 404).
import { Data, Effect, Ref, Schedule } from "effect"
class Unavailable extends Data.TaggedError("Unavailable")<{}> {}
class NotFound extends Data.TaggedError("NotFound")<{ readonly id: number }> {}
// Fake API: Unavailable for the first two calls of each id, NotFound for 404
const makeApi = Effect.gen(function* () {
const calls = yield* Ref.make<Record<number, number>>({})
return (id: number) =>
Effect.gen(function* () {
const n = yield* Ref.modify(calls, (c) => [(c[id] ?? 0) + 1, { ...c, [id]: (c[id] ?? 0) + 1 }])
if (id === 404) return yield* new NotFound({ id })
if (n <= 2) return yield* new Unavailable()
return "Ada"
})
})
// TODO: policy = exponential backoff, at most 3 retries, only for Unavailable
// TODO: loadUser(api, id): count attempts in a Ref, retry with the policy, print the line
const program = Effect.gen(function* () {
const api = yield* makeApi
// TODO: loadUser(api, 1) then loadUser(api, 404)
})
Effect.runPromise(program)
2. Job poll with a time limit #
Build pollJob(name, check). check is an effect that returns "pending" or "done".
- Poll with
Effect.repeat. Wait"1 millis"between checks. Stop when the status is"done". Count the polls in aRef. - Put a
"20 millis"timeout around the full poll loop. A job that never completes must not block the program. - Print
<name>: done after <n> pollson success. Print<name>: timed outwhen the time limit wins.
The starter gives 2 checks. build becomes done on its third check. deploy stays pending forever. Exact output:
build: done after 3 polls
deploy: timed outimport { Effect, Ref, Schedule } from "effect"
// build is done on its 3rd check; deploy is never done
const makeChecks = Effect.gen(function* () {
const buildChecks = yield* Ref.make(0)
const build = Ref.updateAndGet(buildChecks, (n) => n + 1).pipe(Effect.map((n) => (n >= 3 ? "done" : "pending")))
const deploy = Effect.succeed("pending")
return { build, deploy }
})
// TODO: pollJob(name, check): repeat until "done" with 1 ms spacing, 20 ms overall timeout, print the line
const program = Effect.gen(function* () {
const checks = yield* makeChecks
// TODO: pollJob("build", checks.build) then pollJob("deploy", checks.deploy)
})
Effect.runPromise(program)
3. Batch with a time limit per item #
Process a batch of items. Each item gets its own time limit and 1 retry.
work(item)is given. It sleeps for the delay of the item, in milliseconds, and returns"ok". Itembis slow.- For each item: apply a
"10 millis"timeout towork(item), then retry it 1 time (times: 1), then change aTimeoutErrorinto the string"timed out"withEffect.catchTag. - Count each call to
workin 1 sharedRef. - Use
Effect.forEachover the items. Print<item>: <result>for each item, thenattempts: <n>.
Exact output:
a: ok
b: timed out
c: ok
attempts: 4import { Effect, Ref } from "effect"
const delays: Record<string, number> = { a: 1, b: 50, c: 1 } // milliseconds
const items = ["a", "b", "c"]
const work = (item: string) => Effect.sleep(delays[item]!).pipe(Effect.as("ok"))
const program = Effect.gen(function* () {
const attempts = yield* Ref.make(0)
// TODO: process(item): count the attempt, run work(item) with a 10 ms timeout,
// retry once, map TimeoutError to "timed out"
// TODO: Effect.forEach over items, print "<item>: <result>" for each
console.log("attempts:", yield* Ref.get(attempts))
})
Effect.runPromise(program)
Recall #
Answer in your head first, then reveal. Come back to these tomorrow.
How many times does `Effect.retry(task, { times: 3 })` run `task` if it always fails? #
4 times. The first attempt always happens. times counts the retries after it. Schedule.recurs(3) and Schedule.upTo({ times: 3 }) use the same rule.
What is the difference between `Effect.retry` and `Effect.repeat`? #
retry runs the effect again after a failure and stops on a success. repeat runs the effect again after a success and stops on a failure. Both accept the same Schedule values.
Which combinator do you use for "exponential backoff, but stop after 5 retries"? #
Schedule.max([Schedule.exponential("100 millis"), Schedule.recurs(5)]), or Schedule.exponential("100 millis").pipe(Schedule.upTo({ times: 5 })). max continues only while both schedules continue. recurs(5) stops after 5 recurrences.
What is the type of `Effect.succeed("x").pipe(Effect.timeout("1 second"))`? #
Effect<string, TimeoutError>. timeout adds Cause.TimeoutError to the error channel. timeoutOption gives Effect<Option<string>, never> instead.
You must retry `Unavailable` errors and fail at once on `NotFound`. What are the 2 ways to write that? #
The options form: Effect.retry(effect, { times: 5, while: (e) => e._tag === "Unavailable" }). The schedule form with the builder: Effect.retry(($) => $(Schedule.recurs(5)).pipe(Schedule.while(({ input }) => input._tag === "Unavailable"))). The $ helper gives the schedule the error type of the effect.
`slow.pipe(Effect.retry({ times: 2 }), Effect.timeout("1 second"))` and `slow.pipe(Effect.timeout("1 second"), Effect.retry({ times: 2 }))`: what is the difference? #
In the first pipe, 1 time limit applies to all 3 attempts together. When the limit wins, Effect interrupts the full retry loop. In the second pipe, each attempt gets its own second. A slow attempt fails with TimeoutError, and retry runs the next attempt. For a time limit per attempt, put timeout inside retry.
Why do you add `Schedule.jittered` to a retry policy, and what does it change in the output of a program? #
Many clients that fail at the same time retry at the same time and overload the server again. jittered multiplies each delay by a random factor between 0.8 and 1.2, so the retries spread out. It changes only the delays. The number of attempts and the output of the schedule stay the same.