ruby 104 lines · 3 tabs

Service-Level “Circuit Breaker” (Simple)

Shared by codesnips Jan 2026
3 tabs
class CircuitBreaker
  class CircuitOpenError < StandardError; end

  def initialize(name, redis: Redis.current, failure_threshold: 5, failure_window: 60, cooldown: 30)
    @key = "circuit:#{name}"
    @redis = redis
    @failure_threshold = failure_threshold
    @failure_window = failure_window
    @cooldown = cooldown
  end

  def run
    if open?
      raise CircuitOpenError, "circuit #{@key} is open"
    end

    result = yield
    record_success
    result
  rescue CircuitOpenError
    raise
  rescue StandardError => e
    record_failure
    raise e
  end

  def open?
    opened_at = @redis.get("#{@key}:opened_at")
    return false if opened_at.nil?
    Time.now.to_i - opened_at.to_i < @cooldown
  end

  private

  def record_success
    @redis.del("#{@key}:failures", "#{@key}:opened_at")
  end

  def record_failure
    failures = @redis.incr("#{@key}:failures")
    @redis.expire("#{@key}:failures", @failure_window) if failures == 1
    trip! if failures >= @failure_threshold
  end

  def trip!
    @redis.set("#{@key}:opened_at", Time.now.to_i)
  end
end
3 files · ruby Explain with highlit

A circuit breaker guards calls to an unreliable downstream service so that repeated failures stop being retried blindly. Instead of hammering a dead dependency and piling up slow, timing-out requests, the breaker "trips" after a threshold of failures and short-circuits subsequent calls for a cooldown window, failing fast until the service looks healthy again. This snippet shows a small, Redis-backed breaker wired into a Faraday client and consumed from a Rails controller.

In CircuitBreaker, the breaker models three states: :closed (normal), :open (tripped, rejecting calls), and :half_open (a trial period after cooldown). State is kept in Redis under a namespaced key so it is shared across every process and dyno, which matters because a per-instance in-memory counter would let each worker independently rediscover the outage. The run method checks open? first and raises CircuitOpenError immediately when the breaker is open and the cooldown has not elapsed. When the cooldown passes, open? returns false so exactly the next call is allowed through as a probe.

Success and failure are recorded via record_success and record_failure. A success clears the failure counter and closes the circuit; a failure increments a counter with an expiry equal to the failure window, and once it reaches failure_threshold the breaker writes an opened_at timestamp to trip. Using INCR with an EXPIRE gives a rolling window cheaply without storing individual timestamps.

PaymentsGateway composes the breaker with a real HTTP client. It configures Faraday with explicit open_timeout and timeout values — critical, since a breaker is useless if calls hang forever — and treats both network errors and 5xx responses as failures worth counting. Note that a CircuitOpenError propagates out untouched so callers can distinguish "we didn't even try" from a genuine gateway error.

PaymentsController shows the payoff: it rescues CircuitBreaker::CircuitOpenError and returns 503 with a Retry-After header, degrading gracefully instead of leaking timeouts to the user. The main trade-off is tuning: too low a threshold trips on transient blips, too long a cooldown delays recovery, and the half-open probe risks one extra failed call. This pattern fits any synchronous dependency whose latency or availability can drag down the caller.


Related snips

Share this code

Here's the card — post it anywhere.

Service-Level “Circuit Breaker” (Simple) — share card
Link copied