Skip to content
Effect 4.0 is here. One ecosystem. Zero dependencies.

CircuitBreaker

Keyed circuit breakers for calls to unreliable dependencies.

A CircuitBreaker holds one circuit per key, so one policy can guard many hosts or tenants. State lives in a CircuitBreakerStore: layerStoreMemory keeps it in the process, layerStoreRedis shares it between processes.

Details

  • Closed: calls run, and the trip policy decides when the circuit opens.
  • Open: calls fail with CircuitOpen until the open time ends.
  • HalfOpen: the next call is the probe, and other calls fail with CircuitHalfOpen while it runs. A successful probe closes the circuit and a failed one reopens it. An interrupted or Ignored probe frees the slot.
  • The backoff count survives a close. It resets once the circuit has stayed closed as long as it was last open.

The breaker never waits. Retry rejections outside it, as in "Retrying rejections" below. Put Effect.retry and Effect.timeout inside it, so it sees one outcome per call. With ExecutionPlan, put a breaker inside each step under its own key.

The breaker emits no metrics or spans. Observe it through state(key), transitions and the typed error.

Gotchas

  • Use one policy per key. Breakers that share a key and a store share one circuit, and nothing checks that their policies match. Separate them with distinct keys or a Redis prefix.
  • Calls admitted before a trip still run to completion, but their outcomes change nothing.
  • "Consecutive" counts in order of completion, not of start.
  • state(key) reads the shared store. transitions only shows what this breaker instance did.
  • Store errors the breaker swallows (a fail-open admit, a failed record, a failed Redis lease renewal) are reported with their cause at References.UnhandledLogLevel. Set it to undefined to silence them.
  • Cap an exponential openDuration, with Schedule.upTo or with Schedule.min and a Schedule.spaced, or it keeps growing for a dependency that flaps.

Example (Retrying rejections)

For CircuitOpen, wait at least the rejection's retryAfter. For CircuitHalfOpen, the jittered backoff keeps waiting callers from retrying at the same moment. Store errors and the call's own errors are not retried.

Example Rejecting calls once a circuit opens

import { Data, Effect } from "effect"
import { CircuitBreaker } from "effect/persistence"
class PaymentsError extends Data.TaggedError("PaymentsError") {}
const program = Effect.gen(function*() {
const breaker = yield* CircuitBreaker.make({
tripPolicy: CircuitBreaker.consecutive(2),
openDuration: "30 seconds"
})
const charge = Effect.fail(new PaymentsError()).pipe(
breaker.execute({ key: "payments" })
)
// Two failures in a row open the circuit.
yield* Effect.flip(charge)
yield* Effect.flip(charge)
// The third call is rejected without running.
return yield* charge.pipe(
Effect.catchReason("CircuitBreakerError", "CircuitOpen", (reason) => Effect.succeed(`${reason.key} is open`))
)
}).pipe(Effect.scoped, Effect.provide(CircuitBreaker.layerStoreMemory()))
await Effect.runPromise(program) // => "payments is open"

Example Retrying rejections

import { Data, Duration, Effect, Ref, Schedule } from "effect"
import { CircuitBreaker } from "effect/persistence"
const isRejection = (error: unknown): error is CircuitBreaker.CircuitBreakerError =>
error instanceof CircuitBreaker.CircuitBreakerError && error.reason._tag !== "CircuitBreakerStoreError"
const retryRejections = Schedule.exponential("20 millis").pipe(
Schedule.jittered,
Schedule.modifyDelay(({ duration, input }) =>
Effect.succeed(
isRejection(input) && input.reason._tag === "CircuitOpen"
? Duration.max(duration, input.reason.retryAfter)
: duration
)
)
)
class QuoteError extends Data.TaggedError("QuoteError") {}
const program = Effect.gen(function*() {
const breaker = yield* CircuitBreaker.make({
tripPolicy: CircuitBreaker.consecutive(1),
openDuration: "50 millis"
})
const healthy = yield* Ref.make(false)
const quote = Effect.flatMap(Ref.get(healthy), (ok) => ok ? Effect.succeed(42) : Effect.fail(new QuoteError()))
// One failure opens the circuit, then the dependency recovers.
yield* Effect.flip(quote.pipe(breaker.execute({ key: "quotes" })))
yield* Ref.set(healthy, true)
// Rejected while open, then retried once the circuit lets a probe through.
return yield* quote.pipe(
breaker.execute({ key: "quotes" }),
Effect.retry({ schedule: retryRejections, while: isRejection, times: 5 })
)
}).pipe(Effect.scoped, Effect.provide(CircuitBreaker.layerStoreMemory()))
await Effect.runPromise(program) // => 42
29 exports Since v4.0.6 Source

Constructors

Creates a trip policy that opens after failures consecutive failures.

Gotchas

Throws a RangeError when failures is not an integer of at least 1.

Signature

declare function consecutive(failures: number): TripPolicy
Since v4.0.6

Creates an outcome that records a failure.

Details

retryAfter is a hint, such as an HTTP Retry-After header. It is used only when this failure opens the circuit, can only lengthen the opening, and is capped at maxOpenDuration.

Signature

declare function failure(options?: {
readonly retryAfter?: Input;
}): Outcome
Since v4.0.6

Outcome that records nothing.

Signature

declare const ignored: Outcome
Since v4.0.6

Creates a CircuitBreaker from the current CircuitBreakerStore.

Details

  • tripPolicy decides when a Closed circuit opens.
  • openDuration is a duration or a Schedule. The n-th consecutive opening lasts the schedule's n-th delay, and the last delay repeats once the schedule ends. Elapsed-time combinators such as upTo and during measure the time spent open, not wall time. Schedules that read the current date, such as cron, are not supported.
  • maxOpenDuration (default one hour) caps a Retry-After hint. It never caps openDuration.
  • onStoreError decides what a call does when the store cannot admit it: "allow" (the default) runs it unrecorded, "fail" fails it with CircuitBreakerStoreError.

The backoff count resets once the circuit has stayed closed as long as its last opening, Retry-After hint included.

Signature

declare const make: (options: {
readonly maxOpenDuration?: Duration.Input;
readonly onStoreError?: "allow" | "fail";
readonly openDuration: Duration.Input | Schedule.Schedule<unknown, number, never, never>;
readonly tripPolicy: TripPolicy;
}) => Effect.Effect<CircuitBreaker, never, CircuitBreakerStore | Scope.Scope>

Example Capping backoff growth by time spent open

import { Effect, Schedule } from "effect"
import { CircuitBreaker } from "effect/persistence"
// Openings last 1, 2, 4, 8, 16 and 32 seconds. By then 63 seconds have been
// spent open, past the one-minute cap, so later openings stay at 32 seconds.
const program = Effect.gen(function*() {
const breaker = yield* CircuitBreaker.make({
tripPolicy: CircuitBreaker.consecutive(5),
openDuration: Schedule.exponential("1 second").pipe(
Schedule.upTo({ duration: "1 minute" })
)
})
return yield* Effect.succeed("ok").pipe(breaker.execute({ key: "payments" }))
}).pipe(Effect.scoped, Effect.provide(CircuitBreaker.layerStoreMemory()))
await Effect.runPromise(program) // => "ok"
Since v4.0.6

Creates a trip policy that opens when the failure rate over the last recorded calls reaches a threshold.

Details

threshold is a fraction in [0, 1] and defaults to 0.5. size is the number of recorded calls kept and defaults to 100. minimumCalls defaults to size. A ring with no failures never opens the circuit.

Gotchas

Throws a RangeError when threshold is outside [0, 1], when size is not an integer of at least 1, or when minimumCalls is not an integer between 1 and size.

Signature

declare function rate(options: {
readonly minimumCalls?: number;
readonly size?: number;
readonly threshold?: number;
}): TripPolicy
Since v4.0.6

Outcome that records a success and resets a consecutive failure streak.

Signature

declare const success: Outcome

Errors

Error raised by circuit breaker operations, wrapping a concrete failure reason.

Signature

declare class CircuitBreakerError extends {
readonly _tag: "CircuitBreakerError";
readonly reason: CircuitOpen | CircuitHalfOpen | CircuitBreakerStoreError;
} & YieldableError<this> {
constructor(props: {
readonly reason: CircuitBreakerErrorReason;
});
readonly "~effect/persistence/CircuitBreaker/CircuitBreakerError": "~effect/persistence/CircuitBreaker/CircuitBreakerError";
message: string;
}

