Minimal circuit breaker with configurable thresholds, error filtering, and exponential backoff
gem install philiprehberger-circuitMinimal circuit breaker with configurable thresholds, error filtering, and exponential backoff
Add to your Gemfile:
gem "philiprehberger-circuit"
Or install directly:
gem install philiprehberger-circuit
require "philiprehberger/circuit"
breaker = Philiprehberger::Circuit::Breaker.new(:payment_api, threshold: 5, timeout: 30)
breaker.call do
PaymentGateway.charge(amount)
end
# After 5 failures -> circuit opens -> raises OpenError for 30s
# After 30s -> half-open -> allows one probe request
Return a fallback value when the circuit is open instead of raising:
breaker.call(fallback: -> { :queued }) do
PaymentGateway.charge(amount)
end
breaker = Philiprehberger::Circuit::Breaker.new(:api, error_classes: [Net::TimeoutError, Net::OpenTimeout])
Limit the number of probe requests allowed in half-open state:
breaker = Philiprehberger::Circuit::Breaker.new(:api, half_open_requests: 2)
# Only 2 requests pass through in half-open; the rest are rejected
Access counters for monitoring:
breaker.metrics
# => { success_count: 42, failure_count: 3, rejected_count: 7, state_changes: [...] }
Each state change entry contains { from:, to:, at: } with the transition timestamp.
Inspect the most recent failure recorded by the breaker for diagnostics.
Returns nil when no failure has happened since the last reset.
breaker.call { raise Net::OpenTimeout, 'connection refused' } rescue nil
breaker.last_failure
# => { at: 2026-04-28 14:32:08 +0000, error_class: Net::OpenTimeout, message: "connection refused" }
last_failure is captured for both closed-state failures and half-open
probe failures, and is cleared by #metrics_reset!.
Zero the counters and clear the state-change log without touching the current state (useful for periodic metric windows):
breaker.metrics_reset!
breaker.metrics
# => { success_count: 0, failure_count: 0, rejected_count: 0, state_changes: [] }
# breaker.state is unchanged
Use exponential backoff for the open-to-half-open timeout:
breaker = Philiprehberger::Circuit::Breaker.new(
:api,
timeout_strategy: :exponential,
base_timeout: 10,
max_timeout: 300
)
# Timeouts double on each consecutive open: 20s, 40s, 80s, ... capped at 300s
# Resets to base_timeout when circuit closes
breaker.on_open { AlertService.notify("Circuit opened!") }
breaker.on_close { AlertService.notify("Circuit recovered") }
breaker.on_half_open { Logger.info("Circuit probing...") }
Administratively force the circuit into the open state, regardless of current state or failure count:
breaker.trip!
breaker.state # => :open
# Subsequent calls short-circuit like any other open-state call
breaker.call(fallback: -> { :queued }) { PaymentGateway.charge(amount) }
# => :queued
Idiomatic predicate methods for testing the current state without comparing against state symbols:
breaker.closed? # => true (fresh breaker)
breaker.open? # => false
breaker.half_open? # => false
breaker.trip!
breaker.open? # => true
breaker.closed? # => false
Force the breaker into a fixed state for maintenance windows. While forced, automatic state transitions (failure-based opening, timeout-based half-open probes) are suspended until reset! is called:
# Force open during a maintenance window — rejects all calls regardless of timeout
breaker.force_open!
breaker.forced? # => true
breaker.call { :anything } # => raises OpenError
# Force closed to bypass the breaker entirely — accepts all calls, ignores failures
breaker.force_closed!
breaker.forced? # => true
# Clear the forced flag and return to normal automatic behavior
breaker.reset!
breaker.forced? # => false
Hook into every state transition for logging or alerting:
breaker.on_reset do |from_state, to_state|
Logger.info("Circuit #{breaker.name} transitioned from #{from_state} to #{to_state}")
end
| Method | Description |
|---|---|
Breaker.new(name, **opts) | Create a breaker (see options below) |
#call(fallback: nil) { block } | Execute with circuit protection |
#state | Current state (:closed, :open, :half_open) |
#closed? / #open? / #half_open? | State predicates |
#reset! | Force back to closed |
#trip! | Force open (administrative trip) |
#force_open! | Force open and suspend automatic transitions until #reset! |
#force_closed! | Force closed and suspend automatic transitions until #reset! |
#forced? | Whether the breaker is in a manually forced state |
#metrics | Returns { success_count:, failure_count:, rejected_count:, state_changes: [] } |
#last_failure | Returns { at:, error_class:, message: } for the most recent failure (or nil) |
#metrics_reset! | Zero counters and clear state-change log without altering current state |
#on_open { } | Callback when circuit opens |
#on_close { } | Callback when circuit closes |
#on_half_open { } | Callback when circuit enters half-open |
#on_reset { |from, to| } | Callback on every state transition |
| Option | Default | Description |
|---|---|---|
threshold: | 5 | Failures before opening |
timeout: | 30 | Seconds before trying half-open (fixed strategy) |
error_classes: | [StandardError] | Exception classes to count |
half_open_requests: | 1 | Max probe requests in half-open |
timeout_strategy: | :fixed | :fixed or :exponential |
base_timeout: | timeout | Base timeout for exponential backoff |
max_timeout: | base * 32 | Max timeout cap for exponential backoff |
bundle install
bundle exec rspec
bundle exec rubocop
If you find this project useful: