Skip to content

Commit

Permalink
Add first working prototype
Browse files Browse the repository at this point in the history
  • Loading branch information
vassilevsky committed Sep 27, 2017
1 parent ba758af commit a08476a
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 28 deletions.
2 changes: 0 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -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
21 changes: 3 additions & 18 deletions circuit.gemspec
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
# 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"
spec.version = Circuit::VERSION
spec.authors = ["Ilya Vassilevsky"]
spec.email = ["[email protected]"]

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"
Expand Down
54 changes: 51 additions & 3 deletions lib/circuit.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 0 additions & 3 deletions lib/circuit/version.rb

This file was deleted.

96 changes: 94 additions & 2 deletions spec/circuit_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit a08476a

Please sign in to comment.