├── lib ├── breaker │ ├── version.rb │ ├── in_memory_repo.rb │ └── test_cases.rb └── breaker.rb ├── test ├── test_helper.rb └── acceptance_test.rb ├── Gemfile ├── Rakefile ├── .gitignore ├── breaker.gemspec ├── LICENSE.txt └── README.md /lib/breaker/version.rb: -------------------------------------------------------------------------------- 1 | module Breaker 2 | VERSION = "0.1.1" 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | 3 | require 'minitest/autorun' 4 | 5 | require 'breaker' 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in breaker.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require 'rake/testtask' 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.test_files = Rake::FileList['test/**/*_test.rb'] 7 | end 8 | 9 | task default: :test 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /test/acceptance_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | class AcceptanceTest < MiniTest::Unit::TestCase 4 | include Breaker::TestCases 5 | 6 | InMemoryFuse = Struct.new :state, :failure_count, :retry_threshold, 7 | :failure_threshold, :retry_timeout, :timeout 8 | 9 | attr_reader :fuse, :repo 10 | 11 | def setup 12 | @repo = Breaker::InMemoryRepo.new 13 | @fuse = InMemoryFuse.new :closed, 0, 1, 3, 15, 10 14 | super 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /breaker.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'breaker/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "breaker" 8 | spec.version = Breaker::VERSION 9 | spec.authors = ["ahawkins"] 10 | spec.email = ["adam@hawkins.io"] 11 | spec.description = %q{Circuit breaker pattern for well designed Ruby applications } 12 | spec.summary = %q{} 13 | spec.homepage = "https://github.com/ahawkins/breaker" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 1.3" 22 | spec.add_development_dependency "rake" 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 ahawkins 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/breaker/in_memory_repo.rb: -------------------------------------------------------------------------------- 1 | module Breaker 2 | class InMemoryRepo 3 | Fuse = Struct.new :name, :state, :failure_threshold, :retry_timeout, :timeout, :failure_count, :retry_threshold do 4 | def initialize(*args) 5 | super 6 | 7 | self.failure_threshold ||= 10 8 | self.retry_timeout ||= 60 9 | self.timeout ||= 5 10 | 11 | self.state ||= :closed 12 | self.failure_count ||= 0 13 | end 14 | 15 | def ==(other) 16 | other.instance_of?(self.class) && name == other.name 17 | end 18 | end 19 | 20 | attr_reader :store 21 | 22 | def initialize 23 | @store = [] 24 | end 25 | 26 | def upsert(attributes) 27 | existing = named attributes.fetch(:name) 28 | if existing 29 | update existing, attributes 30 | else 31 | create attributes 32 | end 33 | end 34 | 35 | def count 36 | store.length 37 | end 38 | 39 | def first 40 | store.first 41 | end 42 | 43 | def named(name) 44 | store.find { |fuse| fuse.name == name } 45 | end 46 | 47 | def create(attributes) 48 | fuse = Fuse.new 49 | 50 | attributes.each_pair do |key, value| 51 | fuse.send "#{key}=", value 52 | end 53 | 54 | store << fuse 55 | 56 | fuse 57 | end 58 | 59 | def update(existing, attributes) 60 | existing 61 | 62 | attributes.each_pair do |key, value| 63 | existing.send "#{key}=", value 64 | end 65 | 66 | existing 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/breaker.rb: -------------------------------------------------------------------------------- 1 | require "breaker/version" 2 | require 'timeout' 3 | 4 | module Breaker 5 | CircuitOpenError = Class.new RuntimeError 6 | 7 | class << self 8 | def circuit(name, options = {}) 9 | fuse = repo.upsert options.merge(name: name) 10 | 11 | circuit = Circuit.new fuse 12 | 13 | if block_given? 14 | circuit.run do 15 | yield 16 | end 17 | end 18 | 19 | circuit 20 | end 21 | 22 | def closed?(name) 23 | circuit(name).closed? 24 | end 25 | alias up? closed? 26 | 27 | def open?(name) 28 | circuit(name).open? 29 | end 30 | alias down? open? 31 | 32 | def repo 33 | @repo 34 | end 35 | 36 | def repo=(repo) 37 | @repo = repo 38 | end 39 | end 40 | 41 | class Circuit 42 | attr_accessor :fuse 43 | 44 | def initialize(fuse) 45 | @fuse = fuse 46 | end 47 | 48 | def name 49 | fuse.name 50 | end 51 | 52 | def open(clock = Time.now) 53 | fuse.state = :open 54 | fuse.retry_threshold = clock + retry_timeout 55 | end 56 | 57 | def close 58 | fuse.failure_count = 0 59 | fuse.state = :closed 60 | fuse.retry_threshold = nil 61 | end 62 | 63 | def ==(other) 64 | other.instance_of?(self.class) && fuse == other.fuse 65 | end 66 | 67 | def open? 68 | fuse.state == :open 69 | end 70 | alias down? open? 71 | 72 | def closed? 73 | fuse.state == :closed 74 | end 75 | alias up? closed? 76 | 77 | def retry_timeout 78 | fuse.retry_timeout 79 | end 80 | 81 | def failure_count 82 | fuse.failure_count 83 | end 84 | 85 | def failure_threshold 86 | fuse.failure_threshold 87 | end 88 | 89 | def timeout 90 | fuse.timeout 91 | end 92 | 93 | def run(clock = Time.now) 94 | if closed? || half_open?(clock) 95 | begin 96 | result = Timeout.timeout timeout do 97 | yield 98 | end 99 | 100 | if half_open?(clock) 101 | close 102 | end 103 | 104 | result 105 | rescue => ex 106 | fuse.failure_count = fuse.failure_count + 1 107 | 108 | open clock if tripped? 109 | 110 | raise ex 111 | end 112 | else 113 | raise Breaker::CircuitOpenError, "Cannot run code while #{name} is open!" 114 | end 115 | end 116 | 117 | private 118 | def tripped? 119 | fuse.failure_count > fuse.failure_threshold 120 | end 121 | 122 | def half_open?(clock) 123 | tripped? && clock >= fuse.retry_threshold 124 | end 125 | end 126 | end 127 | 128 | require_relative 'breaker/in_memory_repo' 129 | require_relative 'breaker/test_cases' 130 | 131 | Breaker.repo = Breaker::InMemoryRepo.new 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Breaker 2 | 3 | Circuit Breakers for well designed applications. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | gem 'breaker' 10 | 11 | And then execute: 12 | 13 | $ bundle 14 | 15 | Or install it yourself as: 16 | 17 | $ gem install breaker 18 | 19 | ## Circuit Breaker Pattern 20 | 21 | The circuit breaker pattern is described in the (wonderful) book 22 | [Release It](http://pragprog.com/book/mnee/release-it) by Michael T. 23 | Nygard. A circuit breaker in programming terms is modeled after a 24 | circuit breaker in the real world. A circuit breaker protects the 25 | larger system from failures in other systems. They are especially 26 | useful for protecting an application from finnicky remote systems. 27 | 28 | The circuit is a state machine. It has three states: open, closed, and 29 | half-open. The circuit starts off in `closed` state--normal operation. 30 | If the operation failures N times (the failure threshold) the circuit 31 | moves to `open`. Calls in the `open` state fail immediately with an 32 | an exception. After a specified time period has passed (retry 33 | timeout) the circuit moves into `half-open`. Calls happen normally. If 34 | a call fails the state moves to `open`. If the call suceeds it moves 35 | to `closed`. All calls are capped with a timeout. If a timeouts count 36 | as failures. 37 | 38 | ## Usage 39 | 40 | Most interaction should go through `Breaker.circuit`. This is a 41 | factory method that creates `Breaker::Circuit` objects. It requires 42 | one argument: the name. It also takes an `options` hash for 43 | customizing the circuit. Thirdly it takes a block to run inside the 44 | circuit. Here are some examples: 45 | 46 | ```ruby 47 | # Simplest example: protect some code with a circuit breaker 48 | Breaker.circuit 'twitter' do 49 | Tweet.post 'Oh Hai' 50 | end 51 | ``` 52 | 53 | `Breaker.circuit` is an upsert opertion. It will create or update an 54 | existing circuit. Pass the options hash to customize the circuit's 55 | behavior. 56 | 57 | ```ruby 58 | circuit = Breaker.circuit 'twitter', timeout: 5 59 | circuit.run do 60 | Tweet.post 'Oh Hai' 61 | end 62 | ``` 63 | 64 | `Breaker.circuit` returns `Breaker::Circuit` instances which can be 65 | saved for later. There use depends no how persistence works. 66 | 67 | ## Persistence 68 | 69 | Circuit breakers are only really useful in a large system (perhaps 70 | distributed). Some information must be shared acrosss subsystems. Say 71 | there are 5 different services in the system. Each is 72 | talking to 2 different systems protected by circuit breakers. Either 73 | of the systems go down. It's natural that the failure should propogate 74 | through the system so that each service knows the shared ones are 75 | down. This is where state and persistence come into play. 76 | 77 | The breaker gems bundles a simple in memory repository. This is 78 | process specific. If you need to share state across multiple processes 79 | then you must write your own repository. 80 | 81 | The repository manages fueses (in the eletrical sense). A fuse 82 | maintains state. The repository must implement one method: `upsert` 83 | which creates or updates a fuse given by name. The repistory can 84 | return some sort of persistent fuse where writer methods write to 85 | persistent storage. 86 | 87 | Refer to `lib/breaker/in_memory_repo.rb` for an example. The class is 88 | very simple. 89 | 90 | ## Contributing 91 | 92 | 1. Fork it 93 | 2. Create your feature branch (`git checkout -b my-new-feature`) 94 | 3. Commit your changes (`git commit -am 'Add some feature'`) 95 | 4. Push to the branch (`git push origin my-new-feature`) 96 | 5. Create new Pull Request 97 | -------------------------------------------------------------------------------- /lib/breaker/test_cases.rb: -------------------------------------------------------------------------------- 1 | module Breaker 2 | module TestCases 3 | DummyError = Class.new RuntimeError 4 | 5 | def repo 6 | flunk "Test must define a repo to use" 7 | end 8 | 9 | def setup 10 | Breaker.repo = repo 11 | end 12 | 13 | def test_new_fuses_start_off_clean 14 | circuit = Breaker.circuit 'test' 15 | 16 | assert circuit.closed?, "New circuits should be closed" 17 | assert_equal 0, circuit.failure_count 18 | end 19 | 20 | def test_goes_into_open_state_when_failure_threshold_reached 21 | circuit = Breaker.circuit 'test', failure_threshold: 5, retry_timeout: 30 22 | 23 | assert circuit.closed? 24 | 25 | circuit.failure_threshold.times do 26 | begin 27 | circuit.run do 28 | raise DummyError 29 | end 30 | rescue DummyError ; end 31 | end 32 | 33 | assert_equal circuit.failure_count, circuit.failure_threshold 34 | refute circuit.open? 35 | 36 | assert_raises DummyError do 37 | circuit.run do 38 | raise DummyError 39 | end 40 | end 41 | 42 | assert circuit.open? 43 | 44 | assert_raises Breaker::CircuitOpenError do 45 | circuit.run do 46 | assert false, "Block should not run in this state" 47 | end 48 | end 49 | end 50 | 51 | def test_success_in_half_open_state_moves_circuit_into_closed 52 | clock = Time.now 53 | circuit = Breaker.circuit 'test', failure_threshold: 2, retry_timeout: 15 54 | 55 | (circuit.failure_threshold + 1).times do 56 | begin 57 | circuit.run clock do 58 | raise DummyError 59 | end 60 | rescue DummyError ; end 61 | end 62 | 63 | assert circuit.open? 64 | 65 | assert_raises Breaker::CircuitOpenError do 66 | circuit.run clock do 67 | # nothing 68 | end 69 | end 70 | 71 | circuit.run clock + circuit.retry_timeout do 72 | # do nothing, this works and flips the circuit back closed 73 | end 74 | 75 | assert circuit.closed? 76 | end 77 | 78 | def test_failures_in_half_open_state_push_retry_timeout_back 79 | clock = Time.now 80 | circuit = Breaker.circuit 'test', failure_threshold: 1, retry_timeout: 15 81 | 82 | (circuit.failure_threshold + 1).times do 83 | begin 84 | circuit.run clock do 85 | raise DummyError 86 | end 87 | rescue DummyError ; end 88 | end 89 | 90 | assert circuit.open? 91 | 92 | assert_raises DummyError do 93 | circuit.run clock + circuit.retry_timeout do 94 | raise DummyError 95 | end 96 | end 97 | 98 | assert_raises Breaker::CircuitOpenError do 99 | circuit.run clock + circuit.retry_timeout do 100 | assert false, "Block should not be run while in this state" 101 | end 102 | end 103 | 104 | assert_raises DummyError do 105 | circuit.run clock + circuit.retry_timeout * 2 do 106 | raise DummyError 107 | end 108 | end 109 | end 110 | 111 | def test_counts_timeouts_as_trips 112 | circuit = Breaker.circuit 'test', retry_timeout: 15, timeout: 0.01 113 | assert circuit.closed? 114 | 115 | assert_raises TimeoutError do 116 | circuit.run do 117 | sleep circuit.timeout * 2 118 | end 119 | end 120 | end 121 | 122 | def test_circuit_factory_persists_fuses 123 | circuit_a = Breaker.circuit 'test' 124 | circuit_b = Breaker.circuit 'test' 125 | 126 | assert_equal circuit_a, circuit_b, "Multiple calls to `circuit` should return the same circuit" 127 | 128 | assert_equal 1, Breaker.repo.count 129 | fuse = Breaker.repo.first 130 | 131 | assert_equal 'test', fuse.name 132 | end 133 | 134 | def test_circuit_factory_creates_new_fuses_with_sensible_defaults 135 | circuit = Breaker.circuit 'test' 136 | 137 | assert_equal 1, Breaker.repo.count 138 | fuse = Breaker.repo.first 139 | 140 | assert_equal 10, fuse.failure_threshold, "Failure Theshold should have a default" 141 | assert_equal 60, fuse.retry_timeout, "Retry timeout should have a default" 142 | assert_equal 5, fuse.timeout, "Timeout should have a default" 143 | end 144 | 145 | def test_circuit_factory_updates_existing_fuses 146 | Breaker.circuit 'test' 147 | assert_equal 1, Breaker.repo.count 148 | 149 | Breaker.circuit 'test', failure_threshold: 1, 150 | retry_timeout: 2, timeout: 3 151 | 152 | assert_equal 1, Breaker.repo.count 153 | fuse = Breaker.repo.first 154 | 155 | assert_equal 1, fuse.failure_threshold 156 | assert_equal 2, fuse.retry_timeout 157 | assert_equal 3, fuse.timeout 158 | end 159 | 160 | def test_circuit_breaker_factory_can_run_code_through_the_circuit 161 | assert_raises DummyError do 162 | Breaker.circuit 'test' do 163 | raise DummyError 164 | end 165 | end 166 | end 167 | 168 | def test_breaker_query_methods 169 | circuit = Breaker.circuit 'test' 170 | circuit.close 171 | 172 | assert Breaker.closed?('test') 173 | assert Breaker.up?('test') 174 | refute Breaker.open?('test') 175 | refute Breaker.down?('test') 176 | 177 | circuit.open 178 | 179 | assert Breaker.open?('test') 180 | assert Breaker.down?('test') 181 | refute Breaker.closed?('test') 182 | refute Breaker.up?('test') 183 | end 184 | end 185 | end 186 | --------------------------------------------------------------------------------