circuit

A highly-tunable circuit breaker for Go with gradual recovery via probabilistic throttling, type-safe generics, and zero background goroutines.

go get github.com/schigh/circuit
Go 1.22+ Generics Zero goroutines MIT

Type-Safe Generics

Return types are inferred from your function. No type assertions or empty interfaces.

Probabilistic Throttling

Gradual recovery with 5 built-in backoff curves. Backoff is divided into 100 ticks for smooth probability transitions.

Lazy Evaluation

No background goroutines. State transitions happen on access. Idle breakers cost nothing.

Full Observability

Buffered state change channels, MetricsCollector interface, JSON-serializable snapshots, and inline callbacks.

Quick Start

// Create a breaker with functional options
b, err := circuit.NewBreaker(
    circuit.WithName("my-api"),
    circuit.WithThreshold(5),
    circuit.WithWindow(time.Minute),
    circuit.WithBackOff(30 * time.Second),
)
if err != nil {
    log.Fatal(err)
}

// Run is generic — return type is inferred from your function
result, err := circuit.Run(b, ctx, func(ctx context.Context) (*Response, error) {
    return client.Call(ctx, request)
})

The function is called synchronously — the caller controls concurrency. If a timeout is configured, it is applied via context.WithTimeout. The runner must respect ctx.Done() for timeouts to take effect.

Running with Type Safety

user, err := circuit.Run(b, ctx, func(ctx context.Context) (*User, error) {
    return userService.GetByID(ctx, userID)
})
if err != nil {
    switch {
    case errors.Is(err, circuit.ErrStateOpen):
        // circuit is open — dependency is down
        return cachedUser, nil
    case errors.Is(err, circuit.ErrStateThrottled):
        // circuit is recovering — request was shed
        return nil, status.Error(codes.Unavailable, "service recovering")
    case errors.Is(err, circuit.ErrTimeout):
        // function exceeded the configured timeout
        return nil, status.Error(codes.DeadlineExceeded, "upstream timeout")
    default:
        return nil, err
    }
}

Error Types

ErrorDescriptionAffects Error Count
Your function's errorReturned as-isYes
ErrTimeoutContext deadline exceeded (converted from context.DeadlineExceeded)Yes
ErrStateOpenCircuit is open, request rejectedNo
ErrStateThrottledRequest shed during throttled recoveryNo
ErrNotInitializedBreaker not created with NewBreakerNo
ErrUnnamedBreakerBreaker added to BreakerBox without a nameNo

All circuit errors carry context — use errors.As to extract the breaker name and state. The Error.Is() method compares the base message only, ignoring context fields:

var circErr circuit.Error
if errors.As(err, &circErr) {
    log.Printf("breaker=%s state=%s: %s", circErr.BreakerName, circErr.State, circErr.Error())
}

Two-Step Mode (Allow/Done)

For HTTP middleware, gRPC interceptors, or any pattern where you don't wrap the call directly:

func CircuitBreakerMiddleware(b *circuit.Breaker) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            done, err := b.Allow(r.Context())
            if err != nil {
                http.Error(w, "service unavailable", http.StatusServiceUnavailable)
                return
            }

            rec := &statusRecorder{ResponseWriter: w, status: 200}
            next.ServeHTTP(rec, r)

            // Report the outcome to the breaker
            if rec.status >= 500 {
                done(fmt.Errorf("HTTP %d", rec.status))
            } else {
                done(nil)
            }
        })
    }
}

Allow checks the breaker state and returns a done callback. Call done(err) after the operation to report the outcome. No timeout is applied — the caller controls timing.

Error Classification

By default, any non-nil error counts as a failure. Customize with two callbacks:

Excluding Errors

Excluded errors are not counted at all — neither as successes nor failures:

b, _ := circuit.NewBreaker(
    circuit.WithName("user-api"),
    circuit.WithIsExcluded(func(err error) bool {
        // Don't count client cancellations against the dependency
        return errors.Is(err, context.Canceled)
    }),
)

Classifying Errors as Successes

Some errors indicate the dependency is healthy but the request was rejected:

b, _ := circuit.NewBreaker(
    circuit.WithName("inventory-api"),
    circuit.WithIsSuccessful(func(err error) bool {
        var httpErr *HTTPError
        if errors.As(err, &httpErr) {
            return httpErr.StatusCode == 404 || httpErr.StatusCode == 409
        }
        return false
    }),
)

Evaluation order: IsExcludedIsSuccessful → failure.

State Transitions

