stoplight 5.1.0 → 5.2.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: 2fba85e77b2b2c6569592f463080da2bca787dc65f12da00bf566b4bb7e56912
4
- data.tar.gz: 4aaac27c0747de8a550575ecd8a93cc2391f3a73c008c543ee049db6f7fd01b7
3
+ metadata.gz: 4b9625eb129c8bbefdab0e3c9552bd6abe6d146c6ffaaab8f25edcb14ddf29db
4
+ data.tar.gz: db63d134926cceef9e46ed7b0dd72156fa1d55a883f116626a46ee1aaa7af9ff
5
5
  SHA512:
6
- metadata.gz: acc5305b5da510feacb9584a60a6043eb4c7c7280bbf6f3d6c77db968a445aa45fcfaaaf41027684ff9dc7f4ebf615b1f27e0d53315eda932126b6c47b5ca514
7
- data.tar.gz: 1ce0b8d4fea66779d6688c52a871ac10568184f796fc3d51a08c9d6fb45e2402b59da077cbcdd049b1dcdc9e2e748ebdde9ebd327b28aa810190b6bf8d17049c
6
+ metadata.gz: 7c59ce8f66fdf2871c2998e59adec143ad563e2815f3ebeaf7e8555a5ca39c65062fc0b067067067e1c7541c39db99d3724539eca6e26102abc5fd5f908a2326
7
+ data.tar.gz: 7c9c7a289d356acfbdbda23f0f28f32d0379999f644e7eae447a9f60b6a6ac05fc73c01af0118f05158802a53eabe449ec03ba5cbdad0de3b2b7148b2b91aa22
data/README.md CHANGED
@@ -13,10 +13,10 @@ Stoplight is traffic control for code. It's an implementation of the circuit bre
13
13
  the documentation of the previous version 4.x, you can find it [here](https://github.com/bolshakov/stoplight/tree/v4.1.1).
14
14
 
15
15
  Stoplight helps your application gracefully handle failures in external dependencies
16
- (like flaky databases, unreliable APIs, or spotty web services). By wrapping these unreliable
16
+ (like flaky databases, unreliable APIs, or spotty web services). By wrapping these unreliable
17
17
  calls, Stoplight prevents cascading failures from affecting your entire application.
18
18
 
19
- **The best part?** Stoplight works with zero configuration out of the box, while offering deep customization when you
19
+ **The best part?** Stoplight works with zero configuration out of the box, while offering deep customization when you
20
20
  need it.
21
21
 
22
22
  ## Installation
@@ -41,10 +41,10 @@ Stoplight operates like a traffic light with three states:
41
41
 
42
42
  ```mermaid
43
43
  stateDiagram
44
- Green --> Red: Failures reach threshold
44
+ Green --> Red: Errors reach threshold
45
45
  Red --> Yellow: After cool_off_time
46
- Yellow --> Green: Successful attempt
47
- Yellow --> Red: Failed attempt
46
+ Yellow --> Green: Successful recovery
47
+ Yellow --> Red: Failed recovery
48
48
  Green --> Green: Success
49
49
 
50
50
  classDef greenState fill:#28a745,stroke:#1e7e34,stroke-width:2px,color:#fff
@@ -60,11 +60,15 @@ stateDiagram
60
60
  - **Red**: Failure state. Fast-fails without running the code. (Circuit open)
61
61
  - **Yellow**: Recovery state. Allows a test execution to see if the problem is resolved. (Circuit half-open)
62
62
 
63
- Stoplight's behavior is controlled by three primary parameters:
63
+ Stoplight's behavior is controlled by two main parameters:
64
64
 
65
- 1. **Threshold** (default: `3`): Number of failures required to transition from green to red.
66
- 2. **Cool Off Time** (default: `60` seconds): Time to wait in the red state before transitioning to yellow.
67
- 3. **Window Size** (default: `nil`): Time window in which failures are counted toward the threshold. By default, all failures are counted.
65
+ 1. **Window Size** (default: `nil`): Time window in which errors are counted toward the threshold. By default, all errors are counted.
66
+ 2. **Threshold** (default: `3`): Number of errors required to transition from green to red.
67
+
68
+ Additionally, two other parameters control how Stoplight behaves after it turns red:
69
+
70
+ 1. **Cool Off Time** (default: `60` seconds): Time to wait in the red state before transitioning to yellow.
71
+ 2. **Recovery Threshold** (default: `1`): Number of successful attempts required to transition from yellow back to green.
68
72
 
69
73
  ## Basic Usage
70
74
 
@@ -78,7 +82,7 @@ light = Stoplight("Payment Service")
78
82
  result = light.run { payment_gateway.process(order) }
79
83
  ```
80
84
 
81
- When everything works, the light stays green and your code runs normally. If the code fails repeatedly, the
85
+ When everything works, the light stays green and your code runs normally. If the code fails repeatedly, the
82
86
  light turns red and raises a `Stoplight::Error::RedLight` exception to prevent further calls.
83
87
 
84
88
  ```ruby
@@ -112,7 +116,7 @@ light.color #=> "green"
112
116
 
113
117
  ### Using Fallbacks
114
118
 
115
- Provide fallbacks to gracefully handle failures:
119
+ Provide fallbacks to gracefully handle errors:
116
120
 
117
121
  ```ruby
118
122
  fallback = ->(error) { error ? "Failed: #{error.message}" : "Service unavailable" }
@@ -121,7 +125,7 @@ light = Stoplight('example-fallback')
121
125
  result = light.run(fallback) { external_service.call }
122
126
  ```
123
127
 
124
- If the light is green but the call fails, the fallback receives the `error`. If the light is red, the fallback
128
+ If the light is green but the call fails, the fallback receives the `error`. If the light is red, the fallback
125
129
  receives `nil`. In both cases, the return value of the fallback becomes the return value of the `run` method.
126
130
 
127
131
  ## Admin Panel
@@ -172,8 +176,7 @@ docker run --net=host bolshakov/stoplight-admin
172
176
  docker run -e REDIS_URL=redis://localhost:6378 --net=host bolshakov/stoplight-admin
173
177
  ```
174
178
 
175
-
176
- ## Configuration
179
+ ## Configuration
177
180
 
178
181
  ### Global Configuration
179
182
 
@@ -182,9 +185,11 @@ Stoplight allows you to set default values for all lights in your application:
182
185
  ```ruby
183
186
  Stoplight.configure do |config|
184
187
  # Set default behavior for all stoplights
185
- config.threshold = 5
188
+ config.traffic_control = :error_rate
189
+ config.window_size = 300
190
+ config.threshold = 0.5
186
191
  config.cool_off_time = 30
187
- config.window_size = 60
192
+ config.recovery_threshold = 5
188
193
 
189
194
  # Set up default data store and notifiers
190
195
  config.data_store = Stoplight::DataStore::Redis.new(redis)
@@ -209,10 +214,11 @@ You can also provide settings during creation:
209
214
  ```ruby
210
215
  data_store = Stoplight::DataStore::Redis.new(Redis.new)
211
216
 
212
- light = Stoplight("Payment Service",
213
- threshold: 5, # 5 failures before turning red
217
+ light = Stoplight("Payment Service",
218
+ window_size: 300, # Only count errors in the last five minutes
219
+ threshold: 5, # 5 errors before turning red
214
220
  cool_off_time: 60, # Wait 60 seconds before attempting recovery
215
- window_size: 300, # Only count failures in the last five minutes
221
+ recovery_threshold: 1, # 1 successful attempt to turn green again
216
222
  data_store: data_store, # Use Redis for persistence
217
223
  tracked_errors: [TimeoutError], # Only count TimeoutError
218
224
  skipped_errors: [ValidationError] # Ignore ValidationError
@@ -233,7 +239,7 @@ users_api = base_api.with(
233
239
  )
234
240
  ```
235
241
 
236
- The `#with` method creates a new stoplight instance without modifying the original, making it ideal for creating
242
+ The `#with` method creates a new stoplight instance without modifying the original, making it ideal for creating
237
243
  specialized stoplights from a common configuration.
238
244
 
239
245
  ## Error Handling
@@ -259,6 +265,117 @@ When both methods are used, `skipped_errors` takes precedence over `tracked_erro
259
265
 
260
266
  ## Advanced Configuration
261
267
 
268
+ ### Traffic Control Strategies
269
+
270
+ You've seen how Stoplight transitions from green to red when errors reach the threshold. But **how exactly does it
271
+ decide when that threshold is reached?** That's where traffic control strategies come in.
272
+
273
+ Stoplight offers two built-in strategies for counting errors:
274
+
275
+ #### Consecutive Errors (Default)
276
+
277
+ Stops traffic when a specified number of consecutive errors occur. Works with or without time sliding windows.
278
+
279
+ ```ruby
280
+ light = Stoplight(
281
+ "Payment API",
282
+ traffic_control: :consecutive_errors,
283
+ threshold: 5,
284
+ )
285
+ ```
286
+
287
+ Counts consecutive errors regardless of when they occurred. Once 5 consecutive errors happen, the stoplight
288
+ turns red and stops traffic.
289
+
290
+ ```ruby
291
+ light = Stoplight(
292
+ "Payment API",
293
+ traffic_control: :consecutive_errors,
294
+ threshold: 5,
295
+ window_size: 300,
296
+ )
297
+ ```
298
+
299
+ Counts consecutive errors within a 5-minute sliding window. Both conditions must be met: 5 consecutive errors
300
+ AND at least 5 total errors within the window.
301
+
302
+ _This is Stoplight's default strategy when no `traffic_control` is specified._ You can omit `traffic_control` parameter
303
+ in the above examples:
304
+
305
+ ```ruby
306
+ light = Stoplight(
307
+ "Payment API",
308
+ threshold: 5,
309
+ )
310
+ ```
311
+
312
+ #### Error Rate
313
+
314
+ Stops traffic when the error rate exceeds a percentage within a sliding time window. Requires `window_size` to be
315
+ configured:
316
+
317
+
318
+ ```ruby
319
+ light = Stoplight(
320
+ "Payment API",
321
+ traffic_control: :error_rate,
322
+ window_size: 300,
323
+ threshold: 0.5,
324
+ )
325
+ ```
326
+
327
+ Monitors error rate over a 5-minute sliding window. The stoplight turns red when error rate exceeds 50%.
328
+
329
+ ```ruby
330
+ light = Stoplight(
331
+ "Payment API",
332
+ traffic_control: {
333
+ error_rate: { min_requests: 20 },
334
+ },
335
+ window_size: 300,
336
+ threshold: 0.5,
337
+ )
338
+ ```
339
+
340
+ Only evaluates error rate after at least 20 requests within the window. Default `min_requests` is 10.
341
+
342
+
343
+ #### When to use:
344
+
345
+ * **Consecutive Errors**: Low-medium traffic, simple behavior, occasional spikes expected
346
+ * **Error Rate**: High traffic, percentage-based SLAs, variable traffic patterns
347
+
348
+ ### Traffic Recovery Strategies
349
+
350
+ In the yellow state, Stoplight behaves differently from normal (green) operation. Instead of
351
+ blocking all traffic, it allows a limited number of real requests to pass through to
352
+ the underlying service to determine if it has recovered. These aren't synthetic probes -
353
+ they're actual user requests that will execute normally if the service is healthy.
354
+
355
+ After collecting the necessary data from these requests, Stoplight decides whether to
356
+ return to green or red state.
357
+
358
+ Traffic Recovery strategies control how Stoplight evaluates these requests during
359
+ the recovery phase.
360
+
361
+ #### Consecutive Successes (Default)
362
+
363
+ Returns to green after a specified number of consecutive successful recovery attempts. This is the default behavior.
364
+
365
+ ```ruby
366
+ light = Stoplight(
367
+ "Payment API",
368
+ traffic_recovery: :consecutive_successes,
369
+ recovery_threshold: 3,
370
+ )
371
+ ```
372
+
373
+ This configuration requires 3 consecutive successful recovery probes before resuming normal traffic. If any probe
374
+ fails during recovery, the stoplight immediately returns to red and waits for another cool-off period before trying again.
375
+
376
+ **Default behavior**: If no `recovery_threshold` is specified, Stoplight uses a conservative default of 1, meaning a
377
+ single successful recovery probe will resume traffic flow.
378
+
262
379
  ### Data Store
263
380
 
264
381
  Stoplight uses an in-memory data store out of the box:
@@ -284,7 +401,7 @@ end
284
401
 
285
402
  #### Connection Pooling with Redis
286
403
 
287
- For high-traffic applications or when you want to control a number of opened connections to Redis:
404
+ For high-traffic applications or when you want to control a number of opened connections to Redis:
288
405
 
289
406
  ```ruby
290
407
  require "connection_pool"
@@ -329,7 +446,7 @@ the [notifier interface documentation] for detailed instructions. Pull requests
329
446
 
330
447
  ### Error Notifiers
331
448
 
332
- Stoplight is built for resilience. If the Redis data store fails, Stoplight automatically falls back to the in-memory
449
+ Stoplight is built for resilience. If the Redis data store fails, Stoplight automatically falls back to the in-memory
333
450
  data store. To get notified about such errors, you can configure an error notifier:
334
451
 
335
452
  ```ruby
@@ -340,7 +457,7 @@ end
340
457
 
341
458
  ### Locking
342
459
 
343
- Sometimes you need to override Stoplight's automatic behavior. Locking allows you to manually control the state of
460
+ Sometimes you need to override Stoplight's automatic behavior. Locking allows you to manually control the state of
344
461
  a stoplight, which is useful for:
345
462
 
346
463
  * **Maintenance periods**: Lock to red when a service is known to be unavailable
@@ -424,7 +541,7 @@ stoplight = Stoplight("test-#{rand}")
424
541
  ## Maintenance Policy
425
542
 
426
543
  Stoplight supports the latest three minor versions of Ruby, which currently are: `3.2.x`, `3.3.x`, and `3.4.x`. Changing
427
- the minimum supported Ruby version is not considered a breaking change. We support the current stable Redis
544
+ the minimum supported Ruby version is not considered a breaking change. We support the current stable Redis
428
545
  version (`7.4.x`) and the latest release of the previous major version (`6.2.x`)
429
546
 
430
547
  ## Development
@@ -25,7 +25,7 @@ module Stoplight
25
25
  helpers Helpers
26
26
 
27
27
  set :protection, except: %i[json_csrf]
28
- set :data_store, proc { Stoplight.config_provider.data_store }
28
+ set :data_store, proc { Stoplight.default_config.data_store }
29
29
  set :views, File.join(__dir__, "admin", "views")
30
30
 
31
31
  get "/" do
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Config
5
+ # The +CompatibilityResult+ class represents the result of a compatibility check
6
+ # for a strategy. It provides methods to determine if the strategy is compatible
7
+ # and to retrieve error messages when it is not.
8
+ class CompatibilityResult
9
+ class << self
10
+ # Creates a new +CompatibilityResult+ instance representing a compatible strategy.
11
+ #
12
+ # @return [CompatibilityResult] An instance with no errors.
13
+ def compatible
14
+ new(errors: [])
15
+ end
16
+
17
+ # Creates a new +CompatibilityResult+ instance representing an incompatible strategy.
18
+ #
19
+ # @param errors [Array<String>] List of error messages indicating incompatibility.
20
+ # @return [CompatibilityResult] An instance with the provided errors.
21
+ def incompatible(*errors)
22
+ new(errors:)
23
+ end
24
+ end
25
+
26
+ # Initializes a new `CompatibilityResult` instance.
27
+ # @param errors [Array<String>] List of error messages if the strategy is not compatible.
28
+ def initialize(errors: [])
29
+ @errors = errors.freeze
30
+ end
31
+
32
+ # Checks if the strategy is compatible.
33
+ # @return [Boolean] `true` if there are no errors, `false` otherwise.
34
+ def compatible?
35
+ @errors.empty?
36
+ end
37
+
38
+ def incompatible? = !compatible?
39
+
40
+ # Retrieves the list of error messages.
41
+ # @return [Array<String>] The list of error messages.
42
+ attr_reader :errors
43
+
44
+ # Retrieves a concatenated error message string.
45
+ # @return [String, nil] A string containing all error messages joined by "; ",
46
+ # or `nil` if the strategy is compatible.
47
+ def error_messages
48
+ unless compatible?
49
+ @errors.join("; ")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Config
5
+ # This is a DSL for configuring Stoplight settings. It is responsible for
6
+ # transforming the provided settings into a format that can be used by Stoplight.
7
+ #
8
+ # @api private
9
+ class DSL
10
+ def transform(settings)
11
+ if settings.has_key?(:data_store)
12
+ settings[:data_store] = build_data_store(settings[:data_store])
13
+ end
14
+
15
+ if settings.has_key?(:notifiers)
16
+ settings[:notifiers] = build_notifiers(settings[:notifiers])
17
+ end
18
+
19
+ if settings.has_key?(:tracked_errors)
20
+ settings[:tracked_errors] = build_tracked_errors(settings[:tracked_errors])
21
+ end
22
+
23
+ if settings.has_key?(:skipped_errors)
24
+ settings[:skipped_errors] = build_skipped_errors(settings[:skipped_errors])
25
+ end
26
+
27
+ if settings.has_key?(:cool_off_time)
28
+ settings[:cool_off_time] = build_cool_off_time(settings[:cool_off_time])
29
+ end
30
+
31
+ if settings.has_key?(:traffic_control)
32
+ settings[:traffic_control] = build_traffic_control(settings[:traffic_control])
33
+ end
34
+
35
+ if settings.has_key?(:traffic_recovery)
36
+ settings[:traffic_recovery] = build_traffic_recovery(settings[:traffic_recovery])
37
+ end
38
+ settings
39
+ end
40
+
41
+ private
42
+
43
+ def build_data_store(data_store)
44
+ DataStore::FailSafe.wrap(data_store)
45
+ end
46
+
47
+ def build_notifiers(notifiers)
48
+ notifiers.map { |notifier| Notifier::FailSafe.wrap(notifier) }
49
+ end
50
+
51
+ def build_tracked_errors(tracked_error)
52
+ Array(tracked_error)
53
+ end
54
+
55
+ def build_skipped_errors(skipped_errors)
56
+ Array(skipped_errors)
57
+ end
58
+
59
+ def build_cool_off_time(cool_off_time)
60
+ cool_off_time.to_i
61
+ end
62
+
63
+ def build_traffic_control(traffic_control)
64
+ case traffic_control
65
+ in Stoplight::TrafficControl::Base
66
+ traffic_control
67
+ in :consecutive_errors
68
+ Stoplight::TrafficControl::ConsecutiveErrors.new
69
+ in :error_rate
70
+ Stoplight::TrafficControl::ErrorRate.new
71
+ in {error_rate: error_rate_settings}
72
+ Stoplight::TrafficControl::ErrorRate.new(**error_rate_settings)
73
+ else
74
+ raise Error::ConfigurationError, <<~ERROR
75
+ unsupported traffic_control strategy provided (`#{traffic_control}`). Supported options:
76
+ * Stoplight::TrafficControl::ConsecutiveErrors
77
+ * Stoplight::TrafficControl::ErrorRate
78
+ ERROR
79
+ end
80
+ end
81
+
82
+ def build_traffic_recovery(traffic_recovery)
83
+ case traffic_recovery
84
+ in Stoplight::TrafficRecovery::Base
85
+ traffic_recovery
86
+ in :consecutive_successes
87
+ Stoplight::TrafficRecovery::ConsecutiveSuccesses.new
88
+ else
89
+ raise Error::ConfigurationError, <<~ERROR
90
+ unsupported traffic_recovery strategy provided (`#{traffic_recovery}`). Supported options:
91
+ * Stoplight::TrafficRecovery::ConsecutiveSuccesses
92
+ ERROR
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -4,26 +4,18 @@ module Stoplight
4
4
  module Config
5
5
  # Provides default settings for the Stoplight library.
6
6
  # @api private
7
- class LibraryDefaultConfig
8
- DEFAULT_SETTINGS = {
9
- cool_off_time: Stoplight::Default::COOL_OFF_TIME,
10
- data_store: Stoplight::Default::DATA_STORE,
11
- error_notifier: Stoplight::Default::ERROR_NOTIFIER,
12
- notifiers: Stoplight::Default::NOTIFIERS,
13
- threshold: Stoplight::Default::THRESHOLD,
14
- window_size: Stoplight::Default::WINDOW_SIZE,
15
- tracked_errors: Stoplight::Default::TRACKED_ERRORS,
16
- skipped_errors: Stoplight::Default::SKIPPED_ERRORS,
17
- traffic_control: Stoplight::Default::TRAFFIC_CONTROL,
18
- traffic_recovery: Stoplight::Default::TRAFFIC_RECOVERY
19
- }.freeze
20
- private_constant :DEFAULT_SETTINGS
21
-
22
- # Returns library default settings.
23
- # @return [Hash]
24
- def to_h
25
- DEFAULT_SETTINGS
26
- end
27
- end
7
+ LibraryDefaultConfig = Light::Config.empty.with(
8
+ cool_off_time: Stoplight::Default::COOL_OFF_TIME,
9
+ data_store: Stoplight::Default::DATA_STORE,
10
+ error_notifier: Stoplight::Default::ERROR_NOTIFIER,
11
+ notifiers: Stoplight::Default::NOTIFIERS,
12
+ threshold: Stoplight::Default::THRESHOLD,
13
+ recovery_threshold: Stoplight::Default::RECOVERY_THRESHOLD,
14
+ window_size: Stoplight::Default::WINDOW_SIZE,
15
+ tracked_errors: Stoplight::Default::TRACKED_ERRORS,
16
+ skipped_errors: Stoplight::Default::SKIPPED_ERRORS,
17
+ traffic_control: Stoplight::Default::TRAFFIC_CONTROL,
18
+ traffic_recovery: Stoplight::Default::TRAFFIC_RECOVERY
19
+ )
28
20
  end
29
21
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Config
5
+ SystemConfig = LibraryDefaultConfig
6
+ end
7
+ end
@@ -25,9 +25,13 @@ module Stoplight
25
25
  attr_accessor :notifiers
26
26
 
27
27
  # @!attribute [w] threshold
28
- # @return [Integer, nil] The default failure threshold to trip the circuit breaker.
28
+ # @return [Integer, Float, nil] The default failure threshold to trip the circuit breaker.
29
29
  attr_writer :threshold
30
30
 
31
+ # @!attribute [w] recovery_threshold
32
+ # @return [Integer, nil] The default recovery threshold for the circuit breaker.
33
+ attr_writer :recovery_threshold
34
+
31
35
  # @!attribute [w] window_size
32
36
  # @return [Integer, nil] The default size of the rolling window for failure tracking.
33
37
  attr_writer :window_size
@@ -44,6 +48,10 @@ module Stoplight
44
48
  # @return [Stoplight::DataStore::Base] The default data store instance.
45
49
  attr_writer :data_store
46
50
 
51
+ # @!attribute [w] traffic_control
52
+ # @return [Stoplight::TrafficControl::Base, Symbol, Hash] The traffic control strategy.
53
+ attr_writer :traffic_control
54
+
47
55
  def initialize
48
56
  # This allows users appending notifiers to the default list,
49
57
  # while still allowing them to override the default list.
@@ -61,9 +69,11 @@ module Stoplight
61
69
  error_notifier: @error_notifier,
62
70
  notifiers: @notifiers,
63
71
  threshold: @threshold,
72
+ recovery_threshold: @recovery_threshold,
64
73
  window_size: @window_size,
65
74
  tracked_errors: @tracked_errors,
66
- skipped_errors: @skipped_errors
75
+ skipped_errors: @skipped_errors,
76
+ traffic_control: @traffic_control
67
77
  }.compact
68
78
  end
69
79
 
@@ -33,6 +33,12 @@ module Stoplight
33
33
  # @param data_store [Stoplight::DataStore::Base]
34
34
  def initialize(data_store)
35
35
  @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
+ )
36
42
  end
37
43
 
38
44
  def names
@@ -98,10 +104,9 @@ module Stoplight
98
104
  circuit_breaker.run(fallback, &code)
99
105
  end
100
106
 
101
- # @!attribute [r] circuit_breaker
102
- # @return [Stoplight] The circuit breaker used to handle failures.
107
+ # @return [Stoplight::Light] The circuit breaker used to handle failures.
103
108
  private def circuit_breaker
104
- @circuit_breaker ||= Stoplight("stoplight:data_store:fail_safe:#{data_store.class.name}", data_store: Default::DATA_STORE)
109
+ @circuit_breaker ||= Stoplight.system_light("stoplight:data_store:fail_safe:#{data_store.class.name}")
105
110
  end
106
111
  end
107
112
  end
@@ -202,6 +202,11 @@ module Stoplight
202
202
  state
203
203
  end
204
204
 
205
+ # @return [String]
206
+ def inspect
207
+ "#<#{self.class.name}>"
208
+ end
209
+
205
210
  # Combined method that performs the state transition based on color
206
211
  #
207
212
  # @param config [Stoplight::Light::Config] The light configuration
@@ -228,6 +228,10 @@ module Stoplight
228
228
  state
229
229
  end
230
230
 
231
+ def inspect
232
+ "#<#{self.class.name} redis=#{@redis.inspect}>"
233
+ end
234
+
231
235
  # Combined method that performs the state transition based on color
232
236
  #
233
237
  # @param config [Stoplight::Light::Config] The light configuration
@@ -17,13 +17,14 @@ module Stoplight
17
17
  NOTIFIERS = [Notifier::IO.new($stderr)].freeze
18
18
 
19
19
  THRESHOLD = 3
20
+ RECOVERY_THRESHOLD = 1
20
21
 
21
22
  WINDOW_SIZE = nil
22
23
 
23
24
  TRACKED_ERRORS = [StandardError].freeze
24
25
  SKIPPED_ERRORS = [].freeze
25
26
 
26
- TRAFFIC_CONTROL = TrafficControl::ConsecutiveFailures.new
27
- TRAFFIC_RECOVERY = TrafficRecovery::SingleSuccess.new
27
+ TRAFFIC_CONTROL = TrafficControl::ConsecutiveErrors.new
28
+ TRAFFIC_RECOVERY = TrafficRecovery::ConsecutiveSuccesses.new
28
29
  end
29
30
  end
@@ -44,12 +44,21 @@ module Stoplight
44
44
  :error_notifier,
45
45
  :notifiers,
46
46
  :threshold,
47
+ :recovery_threshold,
47
48
  :window_size,
48
49
  :tracked_errors,
49
50
  :skipped_errors,
50
51
  :traffic_control,
51
52
  :traffic_recovery
52
53
  ) do
