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.
@@ -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
@@ -34,7 +34,10 @@ module Semian
34
34
  true
35
35
  end
36
36
 
37
- def mark_failed(error)
37
+ def mark_failed(_error)
38
+ end
39
+
40
+ def mark_success
38
41
  end
39
42
  end
40
43
  end
@@ -1,3 +1,3 @@
1
1
  module Semian
2
- VERSION = '0.3.0'
2
+ VERSION = '0.4.0'
3
3
  end
@@ -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-1.0.0.deb"
10
- wget -O /tmp/toxiproxy-1.0.0.deb https://github.com/Shopify/toxiproxy/releases/download/v1.0.0/toxiproxy_1.0.0_amd64.deb
11
- sudo dpkg -i /tmp/toxiproxy-1.0.0.deb
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
@@ -1,4 +1,4 @@
1
- $:.unshift File.expand_path('../lib', __FILE__)
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/csfrancis/semian'
14
+ s.homepage = 'https://github.com/shopify/semian'
15
15
  s.authors = ['Scott Francis', 'Simon Eskildsen']
16
- s.email = 'scott.francis@shopify.com'
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
- Semian.destroy(:testing) rescue nil
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
@@ -2,7 +2,7 @@ module BackgroundHelper
2
2
  attr_writer :threads
3
3
 
4
4
  def teardown
5
- threads.each { |t| t.kill }
5
+ threads.each(&:kill)
6
6
  self.threads = []
7
7
  end
8
8
 
@@ -50,7 +50,7 @@ class TestInstrumentation < MiniTest::Unit::TestCase
50
50
 
51
51
  def assert_notify(*expected_events)
52
52
  events = []
53
- subscription = Semian.subscribe do |event, resource|
53
+ subscription = Semian.subscribe do |event, _resource|
54
54
  events << event
55
55
  end
56
56
  yield
@@ -70,7 +70,7 @@ class TestMysql2 < MiniTest::Unit::TestCase
70
70
  end
71
71
 
72
72
  def test_resource_acquisition_for_connect
73
- client = connect_to_mysql!
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