semian 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +2 -2
- data/.rubocop.yml +113 -0
- data/CHANGELOG.md +8 -0
- data/Gemfile +5 -0
- data/LICENSE.md +1 -1
- data/README.md +488 -39
- data/Rakefile +15 -8
- data/ext/semian/extconf.rb +2 -2
- data/lib/semian.rb +16 -6
- data/lib/semian/adapter.rb +1 -1
- data/lib/semian/circuit_breaker.rb +38 -37
- data/lib/semian/mysql2.rb +21 -1
- data/lib/semian/net_http.rb +95 -0
- data/lib/semian/protected_resource.rb +7 -2
- data/lib/semian/resource.rb +1 -1
- data/lib/semian/simple_integer.rb +23 -0
- data/lib/semian/simple_sliding_window.rb +43 -0
- data/lib/semian/simple_state.rb +43 -0
- data/lib/semian/unprotected_resource.rb +4 -1
- data/lib/semian/version.rb +1 -1
- data/repodb.yml +1 -0
- data/scripts/install_toxiproxy.sh +3 -3
- data/semian.gemspec +4 -3
- data/test/circuit_breaker_test.rb +6 -2
- data/test/helpers/background_helper.rb +1 -1
- data/test/instrumentation_test.rb +1 -1
- data/test/mysql2_test.rb +57 -1
- data/test/net_http_test.rb +481 -0
- data/test/redis_test.rb +3 -3
- data/test/resource_test.rb +33 -31
- data/test/semian_test.rb +3 -2
- data/test/simple_integer_test.rb +49 -0
- data/test/simple_sliding_window_test.rb +65 -0
- data/test/simple_state_test.rb +45 -0
- data/test/test_helper.rb +5 -0
- data/test/unprotected_resource_test.rb +1 -1
- metadata +30 -27
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -1
- metadata.gz.sig +0 -0
@@ -0,0 +1,23 @@
|
|
1
|
+
module Semian
|
2
|
+
module Simple
|
3
|
+
class Integer #:nodoc:
|
4
|
+
attr_accessor :value
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
reset
|
8
|
+
end
|
9
|
+
|
10
|
+
def increment(val = 1)
|
11
|
+
@value += val
|
12
|
+
end
|
13
|
+
|
14
|
+
def reset
|
15
|
+
@value = 0
|
16
|
+
end
|
17
|
+
|
18
|
+
def destroy
|
19
|
+
reset
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Semian
|
2
|
+
module Simple
|
3
|
+
class SlidingWindow #:nodoc:
|
4
|
+
extend Forwardable
|
5
|
+
|
6
|
+
def_delegators :@window, :size, :pop, :shift, :first, :last
|
7
|
+
attr_reader :max_size
|
8
|
+
|
9
|
+
# A sliding window is a structure that stores the most @max_size recent timestamps
|
10
|
+
# like this: if @max_size = 4, current time is 10, @window =[5,7,9,10].
|
11
|
+
# Another push of (11) at 11 sec would make @window [7,9,10,11], shifting off 5.
|
12
|
+
|
13
|
+
def initialize(max_size:)
|
14
|
+
@max_size = max_size
|
15
|
+
@window = []
|
16
|
+
end
|
17
|
+
|
18
|
+
def resize_to(size)
|
19
|
+
raise ArgumentError.new('size must be larger than 0') if size < 1
|
20
|
+
@max_size = size
|
21
|
+
@window.shift while @window.size > @max_size
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
def push(value)
|
26
|
+
@window.shift while @window.size >= @max_size
|
27
|
+
@window << value
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
alias_method :<<, :push
|
32
|
+
|
33
|
+
def clear
|
34
|
+
@window.clear
|
35
|
+
self
|
36
|
+
end
|
37
|
+
|
38
|
+
def destroy
|
39
|
+
clear
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Semian
|
2
|
+
module Simple
|
3
|
+
class State #:nodoc:
|
4
|
+
def initialize
|
5
|
+
reset
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :value
|
9
|
+
|
10
|
+
def open?
|
11
|
+
value == :open
|
12
|
+
end
|
13
|
+
|
14
|
+
def closed?
|
15
|
+
value == :closed
|
16
|
+
end
|
17
|
+
|
18
|
+
def half_open?
|
19
|
+
value == :half_open
|
20
|
+
end
|
21
|
+
|
22
|
+
def open
|
23
|
+
@value = :open
|
24
|
+
end
|
25
|
+
|
26
|
+
def close
|
27
|
+
@value = :closed
|
28
|
+
end
|
29
|
+
|
30
|
+
def half_open
|
31
|
+
@value = :half_open
|
32
|
+
end
|
33
|
+
|
34
|
+
def reset
|
35
|
+
close
|
36
|
+
end
|
37
|
+
|
38
|
+
def destroy
|
39
|
+
reset
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/semian/version.rb
CHANGED
data/repodb.yml
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
classification: library
|
@@ -6,9 +6,9 @@ if which toxiproxy > /dev/null; then
|
|
6
6
|
fi
|
7
7
|
|
8
8
|
if which apt-get > /dev/null; then
|
9
|
-
echo "Installing toxiproxy
|
10
|
-
wget -O /tmp/toxiproxy
|
11
|
-
sudo dpkg -i /tmp/toxiproxy
|
9
|
+
echo "Installing toxiproxy"
|
10
|
+
wget -O /tmp/toxiproxy.deb https://github.com/Shopify/toxiproxy/releases/download/v2.0.0rc1/toxiproxy_2.0.0rc1_amd64.deb
|
11
|
+
sudo dpkg -i /tmp/toxiproxy.deb
|
12
12
|
sudo service toxiproxy start
|
13
13
|
exit 0
|
14
14
|
fi
|
data/semian.gemspec
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
|
1
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
|
2
2
|
|
3
3
|
require 'semian/version'
|
4
4
|
require 'semian/platform'
|
@@ -11,9 +11,9 @@ Gem::Specification.new do |s|
|
|
11
11
|
A Ruby C extention that is used to control access to shared resources
|
12
12
|
across process boundaries with SysV semaphores.
|
13
13
|
DOC
|
14
|
-
s.homepage = 'https://github.com/
|
14
|
+
s.homepage = 'https://github.com/shopify/semian'
|
15
15
|
s.authors = ['Scott Francis', 'Simon Eskildsen']
|
16
|
-
s.email
|
16
|
+
s.email = 'scott.francis@shopify.com'
|
17
17
|
s.license = 'MIT'
|
18
18
|
|
19
19
|
s.files = `git ls-files`.split("\n")
|
@@ -22,5 +22,6 @@ Gem::Specification.new do |s|
|
|
22
22
|
s.add_development_dependency 'timecop'
|
23
23
|
s.add_development_dependency 'mysql2'
|
24
24
|
s.add_development_dependency 'redis'
|
25
|
+
s.add_development_dependency 'thin'
|
25
26
|
s.add_development_dependency 'toxiproxy'
|
26
27
|
end
|
@@ -4,7 +4,11 @@ class TestCircuitBreaker < MiniTest::Unit::TestCase
|
|
4
4
|
SomeError = Class.new(StandardError)
|
5
5
|
|
6
6
|
def setup
|
7
|
-
|
7
|
+
begin
|
8
|
+
Semian.destroy(:testing)
|
9
|
+
rescue
|
10
|
+
nil
|
11
|
+
end
|
8
12
|
Semian.register(:testing, tickets: 1, exceptions: [SomeError], error_threshold: 2, error_timeout: 5, success_threshold: 1)
|
9
13
|
@resource = Semian[:testing]
|
10
14
|
end
|
@@ -108,7 +112,7 @@ class TestCircuitBreaker < MiniTest::Unit::TestCase
|
|
108
112
|
def assert_circuit_opened(resource = @resource)
|
109
113
|
open = false
|
110
114
|
begin
|
111
|
-
resource.acquire {
|
115
|
+
resource.acquire {}
|
112
116
|
rescue Semian::OpenCircuitError
|
113
117
|
open = true
|
114
118
|
end
|
data/test/mysql2_test.rb
CHANGED
@@ -70,7 +70,7 @@ class TestMysql2 < MiniTest::Unit::TestCase
|
|
70
70
|
end
|
71
71
|
|
72
72
|
def test_resource_acquisition_for_connect
|
73
|
-
|
73
|
+
connect_to_mysql!
|
74
74
|
|
75
75
|
Semian[:mysql_testing].acquire do
|
76
76
|
error = assert_raises Mysql2::ResourceBusyError do
|
@@ -160,6 +160,61 @@ class TestMysql2 < MiniTest::Unit::TestCase
|
|
160
160
|
end
|
161
161
|
end
|
162
162
|
|
163
|
+
def test_semian_allows_rollback
|
164
|
+
client = connect_to_mysql!
|
165
|
+
|
166
|
+
client.query('START TRANSACTION;')
|
167
|
+
|
168
|
+
Semian[:mysql_testing].acquire do
|
169
|
+
client.query('ROLLBACK;')
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def test_semian_allows_commit
|
174
|
+
client = connect_to_mysql!
|
175
|
+
|
176
|
+
client.query('START TRANSACTION;')
|
177
|
+
|
178
|
+
Semian[:mysql_testing].acquire do
|
179
|
+
client.query('COMMIT;')
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def test_query_whitelisted_returns_false_for_binary_sql
|
184
|
+
client = connect_to_mysql!
|
185
|
+
|
186
|
+
q = "INSERT IGNORE INTO `theme_template_bodies` (`cityhash`, `body`, `created_at`) VALUES ('716374049952273167', \
|
187
|
+
'\xB1\x01\xD0{\\\"current\\\":{\\\"bg_color\\\":\\\"#ff0000\\\"},\\\"presets\\\":{\\\"sandbox>,\\0\\07\x05\x01\x01, \
|
188
|
+
grey_bg\\\":6M\\0\\06\x05\x01\x01!\fblueJ!\\0\x01l\x04ff\x01!\bredJ \\0$ff0000\\\"}}}', '2015-11-06 19:08:03.498432')"
|
189
|
+
refute client.send(:query_whitelisted?, q)
|
190
|
+
end
|
191
|
+
|
192
|
+
def test_semian_allows_rollback_to_safepoint
|
193
|
+
client = connect_to_mysql!
|
194
|
+
|
195
|
+
client.query('START TRANSACTION;')
|
196
|
+
client.query('SAVEPOINT foobar;')
|
197
|
+
|
198
|
+
Semian[:mysql_testing].acquire do
|
199
|
+
client.query('ROLLBACK TO foobar;')
|
200
|
+
end
|
201
|
+
|
202
|
+
client.query('ROLLBACK;')
|
203
|
+
end
|
204
|
+
|
205
|
+
def test_semian_allows_release_savepoint
|
206
|
+
client = connect_to_mysql!
|
207
|
+
|
208
|
+
client.query('START TRANSACTION;')
|
209
|
+
client.query('SAVEPOINT foobar;')
|
210
|
+
|
211
|
+
Semian[:mysql_testing].acquire do
|
212
|
+
client.query('RELEASE SAVEPOINT foobar;')
|
213
|
+
end
|
214
|
+
|
215
|
+
client.query('ROLLBACK;')
|
216
|
+
end
|
217
|
+
|
163
218
|
def test_resource_timeout_on_query
|
164
219
|
client = connect_to_mysql!
|
165
220
|
client2 = connect_to_mysql!
|
@@ -216,6 +271,7 @@ class TestMysql2 < MiniTest::Unit::TestCase
|
|
216
271
|
|
217
272
|
class FakeMysql < Mysql2::Client
|
218
273
|
private
|
274
|
+
|
219
275
|
def connect(*)
|
220
276
|
end
|
221
277
|
end
|
@@ -0,0 +1,481 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'semian/net_http'
|
3
|
+
require 'thin'
|
4
|
+
|
5
|
+
class TestNetHTTP < MiniTest::Unit::TestCase
|
6
|
+
class RackServer
|
7
|
+
def self.call(env)
|
8
|
+
response_code = env['REQUEST_URI'].delete("/")
|
9
|
+
response_code = '200' if response_code == ""
|
10
|
+
[response_code, {'Content-Type' => 'text/html'}, ['Success']]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
HOSTNAME = "localhost"
|
15
|
+
PORT = 31_050
|
16
|
+
TOXIC_PORT = PORT + 1
|
17
|
+
DEFAULT_SEMIAN_OPTIONS = {
|
18
|
+
tickets: 3,
|
19
|
+
success_threshold: 1,
|
20
|
+
error_threshold: 3,
|
21
|
+
error_timeout: 10,
|
22
|
+
}.freeze
|
23
|
+
DEFAULT_SEMIAN_CONFIGURATION = proc do |host, port|
|
24
|
+
DEFAULT_SEMIAN_OPTIONS.merge(name: "#{host}_#{port}")
|
25
|
+
end
|
26
|
+
|
27
|
+
def test_with_server_raises_if_binding_fails
|
28
|
+
# Occurs when trying to bind to invalid addresses, like non-private
|
29
|
+
# addresses, or when the address is already bound to something else
|
30
|
+
with_server do
|
31
|
+
assert_raises RuntimeError do
|
32
|
+
with_server {}
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def test_semian_identifier
|
38
|
+
with_server do
|
39
|
+
with_semian_configuration do
|
40
|
+
Net::HTTP.start(HOSTNAME, TOXIC_PORT) do |http|
|
41
|
+
assert_equal "nethttp_#{HOSTNAME}_#{TOXIC_PORT}", http.semian_identifier
|
42
|
+
end
|
43
|
+
Net::HTTP.start("127.0.0.1", TOXIC_PORT) do |http|
|
44
|
+
assert_equal "nethttp_127.0.0.1_#{TOXIC_PORT}", http.semian_identifier
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def test_trigger_open
|
51
|
+
with_semian_configuration do
|
52
|
+
with_server do
|
53
|
+
open_circuit!
|
54
|
+
uri = URI("http://#{HOSTNAME}:#{TOXIC_PORT}/200")
|
55
|
+
assert_raises Net::CircuitOpenError do
|
56
|
+
Net::HTTP.get(uri)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def test_trigger_close_after_open
|
63
|
+
with_semian_configuration do
|
64
|
+
with_server do
|
65
|
+
open_circuit!
|
66
|
+
close_circuit!
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_bulkheads_tickets_are_working
|
72
|
+
options = proc do |host, port|
|
73
|
+
{
|
74
|
+
tickets: 2,
|
75
|
+
success_threshold: 1,
|
76
|
+
error_threshold: 3,
|
77
|
+
error_timeout: 10,
|
78
|
+
name: "#{host}_#{port}",
|
79
|
+
}
|
80
|
+
end
|
81
|
+
with_semian_configuration(options) do
|
82
|
+
with_server do
|
83
|
+
http_1 = Net::HTTP.new(HOSTNAME, TOXIC_PORT)
|
84
|
+
http_1.semian_resource.acquire do
|
85
|
+
http_2 = Net::HTTP.new(HOSTNAME, TOXIC_PORT)
|
86
|
+
http_2.semian_resource.acquire do
|
87
|
+
assert_raises Net::ResourceBusyError do
|
88
|
+
Net::HTTP.get(URI("http://#{HOSTNAME}:#{TOXIC_PORT}/"))
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def test_get_is_protected
|
97
|
+
with_semian_configuration do
|
98
|
+
with_server do
|
99
|
+
open_circuit!
|
100
|
+
assert_raises Net::CircuitOpenError do
|
101
|
+
Net::HTTP.get(URI("http://#{HOSTNAME}:#{TOXIC_PORT}/200"))
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def test_instance_get_is_protected
|
108
|
+
with_semian_configuration do
|
109
|
+
with_server do
|
110
|
+
open_circuit!
|
111
|
+
assert_raises Net::CircuitOpenError do
|
112
|
+
http = Net::HTTP.new(HOSTNAME, TOXIC_PORT)
|
113
|
+
http.get("/")
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def test_get_response_is_protected
|
120
|
+
with_semian_configuration do
|
121
|
+
with_server do
|
122
|
+
open_circuit!
|
123
|
+
assert_raises Net::CircuitOpenError do
|
124
|
+
uri = URI("http://#{HOSTNAME}:#{TOXIC_PORT}/200")
|
125
|
+
Net::HTTP.get_response(uri)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def test_post_form_is_protected
|
132
|
+
with_semian_configuration do
|
133
|
+
with_server do
|
134
|
+
open_circuit!
|
135
|
+
assert_raises Net::CircuitOpenError do
|
136
|
+
uri = URI("http://#{HOSTNAME}:#{TOXIC_PORT}/200")
|
137
|
+
Net::HTTP.post_form(uri, 'q' => 'ruby', 'max' => '50')
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def test_http_start_method_is_protected
|
144
|
+
with_semian_configuration do
|
145
|
+
with_server do
|
146
|
+
open_circuit!
|
147
|
+
uri = URI("http://#{HOSTNAME}:#{TOXIC_PORT}/200")
|
148
|
+
assert_raises Net::CircuitOpenError do
|
149
|
+
Net::HTTP.start(uri.host, uri.port) {}
|
150
|
+
end
|
151
|
+
close_circuit!
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def test_http_action_request_inside_start_methods_are_protected
|
157
|
+
with_semian_configuration do
|
158
|
+
with_server do
|
159
|
+
uri = URI("http://#{HOSTNAME}:#{TOXIC_PORT}/200")
|
160
|
+
Net::HTTP.start(uri.host, uri.port) do |http|
|
161
|
+
open_circuit!
|
162
|
+
get_subclasses(Net::HTTPRequest).each do |action|
|
163
|
+
assert_raises(Net::CircuitOpenError, "#{action.name} did not raise a Net::CircuitOpenError") do
|
164
|
+
request = action.new uri
|
165
|
+
http.request(request)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def test_custom_raw_semian_options_work_with_lookup
|
174
|
+
with_server do
|
175
|
+
semian_config = {}
|
176
|
+
semian_config["development"] = {}
|
177
|
+
semian_config["development"]["nethttp_#{HOSTNAME}_#{TOXIC_PORT}"] = DEFAULT_SEMIAN_OPTIONS
|
178
|
+
sample_env = "development"
|
179
|
+
|
180
|
+
semian_configuration_proc = proc do |host, port|
|
181
|
+
semian_identifier = "nethttp_#{host}_#{port}"
|
182
|
+
semian_config[sample_env][semian_identifier].merge(name: "#{host}_#{port}")
|
183
|
+
end
|
184
|
+
|
185
|
+
with_semian_configuration(semian_configuration_proc) do
|
186
|
+
Net::HTTP.start(HOSTNAME, TOXIC_PORT) do |http|
|
187
|
+
assert_equal semian_config["development"][http.semian_identifier],
|
188
|
+
http.raw_semian_options.dup.tap { |o| o.delete(:name) }
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def test_custom_raw_semian_options_work_with_default_fallback
|
195
|
+
with_server do
|
196
|
+
semian_config = {}
|
197
|
+
semian_config["development"] = {}
|
198
|
+
semian_config["development"]["nethttp_default"] = DEFAULT_SEMIAN_OPTIONS
|
199
|
+
sample_env = "development"
|
200
|
+
|
201
|
+
semian_configuration_proc = proc do |host, port|
|
202
|
+
semian_identifier = "nethttp_#{host}_#{port}"
|
203
|
+
semian_identifier = "nethttp_default" unless semian_config[sample_env].key?(semian_identifier)
|
204
|
+
semian_config[sample_env][semian_identifier].merge(name: "default")
|
205
|
+
end
|
206
|
+
Semian["nethttp_default"].reset if Semian["nethttp_default"]
|
207
|
+
Semian.destroy("nethttp_default")
|
208
|
+
with_semian_configuration(semian_configuration_proc) do
|
209
|
+
Net::HTTP.start(HOSTNAME, PORT) do |http|
|
210
|
+
expected_config = semian_config["development"]["nethttp_default"].dup
|
211
|
+
assert_equal expected_config, http.raw_semian_options.dup.tap { |o| o.delete(:name) }
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def test_custom_raw_semian_options_can_disable_using_nil
|
218
|
+
with_server do
|
219
|
+
semian_configuration_proc = proc { nil }
|
220
|
+
with_semian_configuration(semian_configuration_proc) do
|
221
|
+
http = Net::HTTP.new(HOSTNAME, TOXIC_PORT)
|
222
|
+
assert_equal true, http.disabled?
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def test_use_custom_configuration_to_combine_endpoints_into_one_resource
|
228
|
+
semian_config = {}
|
229
|
+
semian_config["development"] = {}
|
230
|
+
semian_config["development"]["nethttp_default"] = DEFAULT_SEMIAN_OPTIONS
|
231
|
+
sample_env = "development"
|
232
|
+
|
233
|
+
semian_configuration_proc = proc do
|
234
|
+
semian_identifier = "nethttp_default"
|
235
|
+
semian_config[sample_env][semian_identifier].merge(name: "default")
|
236
|
+
end
|
237
|
+
|
238
|
+
with_semian_configuration(semian_configuration_proc) do
|
239
|
+
Semian["nethttp_default"].reset if Semian["nethttp_default"]
|
240
|
+
Semian.destroy("nethttp_default")
|
241
|
+
with_server do
|
242
|
+
open_circuit!
|
243
|
+
end
|
244
|
+
with_server(addresses: ["#{HOSTNAME}:#{PORT}", "#{HOSTNAME}:#{PORT + 100}"], reset_semian_state: false) do
|
245
|
+
assert_raises Net::CircuitOpenError do
|
246
|
+
Net::HTTP.get(URI("http://#{HOSTNAME}:#{TOXIC_PORT}/200"))
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
def test_custom_raw_semian_options_can_disable_with_invalid_key
|
253
|
+
with_server do
|
254
|
+
semian_config = {}
|
255
|
+
semian_config["development"] = {}
|
256
|
+
semian_config["development"]["nethttp_#{HOSTNAME}_#{TOXIC_PORT}"] = DEFAULT_SEMIAN_OPTIONS
|
257
|
+
sample_env = "development"
|
258
|
+
|
259
|
+
semian_configuration_proc = proc do |host, port|
|
260
|
+
semian_identifier = "nethttp_#{host}_#{port}"
|
261
|
+
semian_config[sample_env][semian_identifier]
|
262
|
+
end
|
263
|
+
with_semian_configuration(semian_configuration_proc) do
|
264
|
+
http = Net::HTTP.new(HOSTNAME, TOXIC_PORT)
|
265
|
+
assert_equal false, http.disabled?
|
266
|
+
|
267
|
+
http = Net::HTTP.new(HOSTNAME, TOXIC_PORT + 100)
|
268
|
+
assert_equal true, http.disabled?
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
def test_adding_extra_errors_and_resetting_affects_exceptions_list
|
274
|
+
orig_errors = Semian::NetHTTP.exceptions.dup
|
275
|
+
Semian::NetHTTP.exceptions += [::OpenSSL::SSL::SSLError]
|
276
|
+
assert_equal(orig_errors + [::OpenSSL::SSL::SSLError], Semian::NetHTTP.exceptions)
|
277
|
+
Semian::NetHTTP.reset_exceptions
|
278
|
+
assert_equal(Semian::NetHTTP::DEFAULT_ERRORS, Semian::NetHTTP.exceptions)
|
279
|
+
ensure
|
280
|
+
Semian::NetHTTP.exceptions = orig_errors
|
281
|
+
end
|
282
|
+
|
283
|
+
def test_adding_custom_errors_do_trip_circuit
|
284
|
+
with_semian_configuration do
|
285
|
+
with_custom_errors([::OpenSSL::SSL::SSLError]) do
|
286
|
+
with_server do
|
287
|
+
http = Net::HTTP.new(HOSTNAME, TOXIC_PORT)
|
288
|
+
http.use_ssl = true
|
289
|
+
http.raw_semian_options[:error_threshold].times do
|
290
|
+
assert_raises ::OpenSSL::SSL::SSLError do
|
291
|
+
http.get("/200")
|
292
|
+
end
|
293
|
+
end
|
294
|
+
assert_raises Net::CircuitOpenError do
|
295
|
+
http.get("/200")
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
def test_multiple_different_endpoints_and_ports_are_tracked_differently
|
303
|
+
with_semian_configuration do
|
304
|
+
addresses = ["#{HOSTNAME}:#{PORT}", "#{HOSTNAME}:#{PORT + 100}"]
|
305
|
+
addresses.each do |address|
|
306
|
+
hostname, port = address.split(":")
|
307
|
+
port = port.to_i
|
308
|
+
reset_semian_resource(hostname: hostname, port: port)
|
309
|
+
end
|
310
|
+
with_server(addresses: addresses, reset_semian_state: false) do |hostname, port|
|
311
|
+
with_toxic(hostname: hostname, upstream_port: port, toxic_port: port + 1) do |name|
|
312
|
+
Net::HTTP.get(URI("http://#{hostname}:#{port + 1}/"))
|
313
|
+
open_circuit!(hostname: hostname, toxic_port: port + 1, toxic_name: name)
|
314
|
+
assert_raises Net::CircuitOpenError do
|
315
|
+
Net::HTTP.get(URI("http://#{hostname}:#{port + 1}/"))
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
with_server(addresses: ["127.0.0.1:#{PORT}"], reset_semian_state: false) do
|
320
|
+
# different endpoint, should not raise errors even though localhost == 127.0.0.1
|
321
|
+
Net::HTTP.get(URI("http://127.0.0.1:#{PORT + 1}/"))
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
def test_persistent_state_after_server_restart
|
327
|
+
with_semian_configuration do
|
328
|
+
with_server(addresses: ["#{HOSTNAME}:#{PORT + 100}"]) do |hostname, port|
|
329
|
+
with_toxic(hostname: hostname, upstream_port: port, toxic_port: port + 1) do |name|
|
330
|
+
open_circuit!(hostname: hostname, toxic_port: port + 1, toxic_name: name)
|
331
|
+
end
|
332
|
+
end
|
333
|
+
with_server(addresses: ["#{HOSTNAME}:#{PORT + 100}"], reset_semian_state: false) do |hostname, port|
|
334
|
+
with_toxic(hostname: hostname, upstream_port: port, toxic_port: port + 1) do |_|
|
335
|
+
assert_raises Net::CircuitOpenError do
|
336
|
+
Net::HTTP.get(URI("http://localhost:#{port + 1}/200"))
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|
341
|
+
end
|
342
|
+
|
343
|
+
private
|
344
|
+
|
345
|
+
def with_semian_configuration(options = DEFAULT_SEMIAN_CONFIGURATION)
|
346
|
+
orig_semian_options = Semian::NetHTTP.semian_configuration
|
347
|
+
Semian::NetHTTP.semian_configuration = options
|
348
|
+
yield
|
349
|
+
ensure
|
350
|
+
Semian::NetHTTP.semian_configuration = orig_semian_options
|
351
|
+
end
|
352
|
+
|
353
|
+
def with_custom_errors(errors)
|
354
|
+
orig_errors = Semian::NetHTTP.exceptions.dup
|
355
|
+
Semian::NetHTTP.exceptions += errors
|
356
|
+
yield
|
357
|
+
ensure
|
358
|
+
Semian::NetHTTP.exceptions = orig_errors
|
359
|
+
end
|
360
|
+
|
361
|
+
def get_subclasses(klass)
|
362
|
+
ObjectSpace.each_object(klass.singleton_class).to_a - [klass]
|
363
|
+
end
|
364
|
+
|
365
|
+
def open_circuit!(hostname: HOSTNAME, toxic_port: TOXIC_PORT, toxic_name: "semian_test_net_http")
|
366
|
+
Net::HTTP.start(hostname, toxic_port) do |http|
|
367
|
+
http.read_timeout = 0.1
|
368
|
+
uri = URI("http://#{hostname}:#{toxic_port}/200")
|
369
|
+
http.raw_semian_options[:error_threshold].times do
|
370
|
+
# Cause error error_threshold times so circuit opens
|
371
|
+
Toxiproxy[toxic_name].downstream(:latency, latency: 150).apply do
|
372
|
+
request = Net::HTTP::Get.new(uri)
|
373
|
+
assert_raises Net::ReadTimeout do
|
374
|
+
http.request(request)
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
def close_circuit!(hostname: HOSTNAME, toxic_port: TOXIC_PORT)
|
382
|
+
http = Net::HTTP.new(hostname, toxic_port)
|
383
|
+
Timecop.travel(http.raw_semian_options[:error_timeout])
|
384
|
+
# Cause successes success_threshold times so circuit closes
|
385
|
+
http.raw_semian_options[:success_threshold].times do
|
386
|
+
response = http.get("/200")
|
387
|
+
assert(200, response.code)
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
def with_server(addresses: ["#{HOSTNAME}:#{PORT}"], reset_semian_state: true)
|
392
|
+
addresses.each do |address|
|
393
|
+
hostname, port = address.split(":")
|
394
|
+
begin
|
395
|
+
server = nil
|
396
|
+
server_threw_error = false
|
397
|
+
server_thread = Thread.new do
|
398
|
+
Thin::Logging.silent = true
|
399
|
+
server = Thin::Server.new(hostname, port, RackServer)
|
400
|
+
begin
|
401
|
+
server.start
|
402
|
+
rescue StandardError
|
403
|
+
server_threw_error = true
|
404
|
+
raise
|
405
|
+
end
|
406
|
+
end
|
407
|
+
|
408
|
+
begin
|
409
|
+
poll_until_ready(hostname: hostname, port: port)
|
410
|
+
rescue RuntimeError
|
411
|
+
server_thread.kill
|
412
|
+
server_thread.join if server_threw_error
|
413
|
+
raise
|
414
|
+
end
|
415
|
+
|
416
|
+
assert(server.running?)
|
417
|
+
reset_semian_resource(hostname: hostname, port: port) if reset_semian_state
|
418
|
+
@proxy = Toxiproxy[:semian_test_net_http]
|
419
|
+
yield(hostname, port.to_i)
|
420
|
+
ensure
|
421
|
+
server_thread.kill
|
422
|
+
poll_until_gone(hostname: hostname, port: port)
|
423
|
+
end
|
424
|
+
end
|
425
|
+
end
|
426
|
+
|
427
|
+
def reset_semian_resource(hostname:, port:)
|
428
|
+
Semian["nethttp_#{hostname}_#{port}"].reset if Semian["nethttp_#{hostname}_#{port}"]
|
429
|
+
Semian["nethttp_#{hostname}_#{port.to_i + 1}"].reset if Semian["nethttp_#{hostname}_#{port.to_i + 1}"]
|
430
|
+
Semian.destroy("nethttp_#{hostname}_#{port}")
|
431
|
+
Semian.destroy("nethttp_#{hostname}_#{port.to_i + 1}")
|
432
|
+
end
|
433
|
+
|
434
|
+
def with_toxic(hostname: HOSTNAME, upstream_port: PORT, toxic_port: upstream_port + 1)
|
435
|
+
old_proxy = @proxy
|
436
|
+
name = "semian_test_net_http_#{hostname}_#{upstream_port}<-#{toxic_port}"
|
437
|
+
Toxiproxy.populate([
|
438
|
+
{
|
439
|
+
name: name,
|
440
|
+
upstream: "#{hostname}:#{upstream_port}",
|
441
|
+
listen: "#{hostname}:#{toxic_port}",
|
442
|
+
},
|
443
|
+
])
|
444
|
+
@proxy = Toxiproxy[name]
|
445
|
+
yield(name)
|
446
|
+
rescue StandardError
|
447
|
+
ensure
|
448
|
+
@proxy = old_proxy
|
449
|
+
begin
|
450
|
+
Toxiproxy[name].destroy
|
451
|
+
rescue StandardError
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
def poll_until_ready(hostname: HOSTNAME, port: PORT, time_to_wait: 1)
|
456
|
+
start_time = Time.now.to_i
|
457
|
+
begin
|
458
|
+
TCPSocket.new(hostname, port).close
|
459
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET
|
460
|
+
if Time.now.to_i > start_time + time_to_wait
|
461
|
+
raise "Couldn't reach the service on hostname #{hostname} port #{port} after #{time_to_wait}s"
|
462
|
+
else
|
463
|
+
retry
|
464
|
+
end
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
def poll_until_gone(hostname: HOSTNAME, port: PORT, time_to_wait: 1)
|
469
|
+
start_time = Time.now.to_i
|
470
|
+
loop do
|
471
|
+
if Time.now.to_i > start_time + time_to_wait
|
472
|
+
raise "Could still reach the service on hostname #{hostname} port #{port} after #{time_to_wait}s"
|
473
|
+
end
|
474
|
+
begin
|
475
|
+
TCPSocket.new(hostname, port).close
|
476
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET
|
477
|
+
return true
|
478
|
+
end
|
479
|
+
end
|
480
|
+
end
|
481
|
+
end
|