54
+ class << self
55
+ # Creates a new NULL configuration object.
56
+ # @return [Stoplight::Light::Config]
57
+ def empty
58
+ new(**members.map { |key| [key, nil] }.to_h)
59
+ end
60
+ end
61
+
53
62
  # Checks if the given error should be tracked
54
63
  #
55
64
  # @param error [#==] The error to check, e.g. an Exception, Class or Proc
@@ -60,6 +69,43 @@ module Stoplight
60
69
 
61
70
  !skip && track
62
71
  end
72
+
73
+ # This method applies configuration dsl and revalidates the configuration
74
+ # @return [Stoplight::Light::Config]
75
+ def with(**settings)
76
+ super(**CONFIG_DSL.transform(settings)).then do |config|
77
+ config.validate_config!
78
+ end
79
+ end
80
+
81
+ # @raise [Stoplight::Error::ConfigurationError]
82
+ # @return [Stoplight::Light::Config] The validated configuration object.
83
+ def validate_config!
84
+ validate_traffic_control_compatibility!
85
+ self
86
+ end
87
+
88
+ private
89
+
90
+ def validate_traffic_control_compatibility!
91
+ traffic_control.check_compatibility(self).then do |compatibility_result|
92
+ if compatibility_result.incompatible?
93
+ raise Stoplight::Error::ConfigurationError.new(
94
+ "#{traffic_control.class.name} strategy is incompatible with the Stoplight configuration: #{compatibility_result.error_messages}"
95
+ )
96
+ end
97
+ end
98
+ end
99
+
100
+ def validate_traffic_recovery_compatibility!
101
+ traffic_recovery.check_compatibility(self).then do |compatibility_result|
102
+ if compatibility_result.incompatible?
103
+ raise Stoplight::Error::ConfigurationError.new(
104
+ "#{traffic_control.class.name} strategy is incompatible with the Stoplight configuration: #{compatibility_result.error_messages}"
105
+ )
106
+ end
107
+ end
108
+ end
63
109
  end
64
110
  end
65
111
  end
@@ -150,9 +150,7 @@ module Stoplight
150
150
  # payment_light.run(->(error) { nil }) { call_payment_api }
151
151
  # @see +Stoplight()+
152
152
  def with(**settings)
153
- reconfigure(
154
- Stoplight.config_provider.from_prototype(config, settings)
155
- )
153
+ reconfigure(config.with(**settings))
156
154
  end
157
155
 
158
156
  private
@@ -67,5 +67,21 @@ module Stoplight
67
67
  Color::GREEN
68
68
  end
69
69
  end
70
+
71
+ # Calculates the error rate based on the number of successes and errors.
72
+ #
73
+ # @return [Float]
74
+ def error_rate
75
+ if successes.nil? || errors.nil? || (successes + errors).zero?
76
+ 0.0
77
+ else
78
+ errors.fdiv(successes + errors)
79
+ end
80
+ end
81
+
82
+ # @return [Integer]
83
+ def requests
84
+ successes + errors
85
+ end
70
86
  end
71
87
  end
@@ -58,9 +58,12 @@ module Stoplight
58
58
  other.is_a?(FailSafe) && notifier == other.notifier
59
59
  end
60
60
 
61
- # @return [Stoplight] The circuit breaker used to handle failures.
61
+ # @return [Stoplight::Light] The circuit breaker used to handle failures.
62
62
  private def circuit_breaker
