stoplight 5.3.8 → 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: e14702003b76fde01b28e5266b392f5d55b2924c83365a0ab122f11aebf2e4a7
4
- data.tar.gz: e9c1b348b2637aa66408de62a2bbbc24b9b73927ca9712a8b4137446e336cd0a
3
+ metadata.gz: 2629a7cdd83dce508860ffe0c7fe3da4cad02eab605e0cb1b4f1e4c0f7db509d
4
+ data.tar.gz: 50f13d3625ab327dd07815fd3da1a9384a0cf0f1d75af2db853b098c3b90ba2e
5
5
  SHA512:
6
- metadata.gz: 1601dd8a7fae2e9c8f89a52556c8f931d3712d71648300aef0e3dae226c9998b7b1fa35e7fa0ce151d943e961473e012ca382b718bb50b44000f5a43e4d667b8
7
- data.tar.gz: 7c4e9696009e01bea1908cd3212d4625cc0ac3e03205d1cd69e4471f9aec4f3add7f101e58b393df210658e6be2c2d42b5d4a2ddedd954f832053505e291f232
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(Metadata.new, 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(Metadata.new, 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(Metadata.new, 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(Metadata.new, 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
@@ -11,11 +11,11 @@ module Stoplight
11
11
  KEY_SEPARATOR = ":"
12
12
 
13
13
  def initialize
14
- @errors = Hash.new { |h, k| h[k] = [] }
15
- @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 }
16
16
 
17
- @recovery_probe_errors = Hash.new { |h, k| h[k] = [] }
18
- @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 }
19
19
 
20
20
  @metadata = Hash.new { |h, k| h[k] = Metadata.new }
21
21
  super # MonitorMixin
@@ -32,65 +32,39 @@ module Stoplight
32
32
  light_name = config.name
33
33
 
34
34
  synchronize do
35
- current_time = Time.now
36
- recovery_window = (current_time - config.cool_off_time)..current_time
35
+ current_time = self.current_time
36
+ recovery_window_start = (current_time - config.cool_off_time)
37
37
  recovered_at = @metadata[light_name].recovered_at
38
- window = if config.window_size
39
- window_start = [recovered_at, (current_time - config.window_size)].compact.max
40
- (window_start..current_time)
38
+ window_start = if config.window_size
39
+ [recovered_at, (current_time - config.window_size)].compact.max
41
40
  else
42
- (..current_time)
43
- end
44
-
45
- errors = @errors[config.name].count do |request_time|
46
- window.cover?(request_time)
47
- end
48
-
49
- successes = @successes[config.name].count do |request_time|
50
- window.cover?(request_time)
51
- end
52
-
53
- recovery_probe_errors = @recovery_probe_errors[config.name].count do |request_time|
54
- recovery_window.cover?(request_time)
55
- end
56
- recovery_probe_successes = @recovery_probe_successes[config.name].count do |request_time|
57
- recovery_window.cover?(request_time)
41
+ current_time
58
42
  end
59
43
 
60
44
  @metadata[light_name].with(
61
45
  current_time:,
62
- errors:,
63
- successes:,
64
- recovery_probe_errors:,
65
- recovery_probe_successes:
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)
66
50
  )
67
51
  end
68
52
  end
69
53
 
70
- # @param metrics [<Time>]
71
- # @param window_size [Numeric, nil]
72
- # @return [void]
73
- def cleanup(metrics, window_size:)
74
- min_age = Time.now - [window_size&.*(3), METRICS_RETENTION_TIME].compact.min
75
-
76
- metrics.reject! { _1 < min_age }
77
- end
78
-
79
54
  # @param config [Stoplight::Light::Config]
80
55
  # @param failure [Stoplight::Failure]
81
56
  # @return [Stoplight::Metadata]
82
57
  def record_failure(config, failure)
58
+ current_time = self.current_time
83
59
  light_name = config.name
84
60
 
85
61
  synchronize do
86
- @errors[light_name].unshift(failure.time) if config.window_size
87
-
88
- cleanup(@errors[light_name], window_size: config.window_size)
62
+ @errors[light_name].increment if config.window_size
89
63
 
90
64
  metadata = @metadata[light_name]
91
- @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
92
66
  metadata.with(
93
- last_error_at: failure.time,
67
+ last_error_at: current_time,
94
68
  last_error: failure,
95
69
  consecutive_errors: metadata.consecutive_errors.succ,
96
70
  consecutive_successes: 0
@@ -106,20 +80,18 @@ module Stoplight
106
80
  end
107
81
 
108
82
  # @param config [Stoplight::Light::Config]
109
- # @param request_id [String]
110
- # @param request_time [Time]
111
83
  # @return [void]
112
- def record_success(config, request_time: Time.now, request_id: SecureRandom.hex(12))
84
+ def record_success(config)
113
85
  light_name = config.name
86
+ current_time = self.current_time
114
87
 
115
88
  synchronize do
116
- @successes[light_name].unshift(request_time) if config.window_size
117
- cleanup(@successes[light_name], window_size: config.window_size)
89
+ @successes[light_name].increment if config.window_size
118
90
 
119
91
  metadata = @metadata[light_name]
120
- @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
121
93
  metadata.with(
122
- last_success_at: request_time,
94
+ last_success_at: current_time,
123
95
  consecutive_errors: 0,
124
96
  consecutive_successes: metadata.consecutive_successes.succ
125
97
  )
@@ -137,15 +109,15 @@ module Stoplight
137
109
  # @return [Stoplight::Metadata]
138
110
  def record_recovery_probe_failure(config, failure)
139
111
  light_name = config.name
112
+ current_time = self.current_time
140
113
 
141
114
  synchronize do
142
- @recovery_probe_errors[light_name].unshift(failure.time)
143
- cleanup(@recovery_probe_errors[light_name], window_size: config.cool_off_time)
115
+ @recovery_probe_errors[light_name].increment
144
116
 
145
117
  metadata = @metadata[light_name]
146
- @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
147
119
  metadata.with(
148
- last_error_at: failure.time,
120
+ last_error_at: current_time,
149
121
  last_error: failure,
150
122
  consecutive_errors: metadata.consecutive_errors.succ,
151
123
  consecutive_successes: 0
@@ -161,20 +133,18 @@ module Stoplight
161
133
  end
162
134
 
163
135
  # @param config [Stoplight::Light::Config]
164
- # @param request_id [String]
165
- # @param request_time [Time]
166
136
  # @return [Stoplight::Metadata]
167
- def record_recovery_probe_success(config, request_time: Time.now, request_id: SecureRandom.hex(12))
137
+ def record_recovery_probe_success(config)
168
138
  light_name = config.name
139
+ current_time = self.current_time
169
140
 
170
141
  synchronize do
171
- @recovery_probe_successes[light_name].unshift(request_time)
172
- cleanup(@recovery_probe_successes[light_name], window_size: config.cool_off_time)
142
+ @recovery_probe_successes[light_name].increment
173
143
 
174
144
  metadata = @metadata[light_name]
175
- @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
176
146
  metadata.with(
177
- last_success_at: request_time,
147
+ last_success_at: current_time,
178
148
  consecutive_errors: 0,
179
149
  consecutive_successes: metadata.consecutive_successes.succ
180
150
  )
@@ -210,16 +180,15 @@ module Stoplight
210
180
  #
211
181
  # @param config [Stoplight::Light::Config] The light configuration
212
182
  # @param color [String] The color to transition to ("GREEN", "YELLOW", or "RED")
213
- # @param current_time [Time]
214
183
  # @return [Boolean] true if this is the first instance to detect this transition
215
- def transition_to_color(config, color, current_time: Time.now)
184
+ def transition_to_color(config, color)
216
185
  case color
217
186
  when Color::GREEN
218
187
  transition_to_green(config)
219
188
  when Color::YELLOW
220
- transition_to_yellow(config, current_time:)
189
+ transition_to_yellow(config)
221
190
  when Color::RED
222
- transition_to_red(config, current_time:)
191
+ transition_to_red(config)
223
192
  else
224
193
  raise ArgumentError, "Invalid color: #{color}"
225
194
  end
@@ -229,8 +198,9 @@ module Stoplight
229
198
  #
230
199
  # @param config [Stoplight::Light::Config] The light configuration
231
200
  # @return [Boolean] true if this is the first instance to detect this transition
232
- private def transition_to_green(config, current_time: Time.now)
201
+ private def transition_to_green(config)
233
202
  light_name = config.name
203
+ current_time = self.current_time
234
204
 
235
205
  synchronize do
236
206
  metadata = @metadata[light_name]
@@ -251,10 +221,10 @@ module Stoplight
251
221
  # Transitions to YELLOW (recovery) state and ensures only one notification
252
222
  #
253
223
  # @param config [Stoplight::Light::Config] The light configuration
254
- # @param current_time [Time]
255
224
  # @return [Boolean] true if this is the first instance to detect this transition
256
- private def transition_to_yellow(config, current_time: Time.now)
225
+ private def transition_to_yellow(config)
257
226
  light_name = config.name
227
+ current_time = self.current_time
258
228
 
259
229
  synchronize do
260
230
  metadata = @metadata[light_name]
@@ -280,10 +250,10 @@ module Stoplight
280
250
  # Transitions to RED state and ensures only one notification
281
251
  #
282
252
  # @param config [Stoplight::Light::Config] The light configuration
283
- # @param current_time [Time]
284
253
  # @return [Boolean] true if this is the first instance to detect this transition
285
- private def transition_to_red(config, current_time: Time.now)
254
+ private def transition_to_red(config)
286
255
  light_name = config.name
256
+ current_time = self.current_time
287
257
  recovery_scheduled_after = current_time + config.cool_off_time
288
258
 
289
259
  synchronize do
@@ -306,6 +276,10 @@ module Stoplight
306
276
  end
307
277
  end
308
278
  end
279
+
280
+ private def current_time
281
+ Time.now
282
+ end
309
283
  end
310
284
  end
311
285
  end
@@ -94,7 +94,6 @@ module Stoplight
94
94
  def get_metadata(config)
95
95
  detect_clock_skew
96
96
 
97
- current_time = Time.now
98
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
@@ -147,7 +146,7 @@ module Stoplight
147
146
  # @param failure [Stoplight::Failure] The failure to record.
148
147
  # @return [Stoplight::Metadata] The updated metadata after recording the failure.
149
148
  def record_failure(config, failure)
150
- current_ts = failure.time.to_i
149
+ current_ts = current_time.to_i
151
150
  failure_json = failure.to_json
152
151
 
153
152
  @redis.then do |client|
@@ -163,16 +162,16 @@ module Stoplight
163
162
  get_metadata(config)
164
163
  end
165
164
 
166
- def record_success(config, request_id: SecureRandom.hex(12), request_time: Time.now)
167
- request_ts = request_time.to_i
165
+ def record_success(config, request_id: SecureRandom.hex(12))
166
+ current_ts = current_time.to_i
168
167
 
169
168
  @redis.then do |client|
170
169
  client.evalsha(
171
170
  record_success_sha,
172
- argv: [request_ts, request_id, metrics_ttl, metadata_ttl],
171
+ argv: [current_ts, request_id, metrics_ttl, metadata_ttl],
173
172
  keys: [
174
173
  metadata_key(config),
175
- config.window_size && successes_key(config, time: request_ts)
174
+ config.window_size && successes_key(config, time: current_ts)
176
175
  ].compact
177
176
  )
178
177
  end
@@ -184,7 +183,7 @@ module Stoplight
184
183
  # @param failure [Failure] The failure to record.
185
184
  # @return [Stoplight::Metadata] The updated metadata after recording the failure.
186
185
  def record_recovery_probe_failure(config, failure)
187
- current_ts = failure.time.to_i
186
+ current_ts = current_time.to_i
188
187
  failure_json = failure.to_json
189
188
 
190
189
  @redis.then do |client|
@@ -204,18 +203,17 @@ module Stoplight
204
203
  #
205
204
  # @param config [Stoplight::Light::Config] The light configuration.
206
205
  # @param request_id [String] The unique identifier for the request
207
- # @param request_time [Time] The time of the request
208
206
  # @return [Stoplight::Metadata] The updated metadata after recording the success.
209
- def record_recovery_probe_success(config, request_id: SecureRandom.hex(12), request_time: Time.now)
210
- 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
211
209
 
212
210
  @redis.then do |client|
213
211
  client.evalsha(
214
212
  record_success_sha,
215
- argv: [request_ts, request_id, metrics_ttl, metadata_ttl],
213
+ argv: [current_ts, request_id, metrics_ttl, metadata_ttl],
216
214
  keys: [
217
215
  metadata_key(config),
218
- recovery_probe_successes_key(config, time: request_ts)
216
+ recovery_probe_successes_key(config, time: current_ts)
219
217
  ].compact
220
218
  )
221
219
  end
@@ -237,18 +235,15 @@ module Stoplight
237
235
  #
238
236
  # @param config [Stoplight::Light::Config] The light configuration
239
237
  # @param color [String] The color to transition to ("green", "yellow", or "red")
240
- # @param current_time [Time] Current timestamp
241
238
  # @return [Boolean] true if this is the first instance to detect this transition
242
- def transition_to_color(config, color, current_time: Time.now)
243
- current_time.to_i
244
-
239
+ def transition_to_color(config, color)
245
240
  case color
246
241
  when Color::GREEN
247
242
  transition_to_green(config)
248
243
  when Color::YELLOW
249
- transition_to_yellow(config, current_time:)
244
+ transition_to_yellow(config)
250
245
  when Color::RED
251
- transition_to_red(config, current_time:)
246
+ transition_to_red(config)
252
247
  else
253
248
  raise ArgumentError, "Invalid color: #{color}"
254
249
  end
@@ -258,7 +253,7 @@ module Stoplight
258
253
  #
259
254
  # @param config [Stoplight::Light::Config] The light configuration
260
255
  # @return [Boolean] true if this is the first instance to detect this transition
261
- private def transition_to_green(config, current_time: Time.now)
256
+ private def transition_to_green(config)
262
257
  current_ts = current_time.to_i
263
258
  meta_key = metadata_key(config)
264
259
 
@@ -275,9 +270,8 @@ module Stoplight
275
270
  # Transitions to YELLOW (recovery) state and ensures only one notification
276
271
  #
277
272
  # @param config [Stoplight::Light::Config] The light configuration
278
- # @param current_time [Time] Current timestamp
279
273
  # @return [Boolean] true if this is the first instance to detect this transition
280
- private def transition_to_yellow(config, current_time: Time.now)
274
+ private def transition_to_yellow(config)
281
275
  current_ts = current_time.to_i
282
276
  meta_key = metadata_key(config)
283
277
 
@@ -294,9 +288,8 @@ module Stoplight
294
288
  # Transitions to RED state and ensures only one notification
295
289
  #
296
290
  # @param config [Stoplight::Light::Config] The light configuration
297
- # @param current_time [Time] Current timestamp
298
291
  # @return [Boolean] true if this is the first instance to detect this transition
299
- private def transition_to_red(config, current_time: Time.now)
292
+ private def transition_to_red(config)
300
293
  current_ts = current_time.to_i
301
294
  meta_key = metadata_key(config)
302
295
  recovery_scheduled_after_ts = current_ts + config.cool_off_time
@@ -399,7 +392,7 @@ module Stoplight
399
392
  return unless should_sample?(0.01) # 1% chance
400
393
 
401
394
  redis_seconds, _redis_millis = @redis.then(&:time)
402
- app_seconds = Time.now.to_i
395
+ app_seconds = current_time.to_i
403
396
  if (redis_seconds - app_seconds).abs > SKEW_TOLERANCE
404
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")
405
398
  end
@@ -444,6 +437,10 @@ module Stoplight
444
437
  client.script("load", Lua::RECORD_FAILURE)
445
438
  end
446
439
  end
440
+
441
+ private def current_time
442
+ Time.now
443
+ end
447
444
  end
448
445
  end
449
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
 
@@ -19,7 +19,11 @@ module Stoplight
19
19
  if fallback
20
20
  fallback.call(nil)
21
21
  else
22
- 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
+ )
23
27
  end
24
28
  end
25
29
  end
@@ -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.8")
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.8
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