Circuit Breaker in Ruby
2020-04-16
RubyFunctional ProgrammingI've developed a circuit breaker in Ruby, and published it as rcb
gem in RubyGems.org.
- GitHub: petitviolet/rcb
- RubyGems: rcb
What is CircuitBreaker?
CircuitBreaker is a well-known pattern to make services stable that was originally introduced by Martin Fowler in 2014.
It has three states, Close, Open, and HalfOpen.
- Close is a healthy state and able to run commands
- Once number of failures reach a given threshold in Close state, circuit breaker goes to Open state
- In Open state, circuit breaker does not allow to run any command
- After a while in Open state, goes to HalfOpen state and try a command once
- if get succeeded, goes to Close state
- otherwise, back to Open state
In short, circuit breaker is a pattern to avoid overwhelming services that are unavailable by stopping requests to them. Martin Fowler's post describes what circuit breake is along with Ruby sample codes, but I implemented yet another one for my learning :)
How to use the rcb gem
The following shows a simple usage.
Rcb.configure('api.example.com') do |config|
config.open_condition.max_failure_count 1
config.open_condition.window_msec 300
config.reset_timeout_msec 100
end
response = Rcb.for('api.example.com').run! do
http_client.get('api.example.com')
end
Put global configurations using Rcb.configure
with tag (e.g. api.example.com
, whatever).
In Rails app, config/initializers
will be a good place to put such configurations.
How to use rcb gem is that first creating a circuit breaker instance with Rcb.for
, and then pass a block to run!
method to apply circuit breaker. run!
does not restrict commands to HTTP requests or RPC calls. You can pass anything as long as they're Proc
objects.
As the method has !
suffix, the method invocation may throw an error when given block fails or if the circuit breaker instance's state is Open so that it expects to be rescue
ed.
By the way, it's able to create/get a circuit breaker instance with passing configuration to Rcb.for
factory method, that is, it can work without set global configuration with Rcb.configure
beforehand.
cb = Rcb.for(:hoge,
open_condition: Rcb::OpenCondition.new(5, 1000),
reset_timeout_msec: 3000)
cb.run! { 100 }
=> 100
Configuration
Rcb has two main configurations.
open_condition
- a condition to decide when trip from close to open state
- it has
max_failure_count
andwindow_msec
- if failure count reaches
max_failure_count
withinwindow_msec
milliseconds, the circuit breaker trips to open state.
- if failure count reaches
reset_timeout_msec
- if the circuit breaker is in open state and has passed
reset_timeout_msec
milliseconds, it goes to half-open state
- if the circuit breaker is in open state and has passed
State trip
Following shows how state trips.
>> Rcb.configure(:example) do |config|
config.open_condition.max_failure_count 2
config.open_condition.window_msec 1000
config.reset_timeout_msec 3000
end
>> instance = Rcb.for(:example)
>> instance.config
=> Rcb::Config(tag: example, open_condition: Rcb::OpenCondition(max_failure_count: 2, window_msec: 1000), reset_timeout_msec: 3000)
>> instance.run! { rand(2) == 1 ? (raise 'Fail!') : :success } rescue :fail
=> :success
>> instance.run! { rand(2) == 1 ? (raise 'Fail!') : :success } rescue :fail
=> :fail
>> instance.state
=> :close
>> instance.run! { rand(2) == 1 ? (raise 'Fail!') : :success } rescue :fail
=> :fail
>> instance.run! { rand(2) == 1 ? (raise 'Fail!') : :success } rescue :fail
=> :fail
>> instance.state
=> :open
>> sleep 3
>> instance.state
=> :half_open
>> instance.run! { rand(2) == 1 ? (raise 'Fail!') : :success } rescue :fail
=> :success
>> instance.state
=> :close
How to manage state
Rcb provides a default implementation for managing circuit breakers' states in-memory using class variables. It means that states can be shared only in the same process, cannot be shared with other processes, other machines.
If you want to share states in a broader context, you can inject your state store implementation to the factory method Rcb.for
.
It's able to use anything as long as it defines specified interface.
For example:
- use local files to share with other processes
- use Redis to share with other machines
- use Thread local variables to share only in the same thread
About implementation
The reason why I develop rcb gem is that I want to use Rstructural and guess it enables to coding state management easier.
As the implementation shows, Rcb::State
depends on Rstructural::ADT
and Rstructural::Either
so much.
I've written a blog post about pattern matching in Ruby and the Rstructural
gem.
All of Close
, Open
and HalfOpen
are ADT
object, and the logic of Close#run
uses Either.try
, and apply pattern matching on Either::Right
and Either::Left
to determine the given block get either succeeded or failed.
Rstructural::Either.try
rescues error thrown from a given block within run!
, and it represents either success or failure, so that easy to handle the results using pattern match.
module Rcb::State
extend ADT
Close = data :failure_times do
def run(config, now: Time.now.utc, &block)
case Either.try { block.call }
in Either::Right[result]
Rcb::Result::Ok.new(self, result)
in Either::Left[e]
old_limit = now - (config.open_condition.window_msec / 1000.0)
refreshed_failure_times = failure_times.filter { |ft| old_limit <= ft } + [now]
if refreshed_failure_times.size < config.open_condition.max_failure_count
Rcb::Result::Ng.new(Close.new(refreshed_failure_times), e)
else
Rcb::Result::Ng.new(Open.create, e)
end
end
end
end
Open = data :since do
def run(config, now: Time.now.utc, &block)
# ...
end
end
HalfOpen = const do
def run(&block)
case Either.try { block.call }
in Either::Right[result]
Rcb::Result::Ok.new(Close.create, result)
in Either::Left[e]
Rcb::Result::Ng.new(Open.create, e)
end
end
end
end
And also defined algebraic data types Rcb::Result
consists of Ok
and Ng
to contain either result or error along with success or failure.
module Rcb
module Result
extend ADT
Ok = data :new_state, :result
Ng = data :new_state, :error
end
end
It's absolutely easy to understand what Rcb::Result
is, because of the Rstructural gem and I'm familiar with functional programming, even if this ruby code does not have annotation of data types of these values.
Wrapping up
Implemented a circuit breaker in Ruby using Rstructural gem which offers algebraic data types. It's great experience using pattern matching even if it's untyped language.