Schema for all reasons that can be carried by CircuitBreakerError.

Signature

declare const CircuitBreakerErrorReason: Union<[typeof CircuitOpen, typeof CircuitHalfOpen, typeof CircuitBreakerStoreError]>

Union of reasons carried by CircuitBreakerError.

Signature

type CircuitBreakerErrorReason = CircuitOpen | CircuitHalfOpen | CircuitBreakerStoreError

Error reason for failures in the backing CircuitBreakerStore.

Signature

declare class CircuitBreakerStoreError extends {
readonly _tag: "CircuitBreakerStoreError";
readonly cause?: unknown;
readonly message: string;
} & YieldableError<this> {
constructor(...args: [props: {
readonly _tag?: "CircuitBreakerStoreError";
readonly cause?: unknown;
readonly message: string;
}, options?: MakeOptions]);
}

Error reason for a call rejected while a HalfOpen circuit's probe runs.

Signature

declare class CircuitHalfOpen extends {
readonly _tag: "CircuitHalfOpen";
readonly key: string;
} & YieldableError<this> {
constructor(...args: [props: {
readonly _tag?: "CircuitHalfOpen";
readonly key: string;
}, options?: MakeOptions]);
message: string;
}

Error reason for a call rejected by an Open circuit.

Signature

declare class CircuitOpen extends {
readonly _tag: "CircuitOpen";
readonly key: string;
readonly retryAfter: Duration;
} & YieldableError<this> {
constructor(...args: [props: {
readonly _tag?: "CircuitOpen";
readonly key: string;
readonly retryAfter: Duration;
}, options?: MakeOptions]);
message: string;
}

Layers

Since v4.0.6

Provides CircuitBreaker using the current CircuitBreakerStore.

Details

Takes the same options as make.

Signature

declare function layer(options: {
readonly maxOpenDuration?: Input;
readonly onStoreError?: "allow" | "fail";
readonly openDuration: Input | Schedule<unknown, number, never, never>;
readonly tripPolicy: TripPolicy;
}): Layer<CircuitBreaker, never, CircuitBreakerStore>

Creates an in-memory CircuitBreakerStore layer.

Details

State is local to the layer and uses Clock. idleTimeToLive (default one hour) drops a key once it has been idle that long. An Open key is kept that long past its open time, and a key whose probe is running is never dropped. A dropped key starts again as Closed with a backoff count of 0.

Signature

declare function layerStoreMemory(options?: {
readonly idleTimeToLive?: Input;
}): Layer<CircuitBreakerStore>

Creates a Redis-backed CircuitBreakerStore layer.

Details

  • Each circuit is one hash at ${prefix}${key}, and prefix defaults to "circuitbreaker:". Times come from Redis TIME, so client clock skew never matters. Requires Redis 5 or later.
  • The probe holds a lease of probeLease (default 10 seconds), renewed while it runs. If the lease lapses, another caller takes over and the old probe's outcome is discarded.
  • idleTimeToLive (default one hour) expires idle keys. An Open key, or one whose probe holds the lease, is kept that long past its open time or lease, so expiry never cuts a probe short or resets the backoff early. An expired key starts again as Closed with a backoff count of 0.

Gotchas

  • If renewals cannot reach Redis, the lease lapses and a second probe can start.
  • Durations above 2^50 milliseconds cannot be stored. An operation that needs one fails with CircuitBreakerStoreError.

Signature

declare function layerStoreRedis(options?: {
readonly idleTimeToLive?: Input;
readonly prefix?: string;
readonly probeLease?: Input;
}): Layer<CircuitBreakerStore, never, Redis>

Creates a Redis-backed CircuitBreakerStore layer from wrapped configuration options.

Details

Takes the same options as layerStoreRedis.

Signature

declare function layerStoreRedisConfig(options: {
readonly idleTimeToLive?: Config<Input | undefined>;
readonly prefix?: Config<string | undefined>;
readonly probeLease?: Config<Input | undefined>;
} | Config<{
readonly idleTimeToLive?: Input;
readonly prefix?: string;
readonly probeLease?: Input;
}>): Layer<CircuitBreakerStore, ConfigError, Redis>

Models

Admission

interface
Since v4.0.6

Permit returned by CircuitBreakerStore.admit.

Details

  • generation is an opaque fencing token to pass back to record.
  • probe is true when this call is the HalfOpen probe.
  • consecutiveOpenings counts the openings since the backoff last reset.
  • transition is the Open to HalfOpen transition this admission made, if any.

Signature

interface Admission {
readonly consecutiveOpenings: number;
readonly generation: number;
readonly probe: boolean;
readonly transition: Transition | undefined;
}
Since v4.0.6

Service that runs effects through keyed circuits and reports their state.

Details

  • execute runs the call through the circuit for key and records its outcome. classify maps the call's Exit to an Outcome, directly or as an effect. By default a success is a Success and any failure, defects included, is a Failure, since a rejected Effect.promise only surfaces as a defect. An exit that is only interruption is always Ignored.
  • state reads the shared state of a circuit.
  • transitions emits this breaker's transitions and ends when its scope closes.

Gotchas

  • If classify throws or dies, the outcome is Ignored and the call dies.
  • Bound an effectful classify with Effect.timeoutOrElse or Effect.timeoutOption, since Effect.timeout adds a typed error. A call to the same key from inside classify is rejected while its call is the probe.

Example (Timing out calls inside the breaker)

A timeout inside the breaker counts as a Failure. Outside, it interrupts the call, which is Ignored.

Example (Retrying inside the breaker)

The breaker sees one outcome per call, so a call a retry rescued counts as one Success.

Example (Falling back to another provider with ExecutionPlan)

Each provider guards its calls under its own key, so once the primary's circuit opens its step fails fast and the plan moves to the fallback.

Example (Counting transitions)

Label by from and to, not key, to keep the number of series fixed.

Signature

interface CircuitBreaker {
readonly "~effect/persistence/CircuitBreaker": "~effect/persistence/CircuitBreaker";
readonly execute: {
<A, E, R2 = never>(options: {
readonly classify?: (exit: Exit<A, E>) => Outcome | Effect<Outcome, never, R2>;
readonly key: string;
}): <R>(self: Effect<A, E, R>) => Effect<A, CircuitBreakerError | E, R2 | R>;
<A, E, R, R2 = never>(self: Effect<A, E, R>, options: {
readonly classify?: (exit: Exit<A, E>) => Outcome | Effect<Outcome, never, R2>;
readonly key: string;
}): Effect<A, CircuitBreakerError | E, R | R2>;
};
readonly state: (key: string) => Effect<State, CircuitBreakerError>;
readonly transitions: Stream<Transition>;
}

Example Timing out calls inside the breaker

import { Effect } from "effect"
import { CircuitBreaker } from "effect/persistence"
const program = Effect.gen(function*() {
const breaker = yield* CircuitBreaker.make({
tripPolicy: CircuitBreaker.consecutive(1),
openDuration: "1 minute"
})
yield* Effect.never.pipe(
Effect.timeout("10 millis"),
breaker.execute({ key: "inside" }),
Effect.ignore
)
yield* Effect.never.pipe(
breaker.execute({ key: "outside" }),
Effect.timeout("10 millis"),
Effect.ignore
)
const inside = yield* breaker.state("inside")
const outside = yield* breaker.state("outside")
return [inside._tag, outside._tag]
}).pipe(Effect.scoped, Effect.provide(CircuitBreaker.layerStoreMemory()))
await Effect.runPromise(program) // => ["Open", "Closed"]

Example Retrying inside the breaker

import { Data, Effect, Ref, Schedule } from "effect"
import { CircuitBreaker } from "effect/persistence"
class InventoryError extends Data.TaggedError("InventoryError") {}
const program = Effect.gen(function*() {
const breaker = yield* CircuitBreaker.make({
tripPolicy: CircuitBreaker.consecutive(1),
openDuration: "1 minute"
})
const attempts = yield* Ref.make(0)
// Fails twice, then succeeds.
const stock = Effect.flatMap(
Ref.updateAndGet(attempts, (n) => n + 1),
(n) => n <= 2 ? Effect.fail(new InventoryError()) : Effect.succeed(7)
)
const result = yield* stock.pipe(
Effect.retry({ schedule: Schedule.exponential("1 millis"), times: 3 }),
breaker.execute({ key: "inventory" })
)
const state = yield* breaker.state("inventory")
return [result, state._tag]
}).pipe(Effect.scoped, Effect.provide(CircuitBreaker.layerStoreMemory()))
await Effect.runPromise(program) // => [7, "Closed"]