63
- @circuit_breaker ||= Stoplight("stoplight:notifier:fail_safe:#{notifier.class.name}", data_store: Default::DATA_STORE, notifiers: [])
63
+ @circuit_breaker ||= Stoplight.system_light(
64
+ "stoplight:notifier:fail_safe:#{notifier.class.name}",
65
+ notifiers: []
66
+ )
64
67
  end
65
68
  end
66
69
  end
@@ -9,6 +9,14 @@ module Stoplight
9
9
  #
10
10
  # @example Creating a custom strategy
11
11
  # class ErrorRateStrategy < Stoplight::TrafficControl::Base
12
+ # def check_compatibility(config)
13
+ # if config.window_size.nil?
14
+ # incompatible("`window_size` should be set")
15
+ # else
16
+ # compatible
17
+ # end
18
+ # end
19
+ #
12
20
  # def stop_traffic?(config, metadata)
13
21
  # total = metadata.successes + metadata.failures
14
22
  # return false if total < 10 # Minimum sample size
@@ -21,6 +29,16 @@ module Stoplight
21
29
  # @abstract
22
30
  # @api private
23
31
  class Base
32
+ # Checks if the strategy is compatible with the given Stoplight configuration.
33
+ #
34
+ # @param config [Stoplight::Light::Config]
35
+ # @return [Stoplight::Config::CompatibilityResult]
36
+ # :nocov:
37
+ def check_compatibility(config)
38
+ raise NotImplementedError
39
+ end
40
+ # :nocov:
41
+
24
42
  # Determines whether traffic should be stopped based on the Stoplight's
25
43
  # current state and metrics.
26
44
  #
@@ -36,6 +54,17 @@ module Stoplight
36
54
  def ==(other)
37
55
  other.is_a?(self.class)
38
56
  end
57
+
58
+ # Returns a compatibility result indicating the strategy is compatible.
59
+ #
60
+ # @return [Stoplight::Config::CompatibilityResult] A compatible result.
61
+ private def compatible = Config::CompatibilityResult.compatible
62
+
63
+ # Returns a compatibility result indicating the strategy is incompatible.
64
+ #
65
+ # @param errors [Array<String>] The list of error messages describing incompatibility.
66
+ # @return [Stoplight::Config::CompatibilityResult] An incompatible result.
67
+ private def incompatible(*errors) = Config::CompatibilityResult.incompatible(*errors)
39
68
  end
40
69
  end
41
70
  end
@@ -14,18 +14,30 @@ module Stoplight
14
14
  # reach the threshold.
15
15
  #
16
16
  # @example With window-based configuration
17
- # config = Stoplight::Light::Config.new(threshold: 5, window_size: 60)
18
- # strategy = Stoplight::TrafficControlStrategy::ConsecutiveFailures.new
17
+ # traffic_control = Stoplight::TrafficControlStrategy::ConsecutiveErrors.new
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
- # config = Stoplight::Light::Config.new(threshold: 5, window_size: nil)
24
- # strategy = Stoplight::TrafficControlStrategy::ConsecutiveFailures.new
23
+ # traffic_control = Stoplight::TrafficControlStrategy::ConsecutiveErrors.new
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
27
27
  # @api private
