The concrete friction
Indiscriminate retry loops amplify damage and waste compute
External network calls fail for different physical reasons. A 503 Service Unavailable or network reset is transient: waiting allows upstream load balancers to recover or connections to re-establish. A 401 Unauthorized or 400 Bad Request is deterministic: re-sending the same invalid token or query will never succeed. Retrying deterministic failures wastes network bandwidth, burns API quotas, and delays returning an actionable error to the user.
Retrying without a filter is a category error: it treats permanent logic errors as if they were transient environmental conditions. Retrying without exponential backoff and jitter causes thundering herd problems, knocking recovering services back offline.
type SearchError =
| { readonly _tag: "Unauthorized" }
| { readonly _tag: "IssueServiceUnavailable" };
const searchIssues = async (query: string): Promise<string> => {
if (query === "unauthorized") {
throw ({ _tag: "Unauthorized" } satisfies SearchError);
}
throw ({ _tag: "IssueServiceUnavailable" } satisfies SearchError);
};
async function searchWithRetry(query: string): Promise<string> {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await searchIssues(query);
} catch (_error: unknown) {
// Blindly retries unauthorized requests too.
}
}
throw new Error("search failed");
}The mental model
A retry policy combines an error predicate and a time schedule
Effect.retry decouples the retry policy into two orthogonal decisions: which errors are eligible (the while predicate inspecting tagged errors), and what schedule to apply (exponential backoff with finite upper bounds). Schedule.exponential("20 millis", 2).pipe(Schedule.upTo({ times: 2 })) guarantees backoff and bounds total attempts to two retries.
Attempt issue provider
├── Success ────────────────────────────> Return ISSUE-101
└── Failure:
├── InvalidQuery ─────────────────> Fail immediately (no retry)
└── IssueServiceUnavailable (503)
│
▼ Check schedule (retries < 2)
├── Yes ──> Wait 20ms, then retry
└── No ──> Exhausted ──────> Fail with 503Minimal working example
Executable source
This is the checked source file used by the course. Read it before you run it.
import { Console, Effect, Ref, Schedule } from "effect"
export type IssueServiceUnavailable = {
readonly _tag: "IssueServiceUnavailable"
readonly statusCode: number
}
export const IssueServiceUnavailable = (
fields: Omit<IssueServiceUnavailable, "_tag">
): IssueServiceUnavailable => ({
_tag: "IssueServiceUnavailable",
...fields
})
const retryPolicy = Schedule.exponential("20 millis", 2).pipe(
Schedule.upTo({ times: 2 })
)
const searchIssueProvider = (attemptsRef: Ref.Ref<number>) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attemptsRef, (count) => count + 1)
if (attempt < 2) {
return yield* Effect.fail(IssueServiceUnavailable({ statusCode: 503 }))
}
return "ISSUE-101"
})
export const retryTransientIssueSearch = Effect.gen(function* () {
const attemptsRef = yield* Ref.make(0)
const issueId = yield* Effect.retry(
searchIssueProvider(attemptsRef),
{
schedule: retryPolicy,
while: (error) => error._tag === "IssueServiceUnavailable"
}
)
const attempts = yield* Ref.get(attemptsRef)
return { issueId, attempts }
})
await Effect.runPromise(
retryTransientIssueSearch.pipe(
Effect.flatMap((recovered) =>
Console.log(
`recovered ${recovered.issueId} after ${recovered.attempts} attempts`
)
)
)
)Execute and verify
Predict the result, run the command, and compare the output with the model.
pnpm exec tsx src/course/examples/retry.tsWhy are two retries reported as three total attempts? Which predicate prevents a permanent search failure from retrying?
Reveal expected output
recovered ISSUE-101 after 3 attemptsError anatomy and edge cases
Retrying indefinitely or without error filtering
Using Schedule.forever without a while predicate on external network calls.
If an invalid query or expired credential is submitted, the fiber retries continuously in an infinite loop, pegging CPU usage, spamming the auth provider, and never returning an error to the caller.
Bound the schedule with Schedule.upTo({ times: N }) and retry only the tagged failure variants that represent temporary conditions.
Active lab exercise
Exercise: selective issue-search retries
Retry temporary provider failures twice, but never retry an unauthorized request.
pnpm exec tsx src/exercises/exercise6/exercise.test.ts starterThe harness should fail against the starter. Inspect the assertion, change the starter implementation, and run it again.
Inspect starter code
import { Context, Effect, Schedule, pipe } from "effect"
export type IssueServiceUnavailable = {
readonly _tag: "IssueServiceUnavailable"
readonly statusCode: number
}
export const IssueServiceUnavailable = (
fields: Omit<IssueServiceUnavailable, "_tag">
): IssueServiceUnavailable => ({
_tag: "IssueServiceUnavailable",
...fields
})
export type UnauthorizedError = {
readonly _tag: "UnauthorizedError"
readonly reason: string
}
export const UnauthorizedError = (
fields: Omit<UnauthorizedError, "_tag">
): UnauthorizedError => ({
_tag: "UnauthorizedError",
...fields
})
export interface IssueProvider {
readonly search: (
query: string
) => Effect.Effect<string, IssueServiceUnavailable | UnauthorizedError>
}
export const IssueProvider = Context.Service<IssueProvider>("IssueProvider")
export const searchIssue = (
query: string
): Effect.Effect<
string,
IssueServiceUnavailable | UnauthorizedError,
IssueProvider
> =>
Effect.gen(function* () {
const provider = yield* IssueProvider
// TODO: retry only IssueServiceUnavailable and stop after two retries.
// The starter incorrectly retries every provider failure.
return yield* pipe(provider.search(query), Effect.retry(Schedule.recurs(2)))
})Reveal reference solution
import { Context, Effect, Schedule, pipe } from "effect"
export type IssueServiceUnavailable = {
readonly _tag: "IssueServiceUnavailable"
readonly statusCode: number
}
export const IssueServiceUnavailable = (
fields: Omit<IssueServiceUnavailable, "_tag">
): IssueServiceUnavailable => ({
_tag: "IssueServiceUnavailable",
...fields
})
export type UnauthorizedError = {
readonly _tag: "UnauthorizedError"
readonly reason: string
}
export const UnauthorizedError = (
fields: Omit<UnauthorizedError, "_tag">
): UnauthorizedError => ({
_tag: "UnauthorizedError",
...fields
})
export interface IssueProvider {
readonly search: (
query: string
) => Effect.Effect<string, IssueServiceUnavailable | UnauthorizedError>
}
export const IssueProvider = Context.Service<IssueProvider>("IssueProvider")
const retryPolicy = Schedule.exponential("20 millis", 2).pipe(
Schedule.upTo({ times: 2 })
)
export const searchIssue = (
query: string
): Effect.Effect<
string,
IssueServiceUnavailable | UnauthorizedError,
IssueProvider
> =>
Effect.gen(function* () {
const provider = yield* IssueProvider
return yield* pipe(
provider.search(query),
Effect.retry({
schedule: retryPolicy,
while: (error) => error._tag === "IssueServiceUnavailable"
})
)
})Use Schedule.exponential("20 millis", 2).pipe(Schedule.upTo({ times: 2 })) and check error._tag === "IssueServiceUnavailable" in the retry predicate. UnauthorizedError bypasses the retry policy and fails immediately.