Example Falling back to another provider with ExecutionPlan

import { Context, Data, Effect, ExecutionPlan, Layer } from "effect"
import { CircuitBreaker } from "effect/persistence"
class GeocoderError extends Data.TaggedError("GeocoderError") {}
class Geocoder extends Context.Service<Geocoder, {
readonly lookup: (address: string) => Effect.Effect<string, GeocoderError | CircuitBreaker.CircuitBreakerError>
}>()("app/Geocoder") {}
const provider = (name: string, lookup: (address: string) => Effect.Effect<string, GeocoderError>) =>
Layer.effect(
Geocoder,
Effect.gen(function*() {
const breaker = yield* CircuitBreaker.CircuitBreaker
return Geocoder.of({
lookup: (address) => lookup(address).pipe(breaker.execute({ key: `geocoder:${name}` }))
})
})
)
const Primary = provider("primary", () => Effect.fail(new GeocoderError()))
const Fallback = provider("fallback", (address) => Effect.succeed(`${address} (fallback)`))
const plan = ExecutionPlan.make({ provide: Primary }, { provide: Fallback })
const program = Effect.gen(function*() {
const geocoder = yield* Geocoder
return yield* geocoder.lookup("1 Main St")
}).pipe(
Effect.withExecutionPlan(plan),
Effect.provide(
CircuitBreaker.layer({ tripPolicy: CircuitBreaker.consecutive(3), openDuration: "30 seconds" }).pipe(
Layer.provide(CircuitBreaker.layerStoreMemory())
)
)
)
await Effect.runPromise(program) // => "1 Main St (fallback)"

Example Counting transitions

import { Effect, Metric, Stream } from "effect"
import { CircuitBreaker } from "effect/persistence"
const transitionsTotal = Metric.counter("circuit_breaker_transitions_total", { incremental: true })
const countTransitions = (breaker: CircuitBreaker.CircuitBreaker) =>
breaker.transitions.pipe(
Stream.runForEach((transition) =>
Metric.update(Metric.withAttributes(transitionsTotal, { from: transition.from, to: transition.to }), 1)
),
Effect.forkScoped({ startImmediately: true })
)
const program = Effect.gen(function*() {
const breaker = yield* CircuitBreaker.make({
tripPolicy: CircuitBreaker.consecutive(1),
openDuration: "1 minute"
})
yield* countTransitions(breaker)
yield* Effect.fail("unavailable").pipe(breaker.execute({ key: "a" }), Effect.ignore)
yield* Effect.fail("unavailable").pipe(breaker.execute({ key: "b" }), Effect.ignore)
yield* Effect.yieldNow
const opened = yield* Metric.value(Metric.withAttributes(transitionsTotal, { from: "Closed", to: "Open" }))
return opened.count
}).pipe(
Effect.scoped,
Effect.provide(CircuitBreaker.layerStoreMemory()),
Effect.provideService(Metric.MetricRegistry, new Map())
)
await Effect.runPromise(program) // => 2
Since v4.0.6

Members of the Outcome union.

Since v4.0.6

Classification of a call's exit, as returned by a classify function.

Signature

type Outcome = Outcome.Success | Outcome.Ignored | Outcome.Failure

State

type
Since v4.0.6

State of one circuit, as read from the store.

Details

  • Open.retryAfter is the remaining open time, clamped to zero. A circuit past its open time reads as Open until the next call moves it to HalfOpen.
  • HalfOpen.probing is false when no probe currently holds the slot.
  • consecutiveOpenings on Open and HalfOpen counts the openings since the backoff last reset, not since the circuit last closed.

Signature

type State = {
readonly _tag: "Closed";
readonly key: string;
} | {
readonly _tag: "Open";
readonly consecutiveOpenings: number;
readonly key: string;
readonly retryAfter: Duration.Duration;
} | {
readonly _tag: "HalfOpen";
readonly consecutiveOpenings: number;
readonly key: string;
readonly probing: boolean;
}
Since v4.0.6

Transition between circuit states made by a breaker.

Details