28
- class ConsecutiveFailures < Base
28
+ class ConsecutiveErrors < Base
29
+ # @param config [Stoplight::Light::Config]
30
+ # @return [Stoplight::Config::CompatibilityResult]
31
+ def check_compatibility(config)
32
+ if config.threshold <= 0
33
+ incompatible("`threshold` should be bigger than 0")
34
+ elsif !config.threshold.is_a?(Integer)
35
+ incompatible("`threshold` should be an integer")
36
+ else
37
+ compatible
38
+ end
39
+ end
40
+
29
41
  # Determines if traffic should be stopped based on failure counts.
30
42
  #
31
43
  # @param config [Stoplight::Light::Config]
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module TrafficControl
5
+ # A strategy that stops the traffic based on error rate.
6
+ #
7
+ # @example
8
+ # traffic_control = Stoplight::TrafficControlStrategy::ErrorRate.new
9
+ # config = Stoplight::Light::Config.new(threshold: 0.6, window_size: 300, traffic_control:)
10
+ #
11
+ # Will switch to red if 60% error rate reached within the 5-minute (300 seconds) sliding window.
12
+ # By default this traffic control strategy starts evaluating only after 10 requests have been made. You can
13
+ # adjust this by passing a different value for `min_requests` when initializing the strategy.
14
+ #
15
+ # traffic_control = Stoplight::TrafficControlStrategy::ErrorRate.new(min_requests: 100)
16
+ #
17
+ # @api private
18
+ class ErrorRate < Base
19
+ # @!attribute min_requests
20
+ # @return [Integer]
21
+ attr_reader :min_requests
22
+
23
+ # @param min_requests [Integer] Minimum number of requests before traffic control is applied.
24
+ # until this number of requests is reached, the error rate will not be considered.
25
+ def initialize(min_requests: 10)
26
+ @min_requests = min_requests
27
+ end
28
+
29
+ # @param config [Stoplight::Light::Config]
30
+ # @return [Stoplight::Config::CompatibilityResult]
31
+ def check_compatibility(config)
32
+ if config.window_size.nil?
33
+ incompatible("`window_size` should be set")
34
+ elsif config.threshold < 0 || config.threshold > 1
35
+ incompatible("`threshold` should be between 0 and 1")
36
+ else
37
+ compatible
38
+ end
39
+ end
40
+
41
+ # @param config [Stoplight::Light::Config]
42
+ # @param metadata [Stoplight::Metadata]
43
+ # @return [Boolean]
44
+ def stop_traffic?(config, metadata)
45
+ metadata.requests >= min_requests && metadata.error_rate >= config.threshold
46
+ end
47
+ end
48
+ end
49
+ end
@@ -34,6 +34,16 @@ module Stoplight
34
34
  # @abstract
35
35
  # @api private
36
36
  class Base
37
+ # Checks if the strategy is compatible with the given Stoplight configuration.
38
+ #
39
+ # @param config [Stoplight::Light::Config]
40
+ # @return [Stoplight::Config::CompatibilityResult]
41
+ # :nocov:
42
+ def check_compatibility(config)
43
+ raise NotImplementedError
44
+ end
45
+ # :nocov:
46
+
37
47
  # Determines the appropriate recovery state based on the Stoplight's
38
48
  # current metrics and recovery progress.
39
49
  #
@@ -46,6 +56,23 @@ module Stoplight
46
56
  def determine_color(config, metadata)
47
57
  raise NotImplementedError
48
58
  end
59
+
60
+ # @param other [any]
61
+ # @return [Boolean]
62
+ def ==(other)
63
+ other.is_a?(self.class)
64
+ end
65
+
66
+ # Returns a compatibility result indicating the strategy is compatible.
67
+ #
68
+ # @return [Stoplight::Config::CompatibilityResult] A compatible result.
69
+ private def compatible = Config::CompatibilityResult.compatible
70
+
71
+ # Returns a compatibility result indicating the strategy is incompatible.
72
+ #
73
+ # @param errors [Array<String>] The list of error messages describing incompatibility.
74
+ # @return [Stoplight::Config::CompatibilityResult] An incompatible result.
75
+ private def incompatible(*errors) = Config::CompatibilityResult.incompatible(*errors)
49
76
  end
50
77
  end
51
78
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module TrafficRecovery
5
+ # A conservative strategy that requires multiple consecutive successful probes
6
+ # before resuming traffic flow.
7
+ #
8
+ # The strategy immediately returns to RED state if any failure occurs during
9
+ # the recovery process, ensuring that only truly stable services resume
10
+ # full traffic flow.
11
+ #
12
+ # @example Basic usage with 3 consecutive successes required
13
+ # config = Stoplight::Light::Config.new(
14
+ # cool_off_time: 60,
15
+ # recovery_threshold: 3
16
+ # )
17
+ # strategy = Stoplight::TrafficRecovery::ConsecutiveSuccesses.new
18
+ #
19
+ # Recovery behavior:
20
+ # - After cool-off period, Stoplight enters YELLOW (recovery) state
21
+ # - Requires 3 consecutive successful probes to transition to GREEN
22
+ # - Any failure during recovery immediately returns to RED state
23
+ # - Process repeats after another cool-off period
24
+ #
25
+ # Configuration requirements:
26
+ # - `recovery_threshold`: Integer > 0, specifies required consecutive successes
27
+ #
28
+ # Failure behavior:
29
+ # Unlike some circuit breaker implementations that tolerate occasional failures
30
+ # during recovery, this strategy takes a zero-tolerance approach: any failure
31
+ # during the recovery phase immediately transitions back to RED state. This
32
+ # conservative approach prioritizes stability over recovery speed.
33
+ #
34
+ # @api private
35
+ class ConsecutiveSuccesses < Base
36
+ # @param config [Stoplight::Light::Config]
37
+ # @return [Stoplight::Config::CompatibilityResult]
38
+ def check_compatibility(config)
39
+ if config.recovery_threshold <= 0
40
+ incompatible("`recovery_threshold` should be bigger than 0")
41
+ elsif !config.recovery_threshold.is_a?(Integer)
42
+ incompatible("`recovery_threshold` should be an integer")
43
+ else
44
+ compatible
45
+ end
46
+ end
47
+
48
+ # Determines if traffic should be resumed based on successes counts.
49
+ #
50
+ # @param config [Stoplight::Light::Config]
51
+ # @param metadata [Stoplight::Metadata]
52
+ # @return [String]
53
+ def determine_color(config, metadata)
54
+ recovery_started_at = metadata.recovery_started_at || metadata.recovery_scheduled_after
55
+
56
+ if metadata.last_error_at && metadata.last_error_at >= recovery_started_at
57
+ Color::RED
58
+ elsif [metadata.consecutive_successes, metadata.recovery_probe_successes].min >= config.recovery_threshold
59
+ Color::GREEN
60
+ else
61
+ Color::YELLOW
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stoplight
4
- VERSION = Gem::Version.new("5.1.0")
4
+ VERSION = Gem::Version.new("5.2.0")
5
5
  end