Closed Open Throttled errors > threshold lockout expires backoff expires re-trip
FromToCondition
ClosedOpenError count in window exceeds threshold
OpenThrottledLockout expired (if set) and errors ≤ threshold
ThrottledOpenError count exceeds threshold during recovery
ThrottledClosedBackoff period expired and errors ≤ threshold

State evaluation is lazy — transitions happen when Run, Allow, State(), or Snapshot() is called. There are no background goroutines. A breaker with no traffic stays in its current state indefinitely.

Lockout

When a circuit breaker opens, it can lock out for a specified duration. During lockout, all requests are rejected with ErrStateOpen, even if the error count drops below the threshold.

b, _ := circuit.NewBreaker(
    circuit.WithName("fragile-service"),
    circuit.WithThreshold(3),
    circuit.WithLockOut(10 * time.Second),
)

If no lockout is set, the breaker transitions to throttled as soon as errors drop below the threshold. Combine with WithOpeningResetsErrors(true) for immediate throttling:

b, _ := circuit.NewBreaker(
    circuit.WithName("fast-recovery"),
    circuit.WithThreshold(5),
    circuit.WithOpeningResetsErrors(true),  // clear errors on open → immediate throttle
    circuit.WithBackOff(15 * time.Second),
)

Backoff Strategies

During the throttled state, the breaker probabilistically sheds requests using an EstimationFunc. The backoff duration is divided into 100 ticks. At each tick, the function returns a probability (0–100) that a request should be blocked.

// EstimationFunc takes a tick [1-100] and returns
// the blocking probability [0-100]
type EstimationFunc func(int) uint32

b, _ := circuit.NewBreaker(
    circuit.WithName("api-gateway"),
    circuit.WithBackOff(30 * time.Second),
    circuit.WithEstimationFunc(circuit.Exponential),
)

Linear (default)

Steady, proportional decrease in blocking probability. Returns 100 - tick.

Linear estimation curve

Logarithmic

High blocking initially, rapid decrease after the midpoint. Uses a pre-computed curve.

Logarithmic estimation curve

Exponential

Rapid initial decrease, gradual easing toward full throughput. Uses a pre-computed curve.

Exponential estimation curve

Ease-In-Out

Smooth S-curve — high blocking early, steep drop at midpoint, gentle finish. Uses a pre-computed curve.

Ease-In-Out estimation curve

JitteredLinear

Linear with ±5 random jitter to prevent thundering herd effects during recovery.

Custom Strategies

Implement your own EstimationFunc:

// HalfOpen mimics a traditional half-open pattern:
// block everything for the first half, then allow everything
func HalfOpen(tick int) uint32 {
    if tick <= 50 {
        return 100 // block all
    }
    return 0 // allow all
}

Observability

State Change Notifications

Every breaker exposes a 16-element buffered channel for state change events. Events include full timing information via the BreakerState struct:

type BreakerState struct {
    Name        string     `json:"name"`
    State       State      `json:"state"`
    ClosedSince *time.Time `json:"closed_since,omitempty"`
    Opened      *time.Time `json:"opened,omitempty"`
    LockoutEnds *time.Time `json:"lockout_ends,omitempty"`
    Throttled   *time.Time `json:"throttled,omitempty"`
    BackOffEnds *time.Time `json:"backoff_ends,omitempty"`
}
b, _ := circuit.NewBreaker(circuit.WithName("my-service"))

go func() {
    for state := range b.StateChange() {
        log.Printf("breaker %s: %s", state.Name, state.State)
    }
}()

Or use a callback for inline handling. The callback runs after the state mutex is released, so it does not block other requests:

b, _ := circuit.NewBreaker(
    circuit.WithName("my-service"),
    circuit.WithOnStateChange(func(name string, from, to circuit.State) {
        log.Printf("breaker %s: %s -> %s", name, from, to)
    }),
)

Metrics Collection

Implement the MetricsCollector interface for Prometheus, StatsD, OpenTelemetry, etc. All methods must be safe for concurrent use:

type MetricsCollector interface {
    RecordSuccess(breakerName string, duration time.Duration)
    RecordError(breakerName string, duration time.Duration, err error)
    RecordTimeout(breakerName string)
    RecordStateChange(breakerName string, from, to State)
    RecordRejected(breakerName string, state State)
    RecordExcluded(breakerName string, err error)
}

RecordError includes the error itself, enabling classification by error type in dashboards.

Snapshots

Get a JSON-serializable point-in-time snapshot. Both State and BreakerState implement MarshalJSON/UnmarshalJSON:

// Expose as a health check endpoint
func healthHandler(b *circuit.Breaker) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        snap := b.Snapshot()
        w.Header().Set("Content-Type", "application/json")
        if snap.State == circuit.Open {
            w.WriteHeader(http.StatusServiceUnavailable)
        }
        json.NewEncoder(w).Encode(snap)
    }
}

Managing Multiple Breakers

Use BreakerBox to manage breakers for multiple dependencies. It uses sync.Map internally for thread-safe storage:

box := circuit.NewBreakerBox()

userBreaker, _ := box.Create(
    circuit.WithName("user-service"),
    circuit.WithThreshold(5),
)

// All state changes from Create'd breakers are forwarded to the box channel
go func() {
    for state := range box.StateChange() {
        log.Printf("[%s] %s", state.Name, state.State)
    }
}()

LoadOrCreate

Lazy initialization in request handlers — safe for concurrent callers. A sync.Mutex serializes creation to prevent TOCTOU races, so only one breaker is ever created per name:

b, err := s.box.LoadOrCreate("user-service",
    circuit.WithThreshold(5),
    circuit.WithBackOff(30 * time.Second),
)

AddBYO

Add externally-created breakers to the box for storage and retrieval. State changes from BYO breakers are not forwarded to the box channel:

b, _ := circuit.NewBreaker(circuit.WithName("custom"))
err := box.AddBYO(b)

Panic Handling

If the function passed to Run panics, the panic is recorded as a failure (incrementing the error count), then re-raised so the caller's recovery logic can handle it:

defer func() {
    if r := recover(); r != nil {
        log.Printf("caught panic: %v", r)
    }
}()

circuit.Run(b, ctx, func(ctx context.Context) (string, error) {
    panic("something went wrong")
})

API Reference

Constructors

NewBreaker(opts ...Option) (*Breaker, error)

Creates a new circuit breaker. All breakers must be created with this function. Auto-generates a name from the caller's file/line if none is provided.

NewBreakerBox() *BreakerBox

Creates a breaker manager with a shared state change channel for all breakers created via Create().

Execution

Run[T](b, ctx, fn) (T, error)

Generic, type-safe execution. Applies timeout via context.WithTimeout, catches panics, converts context.DeadlineExceeded to ErrTimeout.

b.Allow(ctx) (func(error), error)

Two-step mode. Returns a done callback for reporting outcomes. No timeout applied.

Breaker Methods

b.Name() string

Returns the breaker's name.

b.State() State

Returns the current state. Triggers lazy evaluation of state transitions.

b.Size() int

Returns the number of errors in the current sliding window.

b.Snapshot() BreakerState

Returns a JSON-serializable point-in-time snapshot with timing information.

b.StateChange() <-chan BreakerState

Returns a read-only channel (buffered, 16 elements) that receives state change events.

BreakerBox Methods

box.Create(opts) (*Breaker, error)

Creates a breaker whose state changes are forwarded to the box channel.

box.Load(name) *Breaker

Fetches a breaker by name. Returns nil if not found.

box.LoadOrCreate(name, opts) (*Breaker, error)

Thread-safe load-or-create. Only one breaker is created per name.

box.AddBYO(b) error

Adds an externally-created breaker. State changes are not forwarded.

State Constants

Closed, Throttled, Open

State type (uint32) with String(), MarshalJSON(), and UnmarshalJSON() methods.

Default Constants

DefaultTimeout = 10s

Default context timeout applied by Run.

DefaultBackOff = 1m

Default throttled recovery duration.

DefaultWindow = 5m

Default sliding window for error counting.

Estimation Functions

Linear, Logarithmic, Exponential, EaseInOut, JitteredLinear

Built-in EstimationFunc implementations. Takes tick [1-100], returns blocking probability [0-100].

Configuration Reference

OptionDefaultMinimumDescription
WithName(s)auto-generatedBreaker identifier
WithTimeout(d)10sContext timeout applied to Run
WithThreshold(n)0 (opens on first error)Max errors in window before opening
WithWindow(d)5m10msSliding window for error counting
WithBackOff(d)1m10msDuration of throttled recovery
WithLockOut(d)0 (no lockout)Forced-open duration before throttling
WithEstimationFunc(f)LinearThrottle probability curve
WithOpeningResetsErrors(v)falseClear error count when opening
WithIsSuccessful(fn)nilClassify errors as successes
WithIsExcluded(fn)nilExclude errors from tracking
WithMetrics(m)nilMetrics collector implementation
WithOnStateChange(fn)nilState transition callback (runs after mutex release)