├── .github
└── workflows
│ └── crystal.yml
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── doc
├── CircuitBreaker.html
├── css
│ └── style.css
├── index.html
└── js
│ └── doc.js
├── shard.yml
├── spec
├── circuit_breaker_spec.cr
├── circuit_state_spec.cr
└── error_watcher_spec.cr
└── src
├── circuit_breaker.cr
├── circuit_state.cr
└── error_watcher.cr
/.github/workflows/crystal.yml:
--------------------------------------------------------------------------------
1 | name: Crystal CI
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 | pull_request:
7 | branches: [ "master" ]
8 | schedule:
9 | - cron: "0 0 * * 1"
10 |
11 | jobs:
12 | build:
13 |
14 | runs-on: ubuntu-latest
15 |
16 | container:
17 | image: crystallang/crystal
18 |
19 | steps:
20 | - uses: actions/checkout@v3
21 | - name: Install dependencies
22 | run: shards install
23 | - name: Run tests
24 | run: crystal spec
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: crystal
2 | crystal:
3 | - latest
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Thomas Peikert
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # circuit_breaker
2 |
3 | ###### This project is being built weekly with the latest crystal version (works with v1.7.2 🎉)
4 |
5 | Simple Implementation of the [circuit breaker pattern](http://martinfowler.com/bliki/CircuitBreaker.html) in Crystal.
6 |
7 | ## What??!?
8 |
9 | > The basic idea behind the circuit breaker is very simple. You wrap a protected function call in a circuit breaker object, which monitors for failures. Once the failures reach a certain threshold, the circuit breaker trips, and all further calls to the circuit breaker return with an error, without the protected call being made at all. Usually you'll also want some kind of monitor alert if the circuit breaker trips. - Martin Fowler
10 |
11 | Given a certain error threshold, timeframe and timeout window, a breaker can be used to monitor criticial command executions. Circuit breakers are usually used to prevent unnecessary requests if a server ressource et al becomes unavailable. This protects the server from additional load and allows it to recover and relieves the client from requests that are doomed to fail.
12 |
13 | Wrap API calls inside a breaker, if the error rate in a given time frame surpasses a certain threshold, all subsequent calls will fail for a given duration.
14 |
15 | ## Installation
16 |
17 | Add to your shard.yml
18 |
19 | ```yaml
20 | dependencies:
21 | circuit_breaker:
22 | github: tpei/circuit_breaker
23 | branch: master
24 | ```
25 |
26 | and then install the library into your project with
27 |
28 | ```bash
29 | $ crystal deps
30 | ```
31 |
32 | ## Usage
33 |
34 | Create a new breaker:
35 | ```crystal
36 | require "circuit_breaker"
37 |
38 | breaker = CircuitBreaker.new(
39 | threshold: 5, # % of errors before you want to trip the circuit
40 | timewindow: 60, # in s: anything older will be ignored in error_rate
41 | reenable_after: 300 # after x seconds, the breaker will allow executions again
42 | )
43 | ```
44 |
45 | Then wrap whatever you like:
46 | ```crystal
47 | breaker.run do
48 | my_rest_call()
49 | end
50 | ```
51 |
52 | ### Handling CircuitBreaker trips
53 |
54 | The Breaker will open and throw an CircuitOpenException for all subsequent calls, once the threshold is reached. You can of course catch these exceptions and do whatever you want :D
55 | ```crystal
56 | begin
57 | breaker.run do
58 | my_rest_call()
59 | end
60 | rescue exc : CircuitOpenException
61 | log "happens to the best of us..."
62 | 42
63 | end
64 | ```
65 |
66 | After the given reenable time, the circuit will transition to "half open". This will completely reset the circuit if the next execution succeeds, but reopen the circuit and reset the timer if the next execution fails.
67 |
68 | ### Handling only certain error types
69 |
70 | If you are feeling really funky, you can also limit the exception classes to monitor. You might want to catch `RandomRestError`, but not `ArgumentError`, so do this:
71 | ```crystal
72 | breaker = CircuitBreaker.new(
73 | threshold: 5,
74 | timewindow: 60,
75 | reenable_after: 300,
76 | handled_errors: [RandomRestError.new]
77 | )
78 |
79 | breaker.run
80 | raise ArgumentError.new("won't count towards the error rate")
81 | end
82 | ```
83 |
84 | ### Ignoring certain error types
85 |
86 | Conversely, you can also add custom errors to ignore and count all others:
87 | ```crystal
88 | breaker = CircuitBreaker.new(
89 | threshold: 5,
90 | timewindow: 60,
91 | reenable_after: 300,
92 | ignored_errors: [ArgumentError.new]
93 | )
94 |
95 | breaker.run
96 | raise ArgumentError.new("won't count towards the error rate")
97 | end
98 | ```
99 |
100 | Unfortunately this both won't match against exception subclasses just yet, so at the moment you have to specify the exact class to monitor and can't just use `RestException` to match every subclass like `RestTimeoutException < RestException`...
101 |
102 |
103 | ## Thanks
104 | Special thanks goes to Pedro Belo on whose ruby circuit breaker implementation ([CB2](https://github.com/pedro/cb2)) this is loosely based.
105 |
--------------------------------------------------------------------------------
/doc/CircuitBreaker.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Simple Implementation of the circuit breaker pattern in Crystal.
48 |
49 |
Given a certain error threshold, timeframe and timeout window, a breaker can be used to monitor criticial command executions. Circuit breakers are usually used to prevent unnecessary requests if a server ressource et al becomes unavailable. This protects the server from additional load and allows it to recover and relieves the client from requests that are doomed to fail.
50 |
51 |
Wrap API calls inside a breaker, if the error rate in a given time frame surpasses a certain threshold, all subsequent calls will fail for a given duration.
get's passed a block to watch for errors every error thrown inside your block counts towards the error rate once the threshold is surpassed, it starts throwing CircuitOpenExceptions you can catch these rrors and implement some fallback behaviour ` begin breaker.run do my_rest_call() end rescue exc : CircuitOpenException log "happens to the best of us..." 42 end `
creates a CircuitBreaker instance with a specified error threshold, timeframe, breaker duration and optionally a number of ignored or handled errors
138 |
139 |
breaker =CircuitBreaker.new(
140 | threshold: 5, # % of errors before you want to trip the circuit
141 | timewindow: 60, # in s: anything older will be ignored in error_rate
142 | reenable_after: 300# after x seconds, the breaker will allow executions again
143 | )
get's passed a block to watch for errors
167 | every error thrown inside your block counts towards the error rate
168 | once the threshold is surpassed, it starts throwing CircuitOpenExceptions
169 | you can catch these rrors and implement some fallback behaviour
170 |
171 |
begin
172 | breaker.run do
173 | my_rest_call()
174 | end
175 | rescue exc : CircuitOpenException
176 | log "happens to the best of us..."
177 | 42
178 | end
Given a certain error threshold, timeframe and timeout window, a breaker can be used to monitor criticial command executions. Circuit breakers are usually used to prevent unnecessary requests if a server ressource et al becomes unavailable. This protects the server from additional load and allows it to recover and relieves the client from requests that are doomed to fail.
38 |
39 |
Wrap API calls inside a breaker, if the error rate in a given time frame surpasses a certain threshold, all subsequent calls will fail for a given duration.
and then install the library into your project with
51 |
52 |
$ crystal deps
53 |
54 |
Usage
55 |
56 |
Create a new breaker:
57 |
58 |
require"circuit_breaker"
59 |
60 | breaker =CircuitBreaker.new(
61 | threshold: 5, # % of errors before you want to trip the circuit
62 | timewindow: 60, # in s: anything older will be ignored in error_rate
63 | reenable_after: 300# after x seconds, the breaker will allow executions again
64 | )
65 |
66 |
Then wrap whatever you like:
67 |
68 |
breaker.run do
69 | my_rest_call()
70 | end
71 |
72 |
The Breaker will open and throw an CircuitOpenException for all subsequent calls, once the threshold is reached. You can of course catch these exceptions and do whatever you want :D
73 |
74 |
begin
75 | breaker.run do
76 | my_rest_call()
77 | end
78 | rescue exc : CircuitOpenException
79 | log "happens to the best of us..."
80 | 42
81 | end
82 |
83 |
After the given reenable time, the circuit will transition to "half open". This will completely reset the circuit if the next execution succeeds, but reopen the circuit and reset the timer if the next execution fails.
84 |
85 |
If you are feeling really funky, you can also hand in exception classes to monitor. You might want to catch RandomRestError, but not ArgumentError, so do this:
Unfortunately this both won't match against exception subclasses just yet, so at the moment you have to specify the exact class to monitor and can't just use RestException to match every subclass like RestTimeoutException < RestException...
112 |
113 |
Thanks
114 |
115 |
Special thanks goes to Pedro Belo on whose ruby circuit breaker implementation (CB2) this is loosely based.
116 |
117 |
118 |
119 |
--------------------------------------------------------------------------------
/doc/js/doc.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | var sessionStorage = window.sessionStorage;
3 | if(!sessionStorage) {
4 | sessionStorage = {
5 | setItem: function() {},
6 | getItem: function() {},
7 | removeItem: function() {}
8 | };
9 | }
10 |
11 | var repositoryName = document.getElementById('repository-name').getAttribute('content');
12 | var typesList = document.getElementById('types-list');
13 | var searchInput = document.getElementById('search-input');
14 | var parents = document.querySelectorAll('#types-list li.parent');
15 |
16 | for(var i = 0; i < parents.length; i++) {
17 | var _parent = parents[i];
18 | _parent.addEventListener('click', function(e) {
19 | e.stopPropagation();
20 |
21 | if(e.target.tagName.toLowerCase() == 'li') {
22 | if(e.target.className.match(/open/)) {
23 | sessionStorage.removeItem(e.target.getAttribute('data-id'));
24 | e.target.className = e.target.className.replace(/ +open/g, '');
25 | } else {
26 | sessionStorage.setItem(e.target.getAttribute('data-id'), '1');
27 | if(e.target.className.indexOf('open') == -1) {
28 | e.target.className += ' open';
29 | }
30 | }
31 | }
32 | });
33 |
34 | if(sessionStorage.getItem(_parent.getAttribute('data-id')) == '1') {
35 | _parent.className += ' open';
36 | }
37 | };
38 |
39 | var childMatch = function(type, regexp){
40 | var types = type.querySelectorAll("ul li");
41 | for (var j = 0; j < types.length; j ++) {
42 | var t = types[j];
43 | if(regexp.exec(t.getAttribute('data-name'))){ return true; };
44 | };
45 | return false;
46 | };
47 |
48 | var searchTimeout;
49 | var performSearch = function() {
50 | clearTimeout(searchTimeout);
51 | searchTimeout = setTimeout(function() {
52 | var text = searchInput.value;
53 | var types = document.querySelectorAll('#types-list li');
54 | var words = text.toLowerCase().split(/\s+/).filter(function(word) {
55 | return word.length > 0;
56 | });
57 | var regexp = new RegExp(words.join('|'));
58 |
59 | for(var i = 0; i < types.length; i++) {
60 | var type = types[i];
61 | if(words.length == 0 || regexp.exec(type.getAttribute('data-name')) || childMatch(type, regexp)) {
62 | type.className = type.className.replace(/ +hide/g, '');
63 | var is_parent = new RegExp("parent").exec(type.className);
64 | var is_not_opened = !(new RegExp("open").exec(type.className));
65 | if(childMatch(type,regexp) && is_parent && is_not_opened){
66 | type.className += " open";
67 | };
68 | } else {
69 | if(type.className.indexOf('hide') == -1) {
70 | type.className += ' hide';
71 | };
72 | };
73 | if(words.length == 0){
74 | type.className = type.className.replace(/ +open/g, '');
75 | };
76 | }
77 | }, 200);
78 | };
79 | if (searchInput.value.length > 0) {
80 | performSearch();
81 | }
82 | searchInput.addEventListener('keyup', performSearch);
83 | searchInput.addEventListener('input', performSearch);
84 |
85 | typesList.onscroll = function() {
86 | var y = typesList.scrollTop;
87 | sessionStorage.setItem(repositoryName + '::types-list:scrollTop', y);
88 | };
89 |
90 | var initialY = parseInt(sessionStorage.getItem(repositoryName + '::types-list:scrollTop') + "", 10);
91 | if(initialY > 0) {
92 | typesList.scrollTop = initialY;
93 | }
94 |
95 | var scrollToEntryFromLocationHash = function() {
96 | var hash = window.location.hash;
97 | if (hash) {
98 | var targetAnchor = unescape(hash.substr(1));
99 | var targetEl = document.querySelectorAll('.entry-detail[id="' + targetAnchor + '"]');
100 |
101 | if (targetEl && targetEl.length > 0) {
102 | targetEl[0].offsetParent.scrollTop = targetEl[0].offsetTop;
103 | }
104 | }
105 | };
106 | window.addEventListener("hashchange", scrollToEntryFromLocationHash, false);
107 | scrollToEntryFromLocationHash();
108 | });
109 |
--------------------------------------------------------------------------------
/shard.yml:
--------------------------------------------------------------------------------
1 | name: circuit_breaker
2 | version: 0.0.5
3 |
4 | authors:
5 | - tpei
6 |
--------------------------------------------------------------------------------
/spec/circuit_breaker_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../src/circuit_breaker.cr"
3 |
4 | describe "CircuitBreaker" do
5 | describe "#run" do
6 | it "returns original block value on success" do
7 | breaker = CircuitBreaker.new(threshold: 20, timewindow: 60, reenable_after: 2)
8 |
9 | breaker.run do
10 | 2
11 | end.should eq 2
12 | end
13 |
14 | it "passes on any raised exceptions" do
15 | breaker = CircuitBreaker.new(threshold: 20, timewindow: 60, reenable_after: 2)
16 |
17 | expect_raises MyError do
18 | breaker.run do
19 | raise MyError.new
20 | end
21 | end
22 | end
23 |
24 | it "throws a CircuitOpenException if the circuit is open" do
25 | breaker = CircuitBreaker.new(threshold: 20, timewindow: 60, reenable_after: 2)
26 |
27 | expect_raises MyError do
28 | breaker.run do
29 | raise MyError.new
30 | end
31 | end
32 |
33 | expect_raises CircuitOpenException do
34 | breaker.run do
35 | 2
36 | end
37 | end
38 |
39 | expect_raises CircuitOpenException do
40 | breaker.run do
41 | raise MyError.new
42 | end
43 | end
44 | end
45 | end
46 |
47 | describe "feature test" do
48 | it "reenables after a given timeframe" do
49 | breaker = CircuitBreaker.new(threshold: 20, timewindow: 60, reenable_after: 2)
50 |
51 | 10.times do
52 | breaker.run do
53 | "swag"
54 | end.should eq "swag"
55 | end
56 |
57 | 3.times do
58 | expect_raises ArgumentError do
59 | breaker.run do
60 | raise ArgumentError.new
61 | end
62 | end
63 | end
64 |
65 | 7.times do
66 | expect_raises CircuitOpenException do
67 | breaker.run do
68 | "swag"
69 | end
70 | end
71 | end
72 |
73 | sleep 2
74 |
75 | breaker.run do
76 | "swag"
77 | end.should eq "swag"
78 | end
79 |
80 | it "goes directly back to open if the first execution after reopening fails" do
81 | breaker = CircuitBreaker.new(threshold: 20, timewindow: 60, reenable_after: 2)
82 |
83 | 10.times do
84 | breaker.run do
85 | "swag"
86 | end.should eq "swag"
87 | end
88 |
89 | 3.times do
90 | expect_raises ArgumentError do
91 | breaker.run do
92 | raise ArgumentError.new
93 | end
94 | end
95 | end
96 |
97 | 7.times do
98 | expect_raises CircuitOpenException do
99 | breaker.run do
100 | "swag"
101 | end
102 | end
103 | end
104 |
105 | sleep 2.1
106 |
107 | expect_raises ArgumentError do
108 | breaker.run do
109 | raise ArgumentError.new
110 | end
111 | end
112 |
113 | expect_raises CircuitOpenException do
114 | breaker.run do
115 | "swag"
116 | end
117 | end
118 | end
119 |
120 | it "errors and executions only count in a given timeframe" do
121 | breaker = CircuitBreaker.new(threshold: 20, timewindow: 2, reenable_after: 60)
122 |
123 | 10.times do
124 | breaker.run do
125 | "swag"
126 | end.should eq "swag"
127 | end
128 | 2.times do
129 | expect_raises ArgumentError do
130 | breaker.run do
131 | raise ArgumentError.new
132 | end
133 | end
134 | end
135 |
136 | sleep 3
137 |
138 | 4.times do
139 | breaker.run do
140 | "swag"
141 | end.should eq "swag"
142 | end
143 |
144 | expect_raises ArgumentError do
145 | breaker.run do
146 | raise ArgumentError.new
147 | end
148 | end
149 | end
150 |
151 | it "if the breaker was given an array of Exception types, only those will be monitored" do
152 | breaker = CircuitBreaker.new(threshold: 20, timewindow: 2, reenable_after: 60, handled_errors: [MyError.new])
153 |
154 | 10.times do
155 | expect_raises ArgumentError do
156 | breaker.run do
157 | raise ArgumentError.new
158 | end
159 | end
160 | end
161 |
162 | 3.times do
163 | expect_raises MyError do
164 | breaker.run do
165 | raise MyError.new
166 | end
167 | end
168 | end
169 |
170 | expect_raises CircuitOpenException do
171 | breaker.run do
172 | raise MyError.new
173 | end
174 | end
175 | end
176 |
177 | it "if the breaker was given an array of Exception types to ignore, those will not be monitored" do
178 | breaker = CircuitBreaker.new(threshold: 20, timewindow: 2, reenable_after: 60, ignored_errors: [ArgumentError.new])
179 |
180 | 10.times do
181 | expect_raises ArgumentError do
182 | breaker.run do
183 | raise ArgumentError.new
184 | end
185 | end
186 | end
187 |
188 | 3.times do
189 | expect_raises MyError do
190 | breaker.run do
191 | raise MyError.new
192 | end
193 | end
194 | end
195 |
196 | expect_raises CircuitOpenException do
197 | breaker.run do
198 | raise MyError.new
199 | end
200 | end
201 | end
202 | end
203 | end
204 |
205 | class MyError < Exception
206 | end
207 |
--------------------------------------------------------------------------------
/spec/circuit_state_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../src/circuit_state.cr"
3 |
4 | describe "CircuitState" do
5 | describe "#trip" do
6 | it "transitions from :closed to :open" do
7 | cs = CircuitState.new
8 | cs.state.should eq :closed
9 |
10 | cs.trip
11 | cs.state.should eq :open
12 | end
13 | end
14 |
15 | describe "#attempt_reset" do
16 | it "transitions from :open to :half_open" do
17 | cs = CircuitState.new
18 | cs.trip
19 | cs.attempt_reset
20 | cs.state.should eq :half_open
21 | end
22 | end
23 |
24 | describe "#reset" do
25 | it "transitions from :half_open to :closed" do
26 | cs = CircuitState.new
27 | cs.state.should eq :closed
28 |
29 | cs.trip
30 | cs.attempt_reset
31 | cs.reset
32 | cs.state.should eq :closed
33 | end
34 |
35 | it "does not transition from :closed" do
36 | cs = CircuitState.new
37 | cs.state.should eq :closed
38 |
39 | expect_raises IllegalStateTransition do
40 | cs.reset
41 | end
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/spec/error_watcher_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../src/error_watcher.cr"
3 |
4 | describe "ErrorWatcher" do
5 | describe "#add_failure" do
6 | it "adds a failure timestamp to @failures array" do
7 | end
8 | end
9 |
10 | describe "#error_rate" do
11 | it "calculates error rate correctly and cleans after time" do
12 | watcher = ErrorWatcher.new(Time::Span.new(hours: 0, minutes: 0, seconds: 1))
13 | watcher.add_failure
14 | watcher.add_execution
15 | watcher.error_rate.should eq 100
16 | sleep 2
17 | watcher.error_rate.should eq 0
18 | end
19 |
20 | it "throws an error if there are more failures than executions" do
21 | watcher = ErrorWatcher.new(Time::Span.new(hours: 0, minutes: 0, seconds: 60))
22 | watcher.add_failure
23 | expect_raises MoreErrorsThanExecutionsException do
24 | watcher.error_rate
25 | end
26 | end
27 |
28 | it "returns 0 if failures and executions are empty" do
29 | watcher = ErrorWatcher.new(Time::Span.new(hours: 0, minutes: 0, seconds: 60))
30 | watcher.error_rate.should eq 0
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/src/circuit_breaker.cr:
--------------------------------------------------------------------------------
1 | require "./circuit_state"
2 | require "./error_watcher"
3 |
4 | # Simple Implementation of the circuit breaker pattern in Crystal.
5 | #
6 | # Given a certain error threshold, timeframe and timeout window, a breaker can be used to monitor criticial command executions. Circuit breakers are usually used to prevent unnecessary requests if a server ressource et al becomes unavailable. This protects the server from additional load and allows it to recover and relieves the client from requests that are doomed to fail.
7 | #
8 | # Wrap API calls inside a breaker, if the error rate in a given time frame surpasses a certain threshold, all subsequent calls will fail for a given duration.
9 | class CircuitBreaker
10 | @error_threshold : Int32
11 | @duration : Int32
12 | @reclose_time : Time
13 |
14 | # creates a CircuitBreaker instance with a specified error threshold, timeframe, breaker duration and optionally a number of ignored or handled errors
15 | #
16 | # ```
17 | # breaker = CircuitBreaker.new(
18 | # threshold: 5, # % of errors before you want to trip the circuit
19 | # timewindow: 60, # in s: anything older will be ignored in error_rate
20 | # reenable_after: 300 # after x seconds, the breaker will allow executions again
21 | # )
22 | # ```
23 | def initialize(threshold @error_threshold, timewindow timeframe, reenable_after @duration, handled_errors = [] of Exception, ignored_errors = [] of Exception)
24 | @state = CircuitState.new
25 | @reclose_time = Time.local
26 | @error_watcher = ErrorWatcher.new(Time::Span.new(hours: 0, minutes: 0, seconds: timeframe))
27 |
28 | # two-step initialization because of known crystal compiler bug
29 | @handled_errors = [] of Exception
30 | @handled_errors += handled_errors
31 | @ignored_errors = [] of Exception
32 | @ignored_errors += ignored_errors
33 | end
34 |
35 | # get's passed a block to watch for errors
36 | # every error thrown inside your block counts towards the error rate
37 | # once the threshold is surpassed, it starts throwing `CircuitOpenException`s
38 | # you can catch these rrors and implement some fallback behaviour
39 | # ```
40 | # begin
41 | # breaker.run do
42 | # my_rest_call()
43 | # end
44 | # rescue exc : CircuitOpenException
45 | # log "happens to the best of us..."
46 | # 42
47 | # end
48 | # ```
49 | def run(&block)
50 | # if open and not reclosable -> fail
51 | if open?
52 | raise CircuitOpenException.new("Circuit Breaker Open")
53 | end
54 |
55 | begin
56 | @error_watcher.add_execution
57 | return_value = yield
58 | handle_execution_success
59 | rescue exc
60 | if monitor? exc
61 | handle_execution_error
62 | end
63 | raise exc
64 | end
65 |
66 | return return_value
67 | end
68 |
69 | # ---------------------------
70 | # private methods
71 | # ---------------------------
72 | private def monitor?(exception : Exception)
73 | exception_type = exception.class
74 | errors = @handled_errors.map(&.class)
75 | ignored = @ignored_errors.map(&.class)
76 | (errors.includes?(exception_type) || errors.empty?) && !ignored.includes?(exception_type)
77 | end
78 |
79 | private def handle_execution_error
80 | @error_watcher.add_failure
81 | if error_rate >= @error_threshold || @state.state == :half_open
82 | open_circuit
83 | end
84 | end
85 |
86 | private def handle_execution_success
87 | if @state.state == :half_open
88 | reset
89 | end
90 | end
91 |
92 | private def open?
93 | @state.state == :open && !reclose? && !openable?
94 | end
95 |
96 | private def openable?
97 | if error_rate >= @error_threshold && @state.state != :open
98 | open_circuit
99 | true
100 | else
101 | false
102 | end
103 | end
104 |
105 | private def trip
106 | @state.trip
107 |
108 | @reclose_time = Time.local + Time::Span.new(hours: 0, minutes: 0, seconds: @duration)
109 | end
110 |
111 | private def reset
112 | @state.reset
113 |
114 | @reclose_time = Time.local
115 | @error_watcher.reset
116 | end
117 |
118 | private def error_rate
119 | @error_watcher.error_rate
120 | end
121 |
122 | private def reclose?
123 | if Time.local > @reclose_time
124 | @state.attempt_reset
125 | true
126 | else
127 | false
128 | end
129 | end
130 |
131 | private def open_circuit
132 | @state.trip
133 | @reclose_time = Time.local + Time::Span.new(hours: 0, minutes: 0, seconds: @duration)
134 | end
135 | end
136 |
137 | class CircuitOpenException < Exception
138 | end
139 |
--------------------------------------------------------------------------------
/src/circuit_state.cr:
--------------------------------------------------------------------------------
1 | class CircuitState
2 | getter :state
3 |
4 | OPEN = :open
5 | CLOSED = :closed
6 | HALF_OPEN = :half_open
7 |
8 | ALLOWED_TRANSITIONS = {
9 | CLOSED => [OPEN],
10 | OPEN => [HALF_OPEN],
11 | HALF_OPEN => [OPEN, CLOSED]
12 | }
13 |
14 | def initialize
15 | @state = CLOSED
16 | end
17 |
18 | def trip
19 | transition_to OPEN
20 | end
21 |
22 | def attempt_reset
23 | transition_to HALF_OPEN
24 | end
25 |
26 | def reset
27 | transition_to CLOSED
28 | end
29 |
30 | private def transition_to(new_state)
31 | unless ALLOWED_TRANSITIONS[@state].includes? new_state
32 | raise IllegalStateTransition.new("From #{@state} to #{new_state}")
33 | end
34 | @state = new_state
35 | end
36 | end
37 |
38 | class IllegalStateTransition < Exception
39 | end
40 |
--------------------------------------------------------------------------------
/src/error_watcher.cr:
--------------------------------------------------------------------------------
1 | class ErrorWatcher
2 | @failures = [] of Time
3 | @executions = [] of Time
4 | @timeframe : Time::Span
5 |
6 | def initialize(@timeframe)
7 | end
8 |
9 | def add_failure
10 | @failures << Time.local
11 | end
12 |
13 | def add_execution
14 | @executions << Time.local
15 | end
16 |
17 | def reset
18 | @failures = [] of Time
19 | @executions = [] of Time
20 | end
21 |
22 | def error_rate : Float64
23 | clean_old_records
24 |
25 | raise MoreErrorsThanExecutionsException.new if failure_count > execution_count
26 | return 0.to_f if @executions.size == 0
27 |
28 | failure_count / execution_count.to_f * 100
29 | end
30 |
31 | # ---------------------------
32 | # private methods
33 | # ---------------------------
34 | private def clean_old_records
35 | clean_old @failures
36 | clean_old @executions
37 | end
38 |
39 | private def clean_old(arr : Array(Time))
40 | threshold = Time.local - @timeframe
41 |
42 | arr.reject! { |time| time < threshold }
43 | end
44 |
45 | private def failure_count
46 | @failures.size
47 | end
48 |
49 | private def execution_count
50 | @executions.size
51 | end
52 |
53 | end
54 |
55 | class MoreErrorsThanExecutionsException < Exception
56 | end
57 |
--------------------------------------------------------------------------------