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.
- checksums.yaml +4 -4
- data/README.md +162 -27
- data/lib/generators/stoplight/install/USAGE +16 -0
- data/lib/generators/stoplight/install/install_generator.rb +75 -0
- data/lib/generators/stoplight/install/templates/stoplight.rb.erb +18 -0
- data/lib/stoplight/admin.rb +1 -1
- data/lib/stoplight/config/compatibility_result.rb +54 -0
- data/lib/stoplight/config/dsl.rb +97 -0
- data/lib/stoplight/config/library_default_config.rb +13 -21
- data/lib/stoplight/config/system_config.rb +7 -0
- data/lib/stoplight/config/user_default_config.rb +19 -17
- data/lib/stoplight/data_store/fail_safe.rb +10 -3
- data/lib/stoplight/data_store/memory.rb +5 -0
- data/lib/stoplight/data_store/redis.rb +4 -0
- data/lib/stoplight/default.rb +4 -5
- data/lib/stoplight/light/config.rb +87 -94
- data/lib/stoplight/metadata.rb +16 -0
- data/lib/stoplight/notifier/fail_safe.rb +5 -2
- data/lib/stoplight/traffic_control/base.rb +35 -0
- data/lib/stoplight/traffic_control/{consecutive_failures.rb → consecutive_errors.rb} +17 -5
- data/lib/stoplight/traffic_control/error_rate.rb +49 -0
- data/lib/stoplight/traffic_recovery/base.rb +27 -0
- data/lib/stoplight/traffic_recovery/consecutive_successes.rb +66 -0
- data/lib/stoplight/version.rb +1 -1
- data/lib/stoplight.rb +46 -14
- metadata +15 -6
- data/lib/stoplight/config/config_provider.rb +0 -62
- data/lib/stoplight/traffic_recovery/single_success.rb +0 -35
@@ -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 [
|
23
|
+
# @!attribute [rw] notifiers
|
24
24
|
# @return [Array<Stoplight::Notifier::Base>] The default list of notifiers.
|
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
|
@@ -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:
|
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
|
-
#
|
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}"
|
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
|
data/lib/stoplight/default.rb
CHANGED
@@ -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::
|
29
|
-
TRAFFIC_RECOVERY = TrafficRecovery::
|
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
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
-
#
|
79
|
-
#
|
80
|
-
|
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
|
-
#
|
94
|
-
#
|
73
|
+
# This method applies configuration dsl and revalidates the configuration
|
95
74
|
# @return [Stoplight::Light::Config]
|
96
75
|
def with(**settings)
|
97
|
-
|
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
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
data/lib/stoplight/metadata.rb
CHANGED
@@ -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(
|
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
|
-
#
|
18
|
-
#
|
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
|
-
#
|
24
|
-
#
|
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
|
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
|
data/lib/stoplight/version.rb
CHANGED