Skip to content

Proposal: circuit breaker support #5

Description

@qualidafial

I saw your Reddit /r/golang post a week or so ago about this package, which piqued my interest.

I took a stab at a design and tried to keep it in line with the existing API design philosophy so far as I could tell.

If you are interested, I would be happy to do the leg work myself and contribute a PR--let me know!


try retries a fallible operation but has no way to stop hammering a downstream that is already failing systemically. A circuit breaker needs state shared across many Do calls (and across goroutines), which doesn't fit the existing Option -> Config model, since Config is rebuilt fresh per call. It should also work identically whether the caller uses the package-level try.Do or a *try.Try client.

Proposed API design

Public API:

type CircuitState int
const (
    // Closed means the circuit is closed and calls are allowed to pass through. The circuit will
    // transition to Open after a certain number of consecutive failures.
    Closed CircuitState = iota
    
    // Open means the circuit is open and calls are not allowed to pass through. The circuit will
    // transition to HalfOpen after a certain timeout.
    Open
    
    // HalfOpen means the circuit is half-open and allows a limited number of trial calls to pass
    // through.
    HalfOpen
)

// ErrCircuitOpen is a sentinel error returned when a call is attempted while the circuit breaker
// is in the Open state.
var ErrCircuitOpen = errors.New("try: circuit breaker open")

// CircuitBreaker is a concurrency-safe circuit breaker that tracks the state of a downstream
// service, and prevents further calls when the service is failing. It transitions between three
// states: Closed, Open, and HalfOpen, based on the number of consecutive successes or failures.
//
// Circuit breakers should be shared across all calls to a particular downstream service, but not
// across different services.
type CircuitBreaker struct { /* mutex-guarded state */ }

// NewCircuitBreaker creates a new CircuitBreaker with the provided options.
func NewCircuitBreaker(opts ...CircuitBreakerOption) *CircuitBreaker

// CircuitBreakerOption is a function that configures a CircuitBreaker.
type CircuitBreakerOption func(*CircuitBreaker)

// WithFailureThreshold sets how many consecutive failures in Closed state before tripping to Open.
// Default TBD.
func WithFailureThreshold(n int) CircuitBreakerOption

// WithOpenTimeout sets how long to stay Open before allowing a trial call (HalfOpen). Default TBD.
func WithOpenTimeout(d time.Duration) CircuitBreakerOption

// WithSuccessThreshold sets how many consecutive successful requests are required to reset the
// breaker from HalfOpen to Closed. Default TBD.
func WithSuccessThreshold(n int) CircuitBreakerOption

// WithOnStateChange registers a callback that is invoked whenever the circuit breaker transitions
// between states. This can be used for observability or logging purposes.
func WithOnStateChange(func(from, to CircuitState)) CircuitBreakerOption // parallel to WithOnRetry

// WithCircuitBreaker uses the provided circuit breaker to short circuit calls when the downstream
// service is failing.
func WithCircuitBreaker(cb *CircuitBreaker) Option

Internal API:

CircuitBreaker methods share the same clock as try.Do / try.Try for time-dependent methods:

// allow returns whether a call is currently allowed based on the current state of the circuit
// breaker. Transitions from Open to HalfOpen if the timeout has elapsed. While in HalfOpen state,
// admits only one in-flight trial call at a time to avoid recreating a thundering herd against a
// downstream that's still recovering. Concurrent callers that hit allow while a trial is already in
// flight are rejected with ErrCircuitOpen until that trial completes.
func (cb *CircuitBreaker) allow(clock Clock) error

// recordSuccess records a successful call. Resets the failure count to zero. Transitions from
// HalfOpen to Closed if SuccessThreshold has been met.
func (cb *CircuitBreaker) recordSuccess(clock Clock)

// recordFailure records a failed call. Resets the success count to zero. Transitions from Closed to
// Open if FailureThreshold has been met. Transitions from HalfOpen to Open on any failure.
func (cb *CircuitBreaker) recordFailure(clock Clock)

Expected behavior

The caller constructs one CircuitBreaker per downstream dependency, and hands it to try via the WithCircuitBreaker option. This mirrors how *try.Try is already used today (one instance per service dependency, shared across call sites/goroutines).

All mutation happens behind a mutex/atomics internally; *CircuitBreaker is safe for concurrent use, same guarantee *Try already gives.

An error only counts against the breaker if try itself would have retried it:

  • Permanent errors, predicate-rejected errors, exhausted per-error budgets, and a parent context that's done (via ctx.Err()) are already non-retryable and so don't count as breaker failures either.
  • A per-attempt timeout from WithTimeout (child-context context.DeadlineExceeded, parent still alive) is retryable by default, so it does count as a breaker failure.
  • If a caller supplies WithRetryIf, that predicate becomes the breaker's failure definition too -- one policy, no divergence between "should I retry this" and "does this count against the circuit."

The breaker transitions between states based on the number of consecutive successes or failures, and it automatically transitions from Open to HalfOpen after the specified timeout.

WithCircuitBreaker composes naturally with everything that exists today:

  • try.Do(ctx, fn, try.WithCircuitBreaker(cb), try.WithAttempts(3))
  • try.New(try.WithCircuitBreaker(cb), ...).Do(ctx, fn)

Integration in the retry loop

If a circuit breaker is configured:

  • At the top of each attempt (just after ctx.Err() is checked), call err := cb.allow(cfg.Clock):
    • If non-nil (ErrCircuitOpen), return it immediately -- no more attempts, no backoff wait -- wrapping lastErr if one exists, same convention as cancelledErr.
    • If nil, drives Open -> HalfOpen transition automatically once OpenTimeout has elapsed.
    • Returns ErrCircuitOpen if the circuit is HalfOpen and a trial call is already in flight.
  • If the attempt succeeds (err == nil), call cb.recordSuccess(cfg.Clock).
    • Resets the failure count to zero.
    • Drives HalfOpen -> Closed transition upon reaching SuccessThreshold consecutive successful attempts.
  • If it fails, and the attempt is retryable (shouldRetry), call cb.recordFailure(cfg.Clock).
    • Resets the success count to zero.
    • Drives HalfOpen -> Open transition on any failure.
    • Drives Closed -> Open transition upon reaching FailureThreshold consecutive failed attempts.

Open questions

  • WithAttemptsForError interaction: once a specific error's per-error budget is exhausted, shouldRetry starts returning false for it -- meaning further occurrences of that same error stop counting toward the breaker too, even though the downstream may still genuinely be failing. Acceptable, or does breaker accounting need to be independent of per-error retry budgets?
  • Should RetryAfterer errors be counted against the breaker? Leaning no, because a rate limit response suggests the server is still healthy, but the client needs to slow down.
  • Should WithDelayFunc be allowed to override the default backoff for trial calls in HalfOpen? Leaning no, since the point of the breaker is to avoid hammering a downstream that's still recovering. But may be worth taking the max of the two, since a downstream may have its own rate limiting that should be respected.
  • Should using WithDelayFunc change whether errors are counted against the breaker? Leaning no--we can't distinguish whether an error is a rate limit or whether we're just providing a custom backoff strategy. If clients need to avoid counting rate limiting against the breaker, they should wrap errors in a RetryAfterer.
  • Default success/failure thresholds and open timeout: what are reasonable defaults for a generic downstream service? Should these be NewCircuitBreaker constructor arguments instead of options?
  • Is the consecutive successful/failed requests policy naive? Do we need to think about error rates and time windows?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions