stoplight 5.3.5 → 5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c930c989a3184cfc4fb254c720a854caf34fb56bbb550df150d65125969240b2
4
- data.tar.gz: 3ca98448ca2a378d6101e4b10e4567adb24bc65c2747d77bccf4b9386aeba64c
3
+ metadata.gz: 2629a7cdd83dce508860ffe0c7fe3da4cad02eab605e0cb1b4f1e4c0f7db509d
4
+ data.tar.gz: 50f13d3625ab327dd07815fd3da1a9384a0cf0f1d75af2db853b098c3b90ba2e
5
5
  SHA512:
6
- metadata.gz: caf3fbc0b32e823a3877525841ab46948fdedbda6791da8006ded3c194229aad7add1c01df82f60b406606a34481a40535662036b2f21caf1ea953ed58ae3eab
7
- data.tar.gz: 0d372655a21b4f2ebf04973a6fa41ae4e24e1bab2851bfef69c6ba866a2e38231cdc2a13f4c3afb7a267f56c7a5e3040d88c4ce0602f6b4392b9216fd57d6f2a
6
+ metadata.gz: 9aa893f78f602c6d71f82f15deb41d6aa0bb4bc0ca37bf3756003699e2b158e1d3a857ee0f1fdd6cebcbb35fb787e5f515a36f89dd5560c000297302cd0e7e18
7
+ data.tar.gz: 772938b653645a1ad3c8ca5e9e5958871c1b8802da6ce947d38fd9d5390fa9e336d8838941571569aed102cd0656c58389224b417bcf059f4569295c42894d6d
data/README.md CHANGED
@@ -99,6 +99,19 @@ light.run { 1 / 0 } #=> raises Stoplight::Error::RedLight: example-zero
99
99
  light.color # => "red"
100
100
  ```
101
101
 
102
+ The `Stoplight::Error::RedLight` provides metadata about the error:
103
+
104
+ ```ruby
105
+ def run_request
106
+ light = Stoplight("Example", cool_off_time: 10)
107
+ light.run { 1 / 0 } #=> raises Stoplight::Error::RedLight
108
+ rescue Stoplight::Error::RedLight => error
109
+ puts error.light_name #=> "Example"
110
+ puts error.cool_off_time #=> 10
111
+ puts error.retry_after #=> Absolute Time after which a recovery attempt can occur (e.g., "2025-10-21 15:39:50.672414 +0600")
112
+ end
113
+ ```
114
+
102
115
  After one minute, the light transitions to yellow, allowing a test execution:
103
116
 
104
117
  ```ruby
@@ -131,6 +144,8 @@ receives `nil`. In both cases, the return value of the fallback becomes the retu
131
144
 
132
145
  Stoplight comes with a built-in Admin Panel that can track all active Lights and manually lock them in the desired state (`Green` or `Red`). Locking lights in certain states might be helpful in scenarios like E2E testing.
133
146
 
147
+ ![Admin Panel Screenshot](assets/admin.png)
148
+
134
149
  To add Admin Panel protected by basic authentication to your Rails project, add this configuration to your `config/routes.rb` file.
135
150
 
136
151
  ```ruby
@@ -12,7 +12,9 @@ require "redis"
12
12
  # "redis://admin:p4ssw0rd@10.0.1.1:6380/15"
13
13
  redis = Redis.new
14
14
  data_store = Stoplight::DataStore::Redis.new(redis)
15
+ error_notifier = Rails.error.method(:report)
15
16
 
16
17
  Stoplight.configure do |config|
17
18
  config.data_store = data_store
19
+ config.error_notifier = error_notifier
18
20
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Stoplight
4
4
  module Config
5
- SystemConfig = LibraryDefaultConfig
5
+ SystemConfig = LibraryDefaultConfig.with(
6
+ traffic_recovery: :consecutive_successes,
7
+ recovery_threshold: 3
8
+ )
6
9
  end
7
10
  end
@@ -10,10 +10,6 @@ module Stoplight
10
10
  #
11
11
  # @api private
12
12
  class FailSafe < Base
13
- # @!attribute [r] data_store
14
- # @return [Stoplight::DataStore::Base] The underlying data store being wrapped.
15
- protected attr_reader :data_store
16
-
17
13
  class << self
18
14
  # Wraps a data store with fail-safe mechanisms.
19
15
  #
@@ -30,62 +26,70 @@ module Stoplight
30
26
  end
31
27
  end
32
28
 
29
+ # @!attribute data_store
30
+ # @return [Stoplight::DataStore::Base] The underlying primary data store being used
31
+ protected attr_reader :data_store
32
+
33
+ # @!attribute failover_data_store
34
+ # @return [Stoplight::DataStore::Base] The fallback data store used when the primary fails.
35
+ private attr_reader :failover_data_store
36
+
37
+ # @!attribute circuit_breaker
38
+ # @return [Stoplight::Light] The circuit breaker used to handle data store failures.
39
+ private attr_reader :circuit_breaker
40
+
33
41
  # @param data_store [Stoplight::DataStore::Base]
34
- def initialize(data_store)
42
+ def initialize(data_store, failover_data_store: Default::DATA_STORE)
35
43
  @data_store = data_store
36
- @circuit_breaker = Stoplight(
37
- "stoplight:data_store:fail_safe:#{data_store.class.name}",
38
- data_store: Default::DATA_STORE,
39
- traffic_control: TrafficControl::ConsecutiveErrors.new,
40
- threshold: Default::THRESHOLD
41
- )
44
+ @failover_data_store = failover_data_store
45
+ @circuit_breaker = Stoplight.system_light("stoplight:data_store:fail_safe:#{data_store.class.name}")
42
46
  end
43
47
 
44
48
  def names
45
- with_fallback([]) do
49
+ with_fallback(:names) do
46
50
  data_store.names
47
51
  end
48
52
  end
49
53
 
50
- def get_metadata(config)
51
- with_fallback(EmptyMetadata, config) do
52
- data_store.get_metadata(config)
54
+ def get_metadata(config, *args, **kwargs)
55
+ with_fallback(:get_metadata, config, *args, **kwargs) do
56
+ data_store.get_metadata(config, *args, **kwargs)
53
57
  end
54
58
  end
55
59
 
56
- def record_failure(config, failure)
57
- with_fallback(EmptyMetadata, config) do
58
- data_store.record_failure(config, failure)
60
+ def record_failure(config, *args, **kwargs)
61
+ with_fallback(:record_failure, config, *args, **kwargs) do
62
+ data_store.record_failure(config, *args, **kwargs)
59
63
  end
60
64
  end
61
65
 
62
- def record_success(config, **args)
63
- with_fallback(nil, config) do
64
- data_store.record_success(config, **args)
66
+ def record_success(config, *args, **kwargs)
67
+ with_fallback(:record_success, config, *args, **kwargs) do
68
+ data_store.record_success(config, *args, **kwargs)
65
69
  end
66
70
  end
67
71
 
68
- def record_recovery_probe_success(config, **args)
69
- with_fallback(EmptyMetadata, config) do
70
- data_store.record_recovery_probe_success(config, **args)
72
+ def record_recovery_probe_success(config, *args, **kwargs)
73
+ with_fallback(:record_recovery_probe_success, config, *args, **kwargs) do
74
+ data_store.record_recovery_probe_success(config, *args, **kwargs)
71
75
  end
72
76
  end
73
77
 
74
- def record_recovery_probe_failure(config, failure)
75
- with_fallback(EmptyMetadata, config) do
76
- data_store.record_recovery_probe_failure(config, failure)
78
+ def record_recovery_probe_failure(config, *args, **kwargs)
79
+ with_fallback(:record_recovery_probe_failure, config, *args, **kwargs) do
80
+ data_store.record_recovery_probe_failure(config, *args, **kwargs)
77
81
  end
78
82
  end
79
83
 
80
- def set_state(config, state)
81
- with_fallback(State::UNLOCKED, config) do
82
- data_store.set_state(config, state)
84
+ def set_state(config, *args, **kwargs)
85
+ with_fallback(:set_state, config, *args, **kwargs) do
86
+ data_store.set_state(config, *args, **kwargs)
83
87
  end
84
88
  end
85
89
 
86
- def transition_to_color(config, color)
87
- with_fallback(false, config) do
88
- data_store.transition_to_color(config, color)
90
+ def transition_to_color(config, *args, **kwargs)
91
+ with_fallback(:transition_to_color, config, *args, **kwargs) do
92
+ data_store.transition_to_color(config, *args, **kwargs)
89
93
  end
90
94
  end
91
95
 
@@ -93,21 +97,16 @@ module Stoplight
93
97
  other.is_a?(self.class) && other.data_store == data_store
94
98
  end
95
99
 
96
- # @param default [Object, nil]
97
- # @param config [Stoplight::Light::Config]
98
- private def with_fallback(default = nil, config = nil, &code)
100
+ # @param method_name [Symbol] protected method name
101
+ private def with_fallback(method_name, *args, **kwargs, &code)
99
102
  fallback = proc do |error|
103
+ config = args.first
100
104
  config.error_notifier.call(error) if config && error
101
- default
105
+ @failover_data_store.public_send(method_name, *args, **kwargs)
102
106
  end
103
107
 
104
108
  circuit_breaker.run(fallback, &code)
105
109
  end
106
-
107
- # @return [Stoplight::Light] The circuit breaker used to handle failures.
108
- private def circuit_breaker
109
- @circuit_breaker ||= Stoplight.system_light("stoplight:data_store:fail_safe:#{data_store.class.name}")
110
- end
111
110
  end
112
111
  end
113
112
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module DataStore
5
+ class Memory < Base
6
+ # Hash-based sliding window for O(1) amortized operations.
7
+ #
8
+ # Maintains a running sum and stores per-second counts in a Hash. Ruby's Hash
9
+ # preserves insertion order (FIFO), allowing efficient removal of expired
10
+ # buckets from the front via +Hash#shift+, with their counts subtracted from
11
+ # the running sum.
12
+ #
13
+ # Performance: O(1) amortized for both reads and writes
14
+ # Memory: Bounded to the number of buckets
15
+ #
16
+ # @note Not thread-safe; synchronization must be handled externally
17
+ # @api private
18
+ class SlidingWindow
19
+ # @!attribute buckets
20
+ # @return [Hash<Integer, Integer>] A hash mapping time buckets to their counts
21
+ private attr_reader :buckets
22
+
23
+ # @!attribute running_sum
24
+ # @return [Integer] The running sum of all increments in the current window
25
+ private attr_accessor :running_sum
26
+
27
+ def initialize
28
+ @buckets = Hash.new { |buckets, bucket| buckets[bucket] = 0 }
29
+ @running_sum = 0
30
+ end
31
+
32
+ # Increment the count at a given timestamp
33
+ def increment
34
+ buckets[current_bucket] += 1
35
+ self.running_sum += 1
36
+ end
37
+
38
+ # @param window_start [Time]
39
+ # @return [Integer]
40
+ def sum_in_window(window_start)
41
+ slide_window!(window_start)
42
+ self.running_sum
43
+ end
44
+
45
+ private def slide_window!(window_start)
46
+ window_start_ts = window_start.to_i
47
+
48
+ loop do
49
+ timestamp, sum = buckets.first
50
+ if timestamp.nil? || timestamp >= window_start_ts
51
+ break
52
+ else
53
+ self.running_sum -= sum
54
+ buckets.shift
55
+ end
56
+ end
57
+ end
58
+
59
+ private def current_bucket
60
+ bucket_for_time(current_time)
61
+ end
62
+
63
+ private def bucket_for_time(time)
64
+ time.to_i
65
+ end
66
+
67
+ private def current_time
68
+ Time.now
69
+ end
70
+
71
+ def inspect
72
+ "#<#{self.class.name} #{buckets}>"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -7,14 +7,15 @@ module Stoplight
7
7
  # @see Base
8
8
  class Memory < Base
9
9
  include MonitorMixin
10
+
10
11
  KEY_SEPARATOR = ":"
11
12
 
12
13
  def initialize
13
- @errors = Hash.new { |h, k| h[k] = [] }
14
- @successes = Hash.new { |h, k| h[k] = [] }
14
+ @errors = Hash.new { |errors, light_name| errors[light_name] = SlidingWindow.new }
15
+ @successes = Hash.new { |successes, light_name| successes[light_name] = SlidingWindow.new }
15
16
 
16
- @recovery_probe_errors = Hash.new { |h, k| h[k] = [] }
17
- @recovery_probe_successes = Hash.new { |h, k| h[k] = [] }
17
+ @recovery_probe_errors = Hash.new { |recovery_probe_errors, light_name| recovery_probe_errors[light_name] = SlidingWindow.new }
18
+ @recovery_probe_successes = Hash.new { |recovery_probe_successes, light_name| recovery_probe_successes[light_name] = SlidingWindow.new }
18
19
 
19
20
  @metadata = Hash.new { |h, k| h[k] = Metadata.new }
20
21
  super # MonitorMixin
@@ -29,66 +30,41 @@ module Stoplight
29
30
  # @return [Stoplight::Metadata]
30
31
  def get_metadata(config)
31
32
  light_name = config.name
32
- window_end = Time.now
33
- recovery_window = (window_end - config.cool_off_time + 1)..window_end
34
33
 
35
34
  synchronize do
35
+ current_time = self.current_time
36
+ recovery_window_start = (current_time - config.cool_off_time)
36
37
  recovered_at = @metadata[light_name].recovered_at
37
- window = if config.window_size
38
- window_start = [recovered_at, (window_end - config.window_size + 1)].compact.max
39
- (window_start..window_end)
38
+ window_start = if config.window_size
39
+ [recovered_at, (current_time - config.window_size)].compact.max
40
40
  else
41
- (..window_end)
42
- end
43
-
44
- errors = @errors[config.name].count do |request_time|
45
- window.cover?(request_time)
46
- end
47
-
48
- successes = @successes[config.name].count do |request_time|
49
- window.cover?(request_time)
50
- end
51
-
52
- recovery_probe_errors = @recovery_probe_errors[config.name].count do |request_time|
53
- recovery_window.cover?(request_time)
54
- end
55
- recovery_probe_successes = @recovery_probe_successes[config.name].count do |request_time|
56
- recovery_window.cover?(request_time)
41
+ current_time
57
42
  end
58
43
 
59
44
  @metadata[light_name].with(
60
- errors:,
61
- successes:,
62
- recovery_probe_errors:,
63
- recovery_probe_successes:
45
+ current_time:,
46
+ errors: @errors[config.name].sum_in_window(window_start),
47
+ successes: @successes[config.name].sum_in_window(window_start),
48
+ recovery_probe_errors: @recovery_probe_errors[config.name].sum_in_window(recovery_window_start),
49
+ recovery_probe_successes: @recovery_probe_successes[config.name].sum_in_window(recovery_window_start)
64
50
  )
65
51
  end
66
52
  end
67
53
 
68
- # @param metrics [<Time>]
69
- # @param window_size [Numeric, nil]
70
- # @return [void]
71
- def cleanup(metrics, window_size:)
72
- min_age = Time.now - [window_size&.*(3), METRICS_RETENTION_TIME].compact.min
73
-
74
- metrics.reject! { _1 < min_age }
75
- end
76
-
77
54
  # @param config [Stoplight::Light::Config]
78
55
  # @param failure [Stoplight::Failure]
79
56
  # @return [Stoplight::Metadata]
80
57
  def record_failure(config, failure)
58
+ current_time = self.current_time
81
59
  light_name = config.name
82
60
 
83
61
  synchronize do
84
- @errors[light_name].unshift(failure.time) if config.window_size
85
-
86
- cleanup(@errors[light_name], window_size: config.window_size)
62
+ @errors[light_name].increment if config.window_size
87
63
 
88
64
  metadata = @metadata[light_name]
89
- @metadata[light_name] = if metadata.last_error_at.nil? || failure.time > metadata.last_error_at
65
+ @metadata[light_name] = if metadata.last_error_at.nil? || current_time > metadata.last_error_at
90
66
  metadata.with(
91
- last_error_at: failure.time,
67
+ last_error_at: current_time,
92
68
  last_error: failure,
93
69
  consecutive_errors: metadata.consecutive_errors.succ,
94
70
  consecutive_successes: 0
@@ -104,20 +80,18 @@ module Stoplight
104
80
  end
105
81
 
106
82
  # @param config [Stoplight::Light::Config]
107
- # @param request_id [String]
108
- # @param request_time [Time]
109
83
  # @return [void]
110
- def record_success(config, request_time: Time.now, request_id: SecureRandom.hex(12))
84
+ def record_success(config)
111
85
  light_name = config.name
86
+ current_time = self.current_time
112
87
 
113
88
  synchronize do
114
- @successes[light_name].unshift(request_time) if config.window_size
115
- cleanup(@successes[light_name], window_size: config.window_size)
89
+ @successes[light_name].increment if config.window_size
116
90
 
117
91
  metadata = @metadata[light_name]
118
- @metadata[light_name] = if metadata.last_success_at.nil? || request_time > metadata.last_success_at
92
+ @metadata[light_name] = if metadata.last_success_at.nil? || current_time > metadata.last_success_at
119
93
  metadata.with(
120
- last_success_at: request_time,
94
+ last_success_at: current_time,
121
95
  consecutive_errors: 0,
122
96
  consecutive_successes: metadata.consecutive_successes.succ
123
97
  )
@@ -135,15 +109,15 @@ module Stoplight
135
109
  # @return [Stoplight::Metadata]
136
110
  def record_recovery_probe_failure(config, failure)
137
111
  light_name = config.name
112
+ current_time = self.current_time
138
113
 
139
114
  synchronize do
140
- @recovery_probe_errors[light_name].unshift(failure.time)
141
- cleanup(@recovery_probe_errors[light_name], window_size: config.cool_off_time)
115
+ @recovery_probe_errors[light_name].increment
142
116
 
143
117
  metadata = @metadata[light_name]
144
- @metadata[light_name] = if metadata.last_error_at.nil? || failure.time > metadata.last_error_at
118
+ @metadata[light_name] = if metadata.last_error_at.nil? || current_time > metadata.last_error_at
145
119
  metadata.with(
146
- last_error_at: failure.time,
120
+ last_error_at: current_time,
147
121
  last_error: failure,
148
122
  consecutive_errors: metadata.consecutive_errors.succ,
149
123
  consecutive_successes: 0
@@ -159,28 +133,23 @@ module Stoplight
159
133
  end
160
134
 
161
135
  # @param config [Stoplight::Light::Config]
162
- # @param request_id [String]
163
- # @param request_time [Time]
164
136
  # @return [Stoplight::Metadata]
165
- def record_recovery_probe_success(config, request_time: Time.now, request_id: SecureRandom.hex(12))
137
+ def record_recovery_probe_success(config)
166
138
  light_name = config.name
139
+ current_time = self.current_time
167
140
 
168
141
  synchronize do
169
- @recovery_probe_successes[light_name].unshift(request_time)
170
- cleanup(@recovery_probe_successes[light_name], window_size: config.cool_off_time)
142
+ @recovery_probe_successes[light_name].increment
171
143
 
172
144
  metadata = @metadata[light_name]
173
- recovery_started_at = metadata.recovery_started_at || request_time
174
- @metadata[light_name] = if metadata.last_success_at.nil? || request_time > metadata.last_success_at
145
+ @metadata[light_name] = if metadata.last_success_at.nil? || current_time > metadata.last_success_at
175
146
  metadata.with(
176
- last_success_at: request_time,
177
- recovery_started_at:,
147
+ last_success_at: current_time,
178
148
  consecutive_errors: 0,
179
149
  consecutive_successes: metadata.consecutive_successes.succ
180
150
  )
181
151
  else
182
152
  metadata.with(
183
- recovery_started_at:,
184
153
  consecutive_errors: 0,
185
154
  consecutive_successes: metadata.consecutive_successes.succ
186
155
  )
@@ -211,16 +180,15 @@ module Stoplight
211
180
  #
212
181
  # @param config [Stoplight::Light::Config] The light configuration
213
182
  # @param color [String] The color to transition to ("GREEN", "YELLOW", or "RED")
214
- # @param current_time [Time]
215
183
  # @return [Boolean] true if this is the first instance to detect this transition
216
- def transition_to_color(config, color, current_time: Time.now)
184
+ def transition_to_color(config, color)
217
185
  case color
218
186
  when Color::GREEN
219
187
  transition_to_green(config)
220
188
  when Color::YELLOW
221
- transition_to_yellow(config, current_time:)
189
+ transition_to_yellow(config)
222
190
  when Color::RED
223
- transition_to_red(config, current_time:)
191
+ transition_to_red(config)
224
192
  else
225
193
  raise ArgumentError, "Invalid color: #{color}"
226
194
  end
@@ -230,8 +198,9 @@ module Stoplight
230
198
  #
231
199
  # @param config [Stoplight::Light::Config] The light configuration
232
200
  # @return [Boolean] true if this is the first instance to detect this transition
233
- private def transition_to_green(config, current_time: Time.now)
201
+ private def transition_to_green(config)
234
202
  light_name = config.name
203
+ current_time = self.current_time
235
204
 
236
205
  synchronize do
237
206
  metadata = @metadata[light_name]
@@ -252,16 +221,14 @@ module Stoplight
252
221
  # Transitions to YELLOW (recovery) state and ensures only one notification
253
222
  #
254
223
  # @param config [Stoplight::Light::Config] The light configuration
255
- # @param current_time [Time]
256
224
  # @return [Boolean] true if this is the first instance to detect this transition
257
- private def transition_to_yellow(config, current_time: Time.now)
225
+ private def transition_to_yellow(config)
258
226
  light_name = config.name
227
+ current_time = self.current_time
259
228
 
260
229
  synchronize do
261
230
  metadata = @metadata[light_name]
262
- if metadata.recovery_started_at
263
- false
264
- else
231
+ if metadata.recovery_started_at.nil?
265
232
  @metadata[light_name] = metadata.with(
266
233
  recovery_started_at: current_time,
267
234
  recovery_scheduled_after: nil,
@@ -269,6 +236,13 @@ module Stoplight
269
236
  breached_at: nil
270
237
  )
271
238
  true
239
+ else
240
+ @metadata[light_name] = metadata.with(
241
+ recovery_scheduled_after: nil,
242
+ recovered_at: nil,
243
+ breached_at: nil
244
+ )
245
+ false
272
246
  end
273
247
  end
274
248
  end
@@ -276,10 +250,10 @@ module Stoplight
276
250
  # Transitions to RED state and ensures only one notification
277
251
  #
278
252
  # @param config [Stoplight::Light::Config] The light configuration
279
- # @param current_time [Time]
280
253
  # @return [Boolean] true if this is the first instance to detect this transition
281
- private def transition_to_red(config, current_time: Time.now)
254
+ private def transition_to_red(config)
282
255
  light_name = config.name
256
+ current_time = self.current_time
283
257
  recovery_scheduled_after = current_time + config.cool_off_time
284
258
 
285
259
  synchronize do
@@ -302,6 +276,10 @@ module Stoplight
302
276
  end
303
277
  end
304
278
  end
279
+
280
+ private def current_time
281
+ Time.now
282
+ end
305
283
  end
306
284
  end
307
285
  end
@@ -94,8 +94,7 @@ module Stoplight
94
94
  def get_metadata(config)
95
95
  detect_clock_skew
96
96
 
97
- window_end = Time.now
98
- window_end_ts = window_end.to_i
97
+ window_end_ts = current_time.to_i
99
98
  window_start_ts = window_end_ts - [config.window_size, Base::METRICS_RETENTION_TIME].compact.min.to_i
100
99
  recovery_window_start_ts = window_end_ts - config.cool_off_time.to_i
101
100
 
@@ -133,10 +132,11 @@ module Stoplight
133
132
  last_error = normalize_failure(last_error_json, config.error_notifier) if last_error_json
134
133
 
135
134
  Metadata.new(
136
- successes: successes,
137
- errors: errors,
138
- recovery_probe_successes: recovery_probe_successes,
139
- recovery_probe_errors: recovery_probe_errors,
135
+ current_time:,
136
+ successes:,
137
+ errors:,
138
+ recovery_probe_successes:,
139
+ recovery_probe_errors:,
140
140
  last_error:,
141
141
  **meta_hash
142
142
  )
@@ -146,7 +146,7 @@ module Stoplight
146
146
  # @param failure [Stoplight::Failure] The failure to record.
147
147
  # @return [Stoplight::Metadata] The updated metadata after recording the failure.
148
148
  def record_failure(config, failure)
149
- current_ts = failure.time.to_i
149
+ current_ts = current_time.to_i
150
150
  failure_json = failure.to_json
151
151
 
152
152
  @redis.then do |client|
@@ -162,16 +162,16 @@ module Stoplight
162
162
  get_metadata(config)
163
163
  end
164
164
 
165
- def record_success(config, request_id: SecureRandom.hex(12), request_time: Time.now)
166
- request_ts = request_time.to_i
165
+ def record_success(config, request_id: SecureRandom.hex(12))
166
+ current_ts = current_time.to_i
167
167
 
168
168
  @redis.then do |client|
169
169
  client.evalsha(
170
170
  record_success_sha,
171
- argv: [request_ts, request_id, metrics_ttl, metadata_ttl],
171
+ argv: [current_ts, request_id, metrics_ttl, metadata_ttl],
172
172
  keys: [
173
173
  metadata_key(config),
174
- config.window_size && successes_key(config, time: request_ts)
174
+ config.window_size && successes_key(config, time: current_ts)
175
175
  ].compact
176
176
  )
177
177
  end
@@ -183,7 +183,7 @@ module Stoplight
183
183
  # @param failure [Failure] The failure to record.
184
184
  # @return [Stoplight::Metadata] The updated metadata after recording the failure.
185
185
  def record_recovery_probe_failure(config, failure)
186
- current_ts = failure.time.to_i
186
+ current_ts = current_time.to_i
187
187
  failure_json = failure.to_json
188
188
 
189
189
  @redis.then do |client|
@@ -203,18 +203,17 @@ module Stoplight
203
203
  #
204
204
  # @param config [Stoplight::Light::Config] The light configuration.
205
205
  # @param request_id [String] The unique identifier for the request
206
- # @param request_time [Time] The time of the request
207
206
  # @return [Stoplight::Metadata] The updated metadata after recording the success.
208
- def record_recovery_probe_success(config, request_id: SecureRandom.hex(12), request_time: Time.now)
209
- request_ts = request_time.to_i
207
+ def record_recovery_probe_success(config, request_id: SecureRandom.hex(12))
208
+ current_ts = current_time.to_i
210
209
 
211
210
  @redis.then do |client|
212
211
  client.evalsha(
213
212
  record_success_sha,
214
- argv: [request_ts, request_id, metrics_ttl, metadata_ttl],
213
+ argv: [current_ts, request_id, metrics_ttl, metadata_ttl],
215
214
  keys: [
216
215
  metadata_key(config),
217
- recovery_probe_successes_key(config, time: request_ts)
216
+ recovery_probe_successes_key(config, time: current_ts)
218
217
  ].compact
219
218
  )
220
219
  end
@@ -236,18 +235,15 @@ module Stoplight
236
235
  #
237
236
  # @param config [Stoplight::Light::Config] The light configuration
238
237
  # @param color [String] The color to transition to ("green", "yellow", or "red")
239
- # @param current_time [Time] Current timestamp
240
238
  # @return [Boolean] true if this is the first instance to detect this transition
241
- def transition_to_color(config, color, current_time: Time.now)
242
- current_time.to_i
243
-
239
+ def transition_to_color(config, color)
244
240
  case color
245
241
  when Color::GREEN
246
242
  transition_to_green(config)
247
243
  when Color::YELLOW
248
- transition_to_yellow(config, current_time:)
244
+ transition_to_yellow(config)
249
245
  when Color::RED
250
- transition_to_red(config, current_time:)
246
+ transition_to_red(config)
251
247
  else
252
248
  raise ArgumentError, "Invalid color: #{color}"
253
249
  end
@@ -257,7 +253,7 @@ module Stoplight
257
253
  #
258
254
  # @param config [Stoplight::Light::Config] The light configuration
259
255
  # @return [Boolean] true if this is the first instance to detect this transition
260
- private def transition_to_green(config, current_time: Time.now)
256
+ private def transition_to_green(config)
261
257
  current_ts = current_time.to_i
262
258
  meta_key = metadata_key(config)
263
259
 
@@ -274,9 +270,8 @@ module Stoplight
274
270
  # Transitions to YELLOW (recovery) state and ensures only one notification
275
271
  #
276
272
  # @param config [Stoplight::Light::Config] The light configuration
277
- # @param current_time [Time] Current timestamp
278
273
  # @return [Boolean] true if this is the first instance to detect this transition
279
- private def transition_to_yellow(config, current_time: Time.now)
274
+ private def transition_to_yellow(config)
280
275
  current_ts = current_time.to_i
281
276
  meta_key = metadata_key(config)
282
277
 
@@ -293,9 +288,8 @@ module Stoplight
293
288
  # Transitions to RED state and ensures only one notification
294
289
  #
295
290
  # @param config [Stoplight::Light::Config] The light configuration
296
- # @param current_time [Time] Current timestamp
297
291
  # @return [Boolean] true if this is the first instance to detect this transition
298
- private def transition_to_red(config, current_time: Time.now)
292
+ private def transition_to_red(config)
299
293
  current_ts = current_time.to_i
300
294
  meta_key = metadata_key(config)
301
295
  recovery_scheduled_after_ts = current_ts + config.cool_off_time
@@ -398,7 +392,7 @@ module Stoplight
398
392
  return unless should_sample?(0.01) # 1% chance
399
393
 
400
394
  redis_seconds, _redis_millis = @redis.then(&:time)
401
- app_seconds = Time.now.to_i
395
+ app_seconds = current_time.to_i
402
396
  if (redis_seconds - app_seconds).abs > SKEW_TOLERANCE
403
397
  warn("Detected clock skew between Redis and the application server. Redis time: #{redis_seconds}, Application time: #{app_seconds}. See https://github.com/bolshakov/stoplight/wiki/Clock-Skew-and-Stoplight-Reliability")
404
398
  end
@@ -443,6 +437,10 @@ module Stoplight
443
437
  client.script("load", Lua::RECORD_FAILURE)
444
438
  end
445
439
  end
440
+
441
+ private def current_time
442
+ Time.now
443
+ end
446
444
  end
447
445
  end
448
446
  end
@@ -5,6 +5,35 @@ module Stoplight
5
5
  Base = Class.new(StandardError)
6
6
  ConfigurationError = Class.new(Base)
7
7
  IncorrectColor = Class.new(Base)
8
- RedLight = Class.new(Base)
8
+ class RedLight < Base
9
+ # @!attribute light_name
10
+ # @return [String] The light's name
11
+ attr_reader :light_name
12
+
13
+ # @!attribute cool_off_time
14
+ # @return [Numeric] Cool-off period in seconds
15
+ attr_reader :cool_off_time
16
+
17
+ # @!attribute retry_after
18
+ # @return [Time] Absolute Time after which a recovery attempt can occur
19
+ attr_reader :retry_after
20
+
21
+ # Initializes a new RedLight error.
22
+ #
23
+ # @param light_name [String] The light's name
24
+ #
25
+ # @option cool_off_time [Numeric] Cool-off period in seconds
26
+ #
27
+ # @option retry_after [Time] Absolute Time after which a recovery attempt can occur
28
+ #
29
+ # @return [Stoplight::Error::RedLight]
30
+ def initialize(light_name, cool_off_time:, retry_after:)
31
+ @light_name = light_name
32
+ @cool_off_time = cool_off_time
33
+ @retry_after = retry_after
34
+
35
+ super(light_name)
36
+ end
37
+ end
9
38
  end
10
39
  end
@@ -82,6 +82,7 @@ module Stoplight
82
82
  # @return [Stoplight::Light::Config] The validated configuration object.
83
83
  def validate_config!
84
84
  validate_traffic_control_compatibility!
85
+ validate_traffic_recovery_compatibility!
85
86
  self
86
87
  end
87
88
 
@@ -12,10 +12,11 @@ module Stoplight
12
12
  # Executes the provided code block when the light is in the green state.
13
13
  #
14
14
  # @param fallback [Proc, nil] A fallback proc to execute in case of an error.
15
+ # @param metadata [Stoplight::Metadata] Metadata capturing the current state of the light.
15
16
  # @yield The code block to execute.
16
17
  # @return [Object] The result of the code block if successful.
17
18
  # @raise [Exception] Re-raises the error if it is not tracked or no fallback is provided.
18
- def execute(fallback, &code)
19
+ def execute(fallback, metadata:, &code)
19
20
  # TODO: Consider implementing sampling rate to limit the memory footprint
20
21
  code.call.tap { record_success }
21
22
  rescue => error
@@ -12,13 +12,18 @@ module Stoplight
12
12
  # Executes the fallback proc when the light is in the red state.
13
13
  #
14
14
  # @param fallback [Proc, nil] A fallback proc to execute instead of the code block.
15
+ # @param metadata [Stoplight::Metadata] Metadata capturing the current state of the light.
15
16
  # @return [Object, nil] The result of the fallback proc if provided.
16
17
  # @raise [Stoplight::Error::RedLight] Raises an error if no fallback is provided.
17
- def execute(fallback)
18
+ def execute(fallback, metadata:)
18
19
  if fallback
19
20
  fallback.call(nil)
20
21
  else
21
- raise Error::RedLight, config.name
22
+ raise Error::RedLight.new(
23
+ config.name,
24
+ cool_off_time: config.cool_off_time,
25
+ retry_after: metadata.recovery_scheduled_after
26
+ )
22
27
  end
23
28
  end
24
29
  end
@@ -22,7 +22,9 @@ module Stoplight
22
22
  @data_store = config.data_store
23
23
  end
24
24
 
25
- def execute(fallback, &code)
25
+ # @param fallback [Proc, nil] A fallback proc to execute in case of an error.
26
+ # @param metadata [Stoplight::Metadata] Metadata capturing the current state of the light.
27
+ def execute(fallback, metadata:, &code)
26
28
  raise NotImplementedError, "Subclasses must implement the execute method"
27
29
  end
28
30
  end
@@ -13,10 +13,12 @@ module Stoplight
13
13
  # Executes the provided code block when the light is in the yellow state.
14
14
  #
15
15
  # @param fallback [Proc, nil] A fallback proc to execute in case of an error.
16
+ # @param metadata [Stoplight::Metadata] Metadata capturing the current state of the light.
16
17
  # @yield The code block to execute.
17
18
  # @return [Object] The result of the code block if successful.
18
19
  # @raise [Exception] Re-raises the error if it is not tracked or no fallback is provided.
19
- def execute(fallback, &code)
20
+ def execute(fallback, metadata:, &code)
21
+ transition_to_yellow(metadata:)
20
22
  # TODO: We need to employ a probabilistic approach here to avoid "thundering herd" problem
21
23
  code.call.tap { record_recovery_probe_success }
22
24
  rescue => error
@@ -47,6 +49,18 @@ module Stoplight
47
49
  recover(metadata)
48
50
  end
49
51
 
52
+ # @param metadata [Stoplight::Metadata]
53
+ # @return [void]
54
+ def transition_to_yellow(metadata:)
55
+ return unless metadata.color == Color::YELLOW
56
+
57
+ if metadata.recovery_scheduled_after && config.data_store.transition_to_color(config, Color::YELLOW)
58
+ config.notifiers.each do |notifier|
59
+ notifier.notify(config, Color::RED, Color::YELLOW, nil)
60
+ end
61
+ end
62
+ end
63
+
50
64
  private def recover(metadata)
51
65
  recovery_result = config.traffic_recovery.determine_color(config, metadata)
52
66
 
@@ -60,7 +74,7 @@ module Stoplight
60
74
  when TrafficRecovery::YELLOW
61
75
  if data_store.transition_to_color(config, Color::YELLOW)
62
76
  config.notifiers.each do |notifier|
63
- notifier.notify(config, Color::GREEN, Color::YELLOW, nil)
77
+ notifier.notify(config, Color::RED, Color::YELLOW, nil)
64
78
  end
65
79
  end
66
80
  when TrafficRecovery::RED
@@ -32,10 +32,7 @@ module Stoplight
32
32
  #
33
33
  # @return [String]
34
34
  def state
35
- config
36
- .data_store
37
- .get_metadata(config)
38
- .locked_state
35
+ metadata.locked_state
39
36
  end
40
37
 
41
38
  # Returns current color:
@@ -49,10 +46,7 @@ module Stoplight
49
46
  #
50
47
  # @return [String] returns current light color
51
48
  def color
52
- config
53
- .data_store
54
- .get_metadata(config)
55
- .color
49
+ metadata.color
56
50
  end
57
51
 
58
52
  # Runs the given block of code with this circuit breaker
@@ -72,8 +66,10 @@ module Stoplight
72
66
  def run(fallback = nil, &code)
73
67
  raise ArgumentError, "nothing to run. Please, pass a block into `Light#run`" unless block_given?
74
68
 
75
- strategy = state_strategy_factory(color)
76
- strategy.execute(fallback, &code)
69
+ metadata.then do |metadata|
70
+ strategy = state_strategy_factory(metadata.color)
71
+ strategy.execute(fallback, metadata:, &code)
72
+ end
77
73
  end
78
74
 
79
75
  # Locks light in either +State::LOCKED_RED+ or +State::LOCKED_GREEN+
@@ -186,5 +182,10 @@ module Stoplight
186
182
  def reconfigure(config)
187
183
  self.class.new(config)
188
184
  end
185
+
186
+ # @return [Stoplight::Metadata]
187
+ def metadata
188
+ config.data_store.get_metadata(config)
189
+ end
189
190
  end
190
191
  end
@@ -16,9 +16,11 @@ module Stoplight
16
16
  :locked_state,
17
17
  :recovery_scheduled_after,
18
18
  :recovery_started_at,
19
- :recovered_at
19
+ :recovered_at,
20
+ :current_time
20
21
  ) do
21
22
  def initialize(
23
+ current_time: Time.now,
22
24
  successes: 0,
23
25
  errors: 0,
24
26
  recovery_probe_successes: 0,
@@ -49,6 +51,7 @@ module Stoplight
49
51
  recovery_scheduled_after: (Time.at(Integer(recovery_scheduled_after)) if recovery_scheduled_after),
50
52
  recovery_started_at: (Time.at(Integer(recovery_started_at)) if recovery_started_at),
51
53
  recovered_at: (Time.at(Integer(recovered_at)) if recovered_at),
54
+ current_time:,
52
55
  )
53
56
  end
54
57
 
@@ -59,17 +62,16 @@ module Stoplight
59
62
  # @param kwargs [Hash{Symbol => Object}]
60
63
  # @return [Metadata]
61
64
  def with(**kwargs)
62
- self.class.new(**to_h.merge(kwargs))
65
+ self.class.new(**to_h.merge(current_time: Time.now, **kwargs))
63
66
  end
64
67
 
65
- # @param at [Time] (Time.now) the moment of time when the color is determined
66
68
  # @return [String] one of +Color::GREEN+, +Color::RED+, or +Color::YELLOW+
67
- def color(at: Time.now)
69
+ def color
68
70
  if locked_state == State::LOCKED_GREEN
69
71
  Color::GREEN
70
72
  elsif locked_state == State::LOCKED_RED
71
73
  Color::RED
72
- elsif (recovery_scheduled_after && recovery_scheduled_after < at) || recovery_started_at
74
+ elsif (recovery_scheduled_after && recovery_scheduled_after < current_time) || recovery_started_at
73
75
  Color::YELLOW
74
76
  elsif breached_at
75
77
  Color::RED
@@ -14,13 +14,13 @@ module Stoplight
14
14
  # reach the threshold.
15
15
  #
16
16
  # @example With window-based configuration
17
- # traffic_control = Stoplight::TrafficControlStrategy::ConsecutiveErrors.new
17
+ # traffic_control = Stoplight::TrafficControl::ConsecutiveErrors.new
18
18
  # config = Stoplight::Light::Config.new(threshold: 5, window_size: 60, traffic_control:)
