stoplight 5.0.3 → 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.
@@ -20,14 +20,18 @@ module Stoplight
20
20
  # @return [Proc, nil] The default error notifier (callable object).
21
21
  attr_writer :error_notifier
22
22
 
23
- # @!attribute [r] notifiers
23
+ # @!attribute [rw] notifiers
24
24
  # @return [Array<Stoplight::Notifier::Base>] The default list of notifiers.
25
- attr_reader :notifiers
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
@@ -40,24 +44,20 @@ module Stoplight
40
44
  # @return [Array<Class>, nil] The default list of errors to skip.
41
45
  attr_writer :skipped_errors
42
46
 
47
+ # @!attribute [w] data_store
48
+ # @return [Stoplight::DataStore::Base] The default data store instance.
49
+ attr_writer :data_store
50
+
51
+ # @!attribute [w] traffic_control
52
+ # @return [Stoplight::TrafficControl::Base, Symbol, Hash] The traffic control strategy.
53
+ attr_writer :traffic_control
54
+
43
55
  def initialize
44
56
  # This allows users appending notifiers to the default list,
45
57
  # while still allowing them to override the default list.
46
58
  @notifiers = Default::NOTIFIERS
47
59
  end
48
60
 
49
- # @param value [Stoplight::DataStore::Base]
50
- # @return [Stoplight::DataStore::Base] The default data store instance.
51
- def data_store=(value)
52
- @data_store = DataStore::FailSafe.wrap(value)
53
- end
54
-
55
- # @param value [Array<Stoplight::Notifier::Base>]
56
- # @return [Array<Stoplight::Notifier::FailSafe>]
57
- def notifiers=(value)
58
- @notifiers = value.map { |notifier| Notifier::FailSafe.wrap(notifier) }
59
- end
60
-
61
61
  # Converts the user-defined configuration to a hash.
62
62
  #
63
63
  # @return [Hash] A hash representation of the configuration, excluding nil values.
@@ -67,11 +67,13 @@ module Stoplight
67
67
  cool_off_time: @cool_off_time,
68
68
  data_store: @data_store,
69
69
  error_notifier: @error_notifier,
70
- notifiers: (@notifiers == Default::NOTIFIERS) ? nil : @notifiers, # This is to avoid conflicts with legacy config
70
+ notifiers: @notifiers,
71
71
  threshold: @threshold,
72
+ recovery_threshold: @recovery_threshold,
72
73
  window_size: @window_size,
73
74
  tracked_errors: @tracked_errors,
74
- skipped_errors: @skipped_errors
75
+ skipped_errors: @skipped_errors,
76
+ traffic_control: @traffic_control
75
77
  }.compact
76
78
  end
77
79
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Stoplight
4
6
  module DataStore
5
7
  # A wrapper around a data store that provides fail-safe mechanisms using a
@@ -31,6 +33,12 @@ module Stoplight
31
33
  # @param data_store [Stoplight::DataStore::Base]
32
34
  def initialize(data_store)
33
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
+ )
34
42
  end
35
43
 
36
44
  def names
@@ -96,10 +104,9 @@ module Stoplight
96
104
  circuit_breaker.run(fallback, &code)
97
105
  end
98
106
 
99
- # @!attribute [r] circuit_breaker
100
- # @return [Stoplight] The circuit breaker used to handle failures.
107
+ # @return [Stoplight::Light] The circuit breaker used to handle failures.
101
108
  private def circuit_breaker
102
- @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}")
103
110
  end
104
111
  end
105
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
@@ -14,18 +14,17 @@ module Stoplight
14
14
  words.join(" ")
15
15
  end
16
16
 
17
- NOTIFIERS = [
18
- Notifier::FailSafe.wrap(Notifier::IO.new($stderr))
19
- ].freeze
17
+ NOTIFIERS = [Notifier::IO.new($stderr)].freeze
20
18
 
21
19
  THRESHOLD = 3
20
+ RECOVERY_THRESHOLD = 1
22
21
 
23
22
  WINDOW_SIZE = nil
24
23
 
25
24
  TRACKED_ERRORS = [StandardError].freeze
26
25
  SKIPPED_ERRORS = [].freeze
27
26
 
28
- TRAFFIC_CONTROL = TrafficControl::ConsecutiveFailures.new
29
- TRAFFIC_RECOVERY = TrafficRecovery::SingleSuccess.new
27
+ TRAFFIC_CONTROL = TrafficControl::ConsecutiveErrors.new
28
+ TRAFFIC_RECOVERY = TrafficRecovery::ConsecutiveSuccesses.new
30
29
  end
31
30
  end
@@ -3,85 +3,65 @@
3
3
  module Stoplight
4
4
  class Light
5
5
  # A +Stoplight::Light+ configuration object.
6
+ #
7
+ # # @!attribute [r] name
8
+ # @return [String]
9
+ #
10
+ # @!attribute [r] cool_off_time
11
+ # @return [Numeric]
12
+ #
13
+ # @!attribute [r] data_store
14
+ # @return [Stoplight::DataStore::Base]
15
+ #
16
+ # @!attribute [r] error_notifier
17
+ # @return [StandardError => void]
18
+ #
19
+ # @!attribute [r] notifiers
20
+ # @return [Array<Stoplight::Notifier::Base>]
21
+ #
22
+ # @!attribute [r] threshold
23
+ # @return [Numeric]
24
+ #
25
+ # @!attribute [r] window_size
26
+ # @return [Numeric]
27
+ #
28
+ # @!attribute [r] tracked_errors
29
+ # @return [Array<StandardError>]
30
+ #
31
+ # @!attribute [r] skipped_errors
32
+ # @return [Array<Exception>]
33
+ #
34
+ # @!attribute [r] traffic_control
35
+ # @return [Stoplight::TrafficControl::Base]
36
+ #
37
+ # @!attribute [r] traffic_recovery
38
+ # @return [Stoplight::TrafficRecovery::Base]
6
39
  # @api private
