From a08476a7593f2bb8515b2dd2548e352b8a914f68 Mon Sep 17 00:00:00 2001 From: Ilya Vassilevsky Date: Thu, 28 Sep 2017 00:54:43 +0400 Subject: [PATCH] Add first working prototype --- Gemfile | 2 - circuit.gemspec | 21 ++------- lib/circuit.rb | 54 ++++++++++++++++++++++-- lib/circuit/version.rb | 3 -- spec/circuit_spec.rb | 96 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 148 insertions(+), 28 deletions(-) delete mode 100644 lib/circuit/version.rb diff --git a/Gemfile b/Gemfile index d6c75ad..e63a919 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,4 @@ source "https://rubygems.org" -git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } - # Specify your gem's dependencies in circuit.gemspec gemspec diff --git a/circuit.gemspec b/circuit.gemspec index fe77b4e..e7bea6b 100644 --- a/circuit.gemspec +++ b/circuit.gemspec @@ -1,7 +1,7 @@ # coding: utf-8 lib = File.expand_path("../lib", __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require "circuit/version" +require "circuit" Gem::Specification.new do |spec| spec.name = "circuit" @@ -9,25 +9,10 @@ Gem::Specification.new do |spec| spec.authors = ["Ilya Vassilevsky"] spec.email = ["vassilevsky@gmail.com"] - spec.summary = %q{TODO: Write a short summary, because Rubygems requires one.} - spec.description = %q{TODO: Write a longer description or delete this line.} - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "Allows a service to fail fast to prevent overload" spec.license = "MIT" - # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' - # to allow pushing to a single host or delete this section to allow pushing to any host. - if spec.respond_to?(:metadata) - spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" - else - raise "RubyGems 2.0 or newer is required to protect against " \ - "public gem pushes." - end - - spec.files = `git ls-files -z`.split("\x0").reject do |f| - f.match(%r{^(test|spec|features)/}) - end - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.files = `git ls-files -z`.split("\x0") spec.require_paths = ["lib"] spec.add_development_dependency "bundler", "~> 1.15" diff --git a/lib/circuit.rb b/lib/circuit.rb index bf0c941..b4db15a 100644 --- a/lib/circuit.rb +++ b/lib/circuit.rb @@ -1,5 +1,53 @@ -require "circuit/version" +class Circuit + VERSION = "0.1.0" -module Circuit - # Your code goes here... + def initialize(payload:, max_failures: 100, retry_in: 60) + @payload = payload + @max_failures = max_failures + @retry_in = retry_in + + @mutex = Mutex.new + + close + end + + def pass(message, *args) + fail @e if open? && !time_to_retry? + result = payload.public_send(message, *args) + close if open? + result + rescue => e + @e = e + @mutex.synchronize{ @failures[e.class] += 1 } + break! if @failures[e.class] > max_failures + raise e + end + + def open? + !closed? + end + + def closed? + @closed + end + + private + + attr_reader :payload + attr_reader :max_failures + attr_reader :retry_in + + def break! + @closed = false + @broken_at = Time.now + end + + def close + @closed = true + @failures = Hash.new(0) + end + + def time_to_retry? + @broken_at + retry_in < Time.now + end end diff --git a/lib/circuit/version.rb b/lib/circuit/version.rb deleted file mode 100644 index 92b30ab..0000000 --- a/lib/circuit/version.rb +++ /dev/null @@ -1,3 +0,0 @@ -module Circuit - VERSION = "0.1.0" -end diff --git a/spec/circuit_spec.rb b/spec/circuit_spec.rb index 810aea7..d818bef 100644 --- a/spec/circuit_spec.rb +++ b/spec/circuit_spec.rb @@ -5,7 +5,99 @@ expect(Circuit::VERSION).not_to be nil end - it "does something useful" do - expect(false).to eq(true) + context 'happy path' do + let(:foo){ Class.new{ def bar(baz); baz; end }.new } + + it 'passes through a message and returns the result' do + expect(foo).to receive(:bar).with(:baz).and_call_original + expect(Circuit.new(payload: foo).pass(:bar, :baz)).to eq(:baz) + end + end + + context 'when method call fails' do + class BarError < RuntimeError; end + + let(:foo){ Class.new{ def bar; fail BarError; end }.new } + let(:circuit){ Circuit.new(payload: foo) } + + it 'counts the error and lets it raise' do + expect{circuit.pass(:bar)}.to raise_error(BarError) + end + + context 'when circuit has too many errors' do + let(:circuit){ Circuit.new(payload: foo, max_failures: 1) } + + it 'breaks the circuit - does not call the payload anymore, fails immediately' do + expect(circuit).to be_closed + expect{circuit.pass(:bar)}.to raise_error(BarError) + expect(circuit).to be_closed + expect(foo).to receive(:bar).and_call_original + expect{circuit.pass(:bar)}.to raise_error(BarError) + expect(circuit).not_to be_closed + expect(circuit).to be_open + expect(foo).not_to receive(:bar) + expect{circuit.pass(:bar)}.to raise_error(BarError) + end + + context 'when the next call is successful again' do + let(:foo) do + Class.new do + def initialize + @messages = 0 + end + + def bar + @messages += 1 + + if @messages < 3 + fail BarError + else + :baz + end + end + end.new + end + + let(:circuit){ Circuit.new(payload: foo, max_failures: 1, retry_in: 1) } + + it 'closes back the circuit' do + expect{circuit.pass(:bar)}.to raise_error(BarError) + expect{circuit.pass(:bar)}.to raise_error(BarError) + expect(circuit).to be_open + expect{circuit.pass(:bar)}.to raise_error(BarError) + sleep 1 + expect(circuit.pass(:bar)).to eq(:baz) + expect(circuit).to be_closed + end + end + + context 'when payload raises different errors' do + class Error1 < RuntimeError; end + class Error2 < RuntimeError; end + + let(:foo) do + Class.new do + def initialize + @errors = [Error1, Error2, Error1] + end + + def bar + fail @errors.shift + end + end.new + end + + let(:circuit){ Circuit.new(payload: foo, max_failures: 1) } + + it 'fails with top error after break' do + expect{circuit.pass(:bar)}.to raise_error(Error1) + expect{circuit.pass(:bar)}.to raise_error(Error2) + expect(circuit).to be_closed + expect{circuit.pass(:bar)}.to raise_error(Error1) + expect(circuit).to be_open + expect{circuit.pass(:bar)}.to raise_error(Error1) + end + end + end end end