19
19
  #
20
20
  # Will switch to red if 5 consecutive failures occur within the 60-second window
21
21
  #
22
22
  # @example With total number of consecutive failures configuration
23
- # traffic_control = Stoplight::TrafficControlStrategy::ConsecutiveErrors.new
23
+ # traffic_control = Stoplight::TrafficControl::ConsecutiveErrors.new
24
24
  # config = Stoplight::Light::Config.new(threshold: 5, window_size: nil, traffic_control:)
25
25
  #
26
26
  # Will switch to red only if 5 consecutive failures occur regardless of the time window
@@ -5,14 +5,14 @@ module Stoplight
5
5
  # A strategy that stops the traffic based on error rate.
6
6
  #
7
7
  # @example
8
- # traffic_control = Stoplight::TrafficControlStrategy::ErrorRate.new
8
+ # traffic_control = Stoplight::TrafficControl::ErrorRate.new
9
9
  # config = Stoplight::Light::Config.new(threshold: 0.6, window_size: 300, traffic_control:)
10
10
  #
11
11
  # Will switch to red if 60% error rate reached within the 5-minute (300 seconds) sliding window.
12
12
  # By default this traffic control strategy starts evaluating only after 10 requests have been made. You can
13
13
  # adjust this by passing a different value for `min_requests` when initializing the strategy.