Only the legal edges appear. from tells a failed probe ("HalfOpen") apart from a fresh trip ("Closed"). On an opening, consecutiveOpenings counts the openings since the backoff last reset, including this one.

Signature

type Transition = {
readonly consecutiveOpenings: number;
readonly from: "Closed" | "HalfOpen";
readonly key: string;
readonly retryAfter: Duration.Duration;
readonly to: "Open";
} | {
readonly from: "Open";
readonly key: string;
readonly to: "HalfOpen";
} | {
readonly from: "HalfOpen";
readonly key: string;
readonly to: "Closed";
}
Since v4.0.6

Policy that decides when a Closed circuit opens.

Details

  • Consecutive opens after failures failures in a row, in order of completion. Only a Success resets the streak.
  • Rate keeps the last size recorded outcomes and opens when the fraction of failures reaches threshold, once at least minimumCalls outcomes are recorded.

Ignored outcomes never count towards either policy.

Signature

type TripPolicy = {
readonly _tag: "Consecutive";
readonly failures: number;
} | {
readonly _tag: "Rate";
readonly minimumCalls: number;
readonly size: number;
readonly threshold: number;
}

Services

Service tag for the default CircuitBreaker.

When to use

Use when one breaker policy serves the whole context. For several policies, use a LayerMap keyed by policy, with every entry sharing one store, or define your own tags.

Signature

declare const CircuitBreaker: Service<CircuitBreaker, CircuitBreaker>

Example Several breaker policies with LayerMap

import { Effect, LayerMap, Schedule } from "effect"
import { CircuitBreaker } from "effect/persistence"
class Breakers extends LayerMap.Service<Breakers>()("app/Breakers", {
lookup: (dependency: "payments" | "search") =>
dependency === "payments"
? CircuitBreaker.layer({ tripPolicy: CircuitBreaker.consecutive(5), openDuration: "30 seconds" })
: CircuitBreaker.layer({
tripPolicy: CircuitBreaker.rate({ threshold: 0.5, size: 20 }),
openDuration: Schedule.exponential("1 second")
}),
// One store shared by every entry.
dependencies: [CircuitBreaker.layerStoreMemory()],
idleTimeToLive: "10 minutes"
}) {}
const search = Effect.gen(function*() {
const breaker = yield* CircuitBreaker.CircuitBreaker
return yield* Effect.succeed(["result"]).pipe(breaker.execute({ key: "search:eu" }))
}).pipe(Effect.provide(Breakers.get("search")))
await Effect.runPromise(search.pipe(Effect.provide(Breakers.layer))) // => ["result"]

Store that holds circuit state for a CircuitBreaker.

Details

  • admit returns an Admission, or fails with CircuitOpen or CircuitHalfOpen. Its scope stays open while the call runs.
  • record applies an outcome atomically and opens the circuit for openFor if it trips. An outcome stamped with an older generation changes nothing.
  • state reads a key without changing it.

Each operation reads the current time inside the store. A close keeps consecutiveOpenings and sets a reset deadline of the close time plus the last openFor. The first admit at or after the deadline resets the count to 0. record never reads the deadline, so a call admitted before a reset still opens with the openFor it computed.

Signature

declare class CircuitBreakerStore extends Shape<"effect/persistence/CircuitBreaker/CircuitBreakerStore", {
readonly admit: (key: string) => Effect<Admission, CircuitBreakerError, Scope>;
readonly record: (options: {
readonly generation: number;
readonly key: string;
readonly openFor: Duration;
readonly outcome: "Failure" | "Success" | "Ignored";
readonly tripPolicy: TripPolicy;
}) => Effect<Transition | undefined, CircuitBreakerError>;
readonly state: (key: string) => Effect<State, CircuitBreakerError>;
}, this> {
constructor(_: never);
}

Type IDs

Runtime type identifier for CircuitBreakerError.

Signature

declare const ErrorTypeId: ErrorTypeId
Since v4.0.6

Type-level identifier used to brand CircuitBreakerError values.

Signature

type ErrorTypeId = "~effect/persistence/CircuitBreaker/CircuitBreakerError"
Since v4.0.6

Runtime type identifier for CircuitBreaker values.

Signature

declare const TypeId: TypeId
Since v4.0.6

Type-level identifier used to brand CircuitBreaker values.

Signature

type TypeId = "~effect/persistence/CircuitBreaker"