7
- class Config
8
- # @!attribute [r] name
9
- # @return [String]
10
- attr_reader :name
11
-
12
- # @!attribute [r] cool_off_time
13
- # @return [Numeric]
14
- attr_reader :cool_off_time
15
-
16
- # @!attribute [r] data_store
17
- # @return [Stoplight::DataStore::Base]
18
- attr_reader :data_store
19
-
20
- # @!attribute [r] error_notifier
21
- # @return [StandardError => void]
22
- attr_reader :error_notifier
23
-
24
- # @!attribute [r] notifiers
25
- # @return [Array<Stoplight::Notifier::Base>]
26
- attr_reader :notifiers
27
-
28
- # @!attribute [r] threshold
29
- # @return [Numeric]
30
- attr_reader :threshold
31
-
32
- # @!attribute [r] window_size
33
- # @return [Numeric]
34
- attr_reader :window_size
35
-
36
- # @!attribute [r] tracked_errors
37
- # @return [Array<StandardError>]
38
- attr_reader :tracked_errors
39
-
40
- # @!attribute [r] skipped_errors
41
- # @return [Array<Exception>]
42
- attr_reader :skipped_errors
43
-
44
- # @!attribute [r] traffic_control
45
- # @return [Stoplight::TrafficControl::Base]
46
- attr_reader :traffic_control
47
-
48
- # @!attribute [r] traffic_recovery
49
- # @return [Stoplight::TrafficRecovery::Base]
50
- attr_reader :traffic_recovery
51
-
52
- # @param name [String]
53
- # @param cool_off_time [Numeric]
54
- # @param data_store [Stoplight::DataStore::Base]
55
- # @param error_notifier [Proc]
56
- # @param notifiers [Array<Stoplight::Notifier::Base>]
57
- # @param threshold [Numeric]
58
- # @param window_size [Numeric]
59
- # @param tracked_errors [Array<StandardError>]
60
- # @param skipped_errors [Array<Exception>]
61
- # @param traffic_control [Stoplight::TrafficControl::Base]
62
- # @param traffic_recovery [Stoplight::TrafficRecovery::Base]
63
- def initialize(name:, cool_off_time:, data_store:, error_notifier:, notifiers:, threshold:, window_size:,
64
- tracked_errors:, skipped_errors:, traffic_control:, traffic_recovery:)
65
- @name = name
66
- @cool_off_time = cool_off_time.to_i
67
- @data_store = DataStore::FailSafe.wrap(data_store)
68
- @error_notifier = error_notifier
69
- @notifiers = notifiers.map { |notifier| Notifier::FailSafe.wrap(notifier) }
70
- @threshold = threshold
71
- @window_size = window_size
72
- @tracked_errors = Array(tracked_errors)
73
- @skipped_errors = Array(skipped_errors)
74
- @traffic_control = traffic_control
75
- @traffic_recovery = traffic_recovery
40
+ Config = Data.define(
41
+ :name,
42
+ :cool_off_time,
43
+ :data_store,
44
+ :error_notifier,
45
+ :notifiers,
46
+ :threshold,
47
+ :recovery_threshold,
48
+ :window_size,
49
+ :tracked_errors,
50
+ :skipped_errors,
51
+ :traffic_control,
52
+ :traffic_recovery
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
76
60
  end
77
61
 
78
- # @param other [any]
79
- # @return [Boolean]
80
- def ==(other)
81
- other.is_a?(self.class) && to_h == other.to_h
82
- end
83
-
84
- # @param error [Exception]
62
+ # Checks if the given error should be tracked
63
+ #
64
+ # @param error [#==] The error to check, e.g. an Exception, Class or Proc
85
65
  # @return [Boolean]
86
66
  def track_error?(error)
87
67
  skip = skipped_errors.any? { |klass| klass === error }
@@ -90,28 +70,41 @@ module Stoplight
90
70
  !skip && track
91
71
  end
92
72
 
93
- # Updates the configuration with new settings and returns a new instance.
94
- #
73
+ # This method applies configuration dsl and revalidates the configuration
95
74
  # @return [Stoplight::Light::Config]
96
75
  def with(**settings)
97
- self.class.new(**to_h.merge(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
98
  end
99
99
 
100
- # @return [Hash]
101
- def to_h
102
- {
103
- cool_off_time:,
104
- data_store:,
105
- error_notifier:,
106
- name:,
107
- notifiers:,
108
- threshold:,
109
- window_size:,
110
- tracked_errors:,
111
- skipped_errors:,
112
- traffic_control:,
113
- traffic_recovery:
114
- }
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
115
108
  end
116
109
  end
117
110
  end
@@ -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
  #
@@ -30,6 +48,23 @@ module Stoplight
30
48
  def stop_traffic?(config, metadata)
31
49
  raise NotImplementedError
32
50
  end
51
+
52
+ # @param other [any]
53
+ # @return [Boolean]
54
+ def ==(other)
55
+ other.is_a?(self.class)
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)
33
68
  end
34
69
  end
35
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.0.3")
4
+ VERSION = Gem::Version.new("5.2.0")
5
5
  end