data/lib/stoplight.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "zeitwerk"
4
4
 
5
5
  loader = Zeitwerk::Loader.for_gem
6
- loader.inflector.inflect("io" => "IO")
6
+ loader.inflector.inflect("io" => "IO", "dsl" => "DSL")
7
7
  loader.do_not_eager_load(
8
8
  "#{__dir__}/stoplight/data_store",
9
9
  "#{__dir__}/stoplight/admin",
@@ -14,6 +14,9 @@ loader.ignore("#{__dir__}/stoplight/rspec.rb", "#{__dir__}/stoplight/rspec")
14
14
  loader.setup
15
15
 
16
16
  module Stoplight # rubocop:disable Style/Documentation
17
+ CONFIG_DSL = Config::DSL.new
18
+ private_constant :CONFIG_DSL
19
+
17
20
  CONFIG_MUTEX = Mutex.new
18
21
  private_constant :CONFIG_MUTEX
19
22
 
@@ -47,7 +50,7 @@ module Stoplight # rubocop:disable Style/Documentation
47
50
  # produces a warning:
48
51
  #
49
52
  # "Stoplight reconfigured. Existing circuit breakers will not see the new configuration. New
50
- # configuration: #<Stoplight::Config::ConfigProvider cool_off_time=32, threshold=3, window_size=94, tracked_errors=StandardError, skipped_errors=NoMemoryError,ScriptError,SecurityError,SignalException,SystemExit,SystemStackError, data_store=Stoplight::DataStore::Memory>\n"
53
+ # configuration: ...f
51
54
  #
52
55
  # If you really know what you are doing, you can pass the +trust_me_im_an_engineer+ parameter as +true+ to
53
56
  # suppress this warning, which could be useful in test environments.
@@ -56,28 +59,47 @@ module Stoplight # rubocop:disable Style/Documentation
56
59
  user_defaults = Config::UserDefaultConfig.new
57
60
  yield(user_defaults) if block_given?
58
61
 
59
- reconfigured = !@config_provider.nil?
62
+ reconfigured = !@default_config.nil?
60
63
 
61
- @config_provider = Config::ConfigProvider.new(
62
- user_default_config: user_defaults.freeze,
63
- library_default_config: Config::LibraryDefaultConfig.new
64
- ).tap do
64
+ @default_config = Config::LibraryDefaultConfig.with(**user_defaults.to_h).tap do
65
65
  if reconfigured && !trust_me_im_an_engineer
66
66
  warn(
67
67
  "Stoplight reconfigured. Existing circuit breakers will not see new configuration. " \
68
- "New configuration: #{@config_provider.inspect}"
68
+ "New configuration: #{@default_config.inspect}"
69
69
  )
70
70
  end
71
71
  end
72
72
  end
73
73
 
74
+ # Creates a Light for internal use.
75
+ #
76
+ # @param name [String]
77
+ # @param settings [Hash]
78
+ # @return [Stoplight::Light]
79
+ # @api private
80
+ def system_light(name, **settings)
81
+ config = Config::SystemConfig.with(name:, **settings)
82
+ Stoplight::Light.new(config)
83
+ end
84
+
85
+ # Create a Light with the user default configuration.
86
+ #
87
+ # @param name [String]
88
+ # @param settings [Hash]
89
+ # @return [Stoplight::Light]
90
+ # @api private
91
+ def light(name, **settings)
92
+ config = Stoplight.default_config.with(name:, **settings)
93
+ Stoplight::Light.new(config)
94
+ end
95
+
74
96
  # Retrieves the current configuration provider.
75
97
  #
76
- # @return [Stoplight::Config::ConfigProvider]
98
+ # @return [Stoplight::Light::Config]
77
99
  # @api private
78
- def config_provider
100
+ def default_config
79
101
  CONFIG_MUTEX.synchronize do
80
- @config_provider ||= configure
102
+ @default_config ||= configure
81
103
  end
82
104
  end
83
105
  end
@@ -95,6 +117,8 @@ end
95
117
  # @option settings [Numeric] :window_size The size of the rolling window for failure tracking.
96
118
  # @option settings [Array<StandardError>] :tracked_errors A list of errors to track.
97
119
  # @option settings [Array<Exception>] :skipped_errors A list of errors to skip.
120
+ # @option settings [Stoplight::TrafficControl::Base, Symbol, {Symbol, Hash{Symbol, any}}] :traffic_control The
121
+ # traffic control strategy to use.
98
122
  #
99
123
  # @return [Stoplight::Light] A new circuit breaker instance.
100
124
  # @raise [ArgumentError] If an unknown option is provided in the settings.
@@ -118,7 +142,14 @@ end
118
142
  # @example configure skipped errors
119
143
  # light = Stoplight("Payment API", skipped_errors: [ActiveRecord::RecordNotFound])
120
144
  #
145
+ # @example configure traffic control to trip using consecutive failures method
146
+ # # When 5 consecutive failures occur, the circuit breaker will trip.
147
+ # light = Stoplight("Payment API", traffic_control: :consecutive_errors, threshold: 5)
148
+ #
149
+ # @example configure traffic control to trip using error rate method
150
+ # # When 66.6% error rate reached withing a sliding 5 minute window, the circuit breaker will trip.
151
+ # light = Stoplight("Payment API", traffic_control: :error_rate, threshold: 0.666, window_size: 300)
152
+ #
121
153
  def Stoplight(name, **settings) # rubocop:disable Naming/MethodName
122
- config = Stoplight.config_provider.provide(name, settings)
123
- Stoplight::Light.new(config)
154
+ Stoplight.light(name, **settings)
124
155
  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.1.0
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cameron Desautels
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2025-06-27 00:00:00.000000000 Z
13
+ date: 2025-07-11 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: zeitwerk
@@ -59,8 +59,10 @@ files:
59
59
  - lib/stoplight/admin/views/index.erb
60
60
  - lib/stoplight/admin/views/layout.erb
61
61
  - lib/stoplight/color.rb
62
- - lib/stoplight/config/config_provider.rb
62
+ - lib/stoplight/config/compatibility_result.rb
63
+ - lib/stoplight/config/dsl.rb
63
64
  - lib/stoplight/config/library_default_config.rb
65
+ - lib/stoplight/config/system_config.rb
64
66
  - lib/stoplight/config/user_default_config.rb
65
67
  - lib/stoplight/data_store.rb
66
68
  - lib/stoplight/data_store/base.rb
@@ -94,9 +96,10 @@ files:
94
96
  - lib/stoplight/rspec/generic_notifier.rb
95
97
  - lib/stoplight/state.rb
96
98
  - lib/stoplight/traffic_control/base.rb
97
- - lib/stoplight/traffic_control/consecutive_failures.rb
99
+ - lib/stoplight/traffic_control/consecutive_errors.rb
100
+ - lib/stoplight/traffic_control/error_rate.rb
98
101
  - lib/stoplight/traffic_recovery/base.rb
99
- - lib/stoplight/traffic_recovery/single_success.rb
102
+ - lib/stoplight/traffic_recovery/consecutive_successes.rb
100
103
  - lib/stoplight/version.rb
101
104
  homepage: https://github.com/bolshakov/stoplight
102
105
  licenses:
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Stoplight
4
- module Config
5
- # Provides configuration for a Stoplight light by its name.
6
- #
7
- # It combines settings from three sources in the following order of precedence:
8
- # 1. **Settings Overrides**: Explicit settings passed as arguments to +#provide+ method.
9
- # 2. **User-level Default Settings**: Settings defined using the +Stoplight.configure+ method.
10
- # 4. **Library-Level Default Settings**: Default settings defined in the +Stoplight::Config::UserDefaultConfig+ module.
11
- #
12
- # The settings are merged in this order, with higher-precedence settings overriding lower-precedence ones.
13
- # After merging settings, the configuration transformations are applied such as wrapping data stores and notifiers
14
- # with fail-safe mechanism, type conversion, etc. Each transformation must be idempotent.
15
- #
16
- # @api private
17
- class ConfigProvider
18
- # @!attribute [r] default_settings
19
- # @return [Hash]
20
- private attr_reader :default_settings
21
-
22
- # @param user_default_config [Stoplight::Config::UserDefaultConfig]
23
- # @param library_default_config [Stoplight::Config::LibraryDefaultConfig]
24
- # @raise [Error::ConfigurationError] if both user_default_config and legacy_config are not empty
25
- def initialize(user_default_config:, library_default_config:)
26
- @default_settings = library_default_config.to_h.merge(
27
- user_default_config.to_h
28
- )
29
- end
30
-
31
- # @return [Stoplight::DataStore::Base]
32
- def data_store
33
- default_settings.fetch(:data_store)
34
- end
35
-
36
- # Returns a configuration for a specific light with the given name and settings overrides.
37
- #
38
- # @param light_name [Symbol, String] The name of the light.
39
- # @param settings_overrides [Hash] The settings to override.
40
- # @see +Stoplight()+
41
- # @return [Stoplight::Light::Config] The configuration for the specified light.
42
- # @raise [Error::ConfigurationError]
43
- def provide(light_name, settings_overrides = {})
44
- raise Error::ConfigurationError, <<~ERROR if settings_overrides.has_key?(:name)
45
- The +name+ setting cannot be overridden in the configuration.
46
- ERROR
47
-
48
- settings = default_settings.merge(settings_overrides, {name: light_name})
49
- Light::Config.new(**transform_settings(settings))
50
- end
51
-
52
- # Creates a configuration from a given +Stoplight::Light::Config+ object extending it
53
- # with additional settings overrides.
54
- #
55
- # @param config [Stoplight::Light::Config] The configuration object to extend.
56
- # @param settings_overrides [Hash] The settings to override.
57
- # @return [Stoplight::Light::Config] The new extended configuration object.
58
- def from_prototype(config, settings_overrides)
59
- config.to_h.then do |settings|
60
- current_name = settings.delete(:name)
61
- name = settings_overrides.delete(:name) || current_name
62
-
63
- provide(name, **settings.merge(settings_overrides))
64
- end
65
- end
66
-
67
- def inspect
68
- "#<#{self.class.name} " \
69
- "cool_off_time=#{default_settings[:cool_off_time]}, " \
70
- "threshold=#{default_settings[:threshold]}, " \
71
- "window_size=#{default_settings[:window_size]}, " \
72
- "tracked_errors=#{default_settings[:tracked_errors].join(",")}, " \
73
- "skipped_errors=#{default_settings[:skipped_errors].join(",")}, " \
74
- "data_store=#{default_settings[:data_store].class.name}" \
75
- ">"
76
- end
77
-
78
- private
79
-
80
- def transform_settings(settings)
81
- settings.merge(
82
- data_store: build_data_store(settings.fetch(:data_store)),
83
- notifiers: build_notifiers(settings.fetch(:notifiers)),
84
- tracked_errors: build_tracked_errors(settings.fetch(:tracked_errors)),
85
- skipped_errors: build_skipped_errors(settings.fetch(:skipped_errors)),
86
- cool_off_time: build_cool_off_time(settings.fetch(:cool_off_time))
87
- )
88
- end
89
-
90
- def build_data_store(data_store)
91
- DataStore::FailSafe.wrap(data_store)
92
- end
93
-
94
- def build_notifiers(notifiers)
95
- notifiers.map { |notifier| Notifier::FailSafe.wrap(notifier) }
96
- end
97
-
98
- def build_tracked_errors(tracked_error)
99
- Array(tracked_error)
100
- end
101
-
102
- def build_skipped_errors(skipped_errors)
103
- Array(skipped_errors)
104
- end
105
-
106
- def build_cool_off_time(cool_off_time)
107
- cool_off_time.to_i
108
- end
109
- end
110
- end
111
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Stoplight
4
- module TrafficRecovery
5
- # A basic strategy that recovers traffic flow after a successful recovery probe.
6
- #
7
- # This strategy allows traffic to resume when a single successful probe
8
- # occurs after the Stoplight has been in red state. It's a simple "one success and we're back"
9
- # approach.
10
- #
11
- # @example Basic usage
12
- # config = Stoplight::Light::Config.new(cool_off_time: 60)
13
- # strategy = Stoplight::TrafficRecovery::SingleSuccess.new
14
- #
15
- # After the Stoplight turns red:
16
- # - The Stoplight will wait for the cool-off period (60 seconds)
17
- # - Then enter the recovery phase (YELLOW color)
18
- # - The first successful probe will resume normal traffic flow (green color)
19
- # @api private
20
- class SingleSuccess < Base
21
- # @param config [Stoplight::Light::Config]
22
- # @param metadata [Stoplight::Metadata]
23
- # @return [String]
24
- def determine_color(config, metadata)
25
- recovery_started_at = metadata.recovery_started_at || metadata.recovery_scheduled_after
26
- last_success_at = metadata.last_success_at
27
- if last_success_at && recovery_started_at <= last_success_at
28
- Color::GREEN
29
- else
30
- Color::RED
31
- end
32
- end
33
- end
34
- end
35
- end