14
14
  #
15
- # traffic_control = Stoplight::TrafficControlStrategy::ErrorRate.new(min_requests: 100)
15
+ # traffic_control = Stoplight::TrafficControl::ErrorRate.new(min_requests: 100)
16
16
  #
17
17
  # @api private
18
18
  class ErrorRate < Base
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stoplight
4
- VERSION = Gem::Version.new("5.3.5")
4
+ VERSION = Gem::Version.new("5.4.0")
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stoplight
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.3.5
4
+ version: 5.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cameron Desautels
@@ -67,6 +67,7 @@ files:
67
67
  - lib/stoplight/data_store/base.rb
68
68
  - lib/stoplight/data_store/fail_safe.rb
69
69
  - lib/stoplight/data_store/memory.rb
70
+ - lib/stoplight/data_store/memory/sliding_window.rb
70
71
  - lib/stoplight/data_store/redis.rb
71
72
  - lib/stoplight/data_store/redis/get_metadata.lua
72
73
  - lib/stoplight/data_store/redis/lua.rb
@@ -76,7 +77,6 @@ files:
76
77
  - lib/stoplight/data_store/redis/transition_to_red.lua
77
78
  - lib/stoplight/data_store/redis/transition_to_yellow.lua
78
79
  - lib/stoplight/default.rb
79
- - lib/stoplight/empty_metadata.rb
80
80
  - lib/stoplight/error.rb
81
81
  - lib/stoplight/failure.rb
82
82
  - lib/stoplight/light.rb
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Stoplight
4
- EmptyMetadata = Metadata.new
5
- end