stoplight 4.1.1 → 5.0.1
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 +288 -354
- data/lib/stoplight/admin/actions/action.rb +24 -0
- data/lib/stoplight/admin/actions/lock.rb +23 -0
- data/lib/stoplight/admin/actions/lock_all_green.rb +18 -0
- data/lib/stoplight/admin/actions/lock_green.rb +23 -0
- data/lib/stoplight/admin/actions/lock_red.rb +23 -0
- data/lib/stoplight/admin/actions/stats.rb +27 -0
- data/lib/stoplight/admin/actions/unlock.rb +23 -0
- data/lib/stoplight/admin/dependencies.rb +50 -0
- data/lib/stoplight/admin/helpers.rb +27 -0
- data/lib/stoplight/admin/lights_repository/light.rb +155 -0
- data/lib/stoplight/admin/lights_repository.rb +74 -0
- data/lib/stoplight/admin/lights_stats.rb +77 -0
- data/lib/stoplight/admin/views/_card.erb +120 -0
- data/lib/stoplight/admin/views/index.erb +36 -0
- data/lib/stoplight/admin/views/layout.erb +66 -0
- data/lib/stoplight/admin.rb +68 -0
- data/lib/stoplight/color.rb +3 -3
- data/lib/stoplight/config/config_provider.rb +62 -0
- data/lib/stoplight/config/library_default_config.rb +29 -0
- data/lib/stoplight/config/user_default_config.rb +83 -0
- data/lib/stoplight/data_store/base.rb +59 -33
- data/lib/stoplight/data_store/fail_safe.rb +105 -0
- data/lib/stoplight/data_store/memory.rb +257 -50
- data/lib/stoplight/data_store/redis/get_metadata.lua +38 -0
- data/lib/stoplight/data_store/redis/lua.rb +23 -0
- data/lib/stoplight/data_store/redis/record_failure.lua +36 -0
- data/lib/stoplight/data_store/redis/record_success.lua +35 -0
- data/lib/stoplight/data_store/redis/transition_to_green.lua +10 -0
- data/lib/stoplight/data_store/redis/transition_to_red.lua +10 -0
- data/lib/stoplight/data_store/redis/transition_to_yellow.lua +9 -0
- data/lib/stoplight/data_store/redis.rb +345 -106
- data/lib/stoplight/default.rb +11 -9
- data/lib/stoplight/error.rb +1 -13
- data/lib/stoplight/failure.rb +14 -13
- data/lib/stoplight/light/config.rb +118 -0
- data/lib/stoplight/light/configuration_builder_interface.rb +128 -0
- data/lib/stoplight/light/green_run_strategy.rb +53 -0
- data/lib/stoplight/light/red_run_strategy.rb +26 -0
- data/lib/stoplight/light/run_strategy.rb +30 -0
- data/lib/stoplight/light/yellow_run_strategy.rb +78 -0
- data/lib/stoplight/light.rb +164 -84
- data/lib/stoplight/metadata.rb +71 -0
- data/lib/stoplight/notifier/base.rb +14 -7
- data/lib/stoplight/notifier/fail_safe.rb +67 -0
- data/lib/stoplight/notifier/generic.rb +54 -5
- data/lib/stoplight/rspec/generic_notifier.rb +11 -12
- data/lib/stoplight/rspec.rb +1 -1
- data/lib/stoplight/state.rb +3 -3
- data/lib/stoplight/traffic_control/base.rb +35 -0
- data/lib/stoplight/traffic_control/consecutive_failures.rb +43 -0
- data/lib/stoplight/traffic_recovery/base.rb +51 -0
- data/lib/stoplight/traffic_recovery/single_success.rb +35 -0
- data/lib/stoplight/version.rb +1 -1
- data/lib/stoplight.rb +111 -51
- metadata +49 -98
- data/lib/stoplight/builder.rb +0 -70
- data/lib/stoplight/circuit_breaker.rb +0 -102
- data/lib/stoplight/configurable.rb +0 -95
- data/lib/stoplight/configuration.rb +0 -126
- data/lib/stoplight/light/deprecated.rb +0 -44
- data/lib/stoplight/light/lockable.rb +0 -45
- data/lib/stoplight/light/runnable.rb +0 -127
- data/lib/stoplight/notifier.rb +0 -6
- data/spec/spec_helper.rb +0 -22
- data/spec/stoplight/builder_spec.rb +0 -165
- data/spec/stoplight/circuit_breaker_spec.rb +0 -43
- data/spec/stoplight/color_spec.rb +0 -39
- data/spec/stoplight/configurable_spec.rb +0 -25
- data/spec/stoplight/data_store/base_spec.rb +0 -71
- data/spec/stoplight/data_store/memory_spec.rb +0 -22
- data/spec/stoplight/data_store/redis_spec.rb +0 -45
- data/spec/stoplight/data_store_spec.rb +0 -9
- data/spec/stoplight/default_spec.rb +0 -80
- data/spec/stoplight/error_spec.rb +0 -39
- data/spec/stoplight/failure_spec.rb +0 -108
- data/spec/stoplight/light/lockable_spec.rb +0 -93
- data/spec/stoplight/light/runnable_spec.rb +0 -38
- data/spec/stoplight/light_spec.rb +0 -156
- data/spec/stoplight/notifier/base_spec.rb +0 -18
- data/spec/stoplight/notifier/generic_spec.rb +0 -50
- data/spec/stoplight/notifier/io_spec.rb +0 -41
- data/spec/stoplight/notifier/logger_spec.rb +0 -75
- data/spec/stoplight/notifier_spec.rb +0 -9
- data/spec/stoplight/state_spec.rb +0 -39
- data/spec/stoplight/version_spec.rb +0 -9
- data/spec/stoplight_spec.rb +0 -32
- data/spec/support/configurable.rb +0 -69
- data/spec/support/data_store/base/clear_failures.rb +0 -24
- data/spec/support/data_store/base/clear_state.rb +0 -20
- data/spec/support/data_store/base/get_all.rb +0 -44
- data/spec/support/data_store/base/get_failures.rb +0 -30
- data/spec/support/data_store/base/get_state.rb +0 -7
- data/spec/support/data_store/base/names.rb +0 -29
- data/spec/support/data_store/base/record_failures.rb +0 -70
- data/spec/support/data_store/base/set_state.rb +0 -15
- data/spec/support/data_store/base/with_notification_lock.rb +0 -27
- data/spec/support/data_store/base.rb +0 -21
- data/spec/support/database_cleaner.rb +0 -26
- data/spec/support/exception_helpers.rb +0 -9
- data/spec/support/light/runnable/color.rb +0 -79
- data/spec/support/light/runnable/run.rb +0 -247
- data/spec/support/light/runnable/state.rb +0 -31
- data/spec/support/light/runnable.rb +0 -5
data/lib/stoplight/light.rb
CHANGED
@@ -1,110 +1,190 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'stoplight/light/deprecated'
|
4
|
-
|
5
3
|
module Stoplight
|
6
4
|
#
|
7
5
|
# @api private use +Stoplight()+ method instead
|
8
6
|
class Light
|
9
7
|
extend Forwardable
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
8
|
+
include ConfigurationBuilderInterface
|
9
|
+
|
10
|
+
# @!attribute [r] config
|
11
|
+
# @return [Stoplight::Light::Config]
|
12
|
+
# @api private
|
13
|
+
attr_reader :config
|
14
|
+
|
15
|
+
# @!attribute [r] name
|
16
|
+
# The name of the light.
|
17
|
+
# @return [String]
|
18
|
+
def_delegator :config, :name
|
19
|
+
|
20
|
+
# @param config [Stoplight::Light::Config]
|
21
|
+
def initialize(config, green_run_strategy: nil, yellow_run_strategy: nil, red_run_strategy: nil)
|
22
|
+
@config = config
|
23
|
+
@green_run_strategy = green_run_strategy
|
24
|
+
@yellow_run_strategy = yellow_run_strategy
|
25
|
+
@red_run_strategy = red_run_strategy
|
26
|
+
end
|
18
27
|
|
19
|
-
#
|
20
|
-
#
|
21
|
-
|
28
|
+
# Returns the current state of the light:
|
29
|
+
# * +Stoplight::State::LOCKED_GREEN+ -- light is locked green and allows all traffic
|
30
|
+
# * +Stoplight::State::LOCKED_RED+ -- light is locked red and blocks all traffic
|
31
|
+
# * +Stoplight::State::UNLOCKED+ -- light is not locked and follow the configured rules
|
32
|
+
#
|
33
|
+
# @return [String]
|
34
|
+
def state
|
35
|
+
config
|
36
|
+
.data_store
|
37
|
+
.get_metadata(config)
|
38
|
+
.locked_state
|
39
|
+
end
|
22
40
|
|
23
|
-
#
|
24
|
-
#
|
25
|
-
|
41
|
+
# Returns current color:
|
42
|
+
# * +Stoplight::Color::GREEN+ -- circuit breaker is closed
|
43
|
+
# * +Stoplight::Color::RED+ -- circuit breaker is open
|
44
|
+
# * +Stoplight::Color::YELLOW+ -- circuit breaker is half-open
|
45
|
+
#
|
46
|
+
# @example
|
47
|
+
# light = Stoplight('example')
|
48
|
+
# light.color #=> Color::GREEN
|
49
|
+
#
|
50
|
+
# @return [String] returns current light color
|
51
|
+
def color
|
52
|
+
config
|
53
|
+
.data_store
|
54
|
+
.get_metadata(config)
|
55
|
+
.color
|
56
|
+
end
|
26
57
|
|
27
|
-
#
|
28
|
-
#
|
29
|
-
|
58
|
+
# Runs the given block of code with this circuit breaker
|
59
|
+
#
|
60
|
+
# @example
|
61
|
+
# light = Stoplight('example')
|
62
|
+
# light.run { 2/0 }
|
63
|
+
#
|
64
|
+
# @example Running with fallback
|
65
|
+
# light = Stoplight('example')
|
66
|
+
# light.run(->(error) { 0 }) { 1 / 0 } #=> 0
|
67
|
+
#
|
68
|
+
# @param fallback [Proc, nil] (nil) fallback code to run if the circuit breaker is open
|
69
|
+
# @raise [Stoplight::Error::RedLight]
|
70
|
+
# @return [any]
|
71
|
+
# @raise [Error::RedLight]
|
72
|
+
def run(fallback = nil, &code)
|
73
|
+
raise ArgumentError, "nothing to run. Please, pass a block into `Light#run`" unless block_given?
|
74
|
+
|
75
|
+
strategy = state_strategy_factory(color)
|
76
|
+
strategy.execute(fallback, &code)
|
77
|
+
end
|
30
78
|
|
31
|
-
#
|
32
|
-
#
|
33
|
-
|
79
|
+
# Locks light in either +State::LOCKED_RED+ or +State::LOCKED_GREEN+
|
80
|
+
#
|
81
|
+
# @example
|
82
|
+
# light = Stoplight('example-locked')
|
83
|
+
# light.lock(Stoplight::Color::RED)
|
84
|
+
#
|
85
|
+
# @param color [String] should be either +Color::RED+ or +Color::GREEN+
|
86
|
+
# @return [Stoplight::Light] returns locked light (circuit breaker)
|
87
|
+
def lock(color)
|
88
|
+
state = case color
|
89
|
+
when Color::RED then State::LOCKED_RED
|
90
|
+
when Color::GREEN then State::LOCKED_GREEN
|
91
|
+
else raise Error::IncorrectColor
|
92
|
+
end
|
34
93
|
|
35
|
-
|
36
|
-
# # @return [Proc]
|
37
|
-
def_delegator :configuration, :error_notifier
|
94
|
+
config.data_store.set_state(config, state)
|
38
95
|
|
39
|
-
|
40
|
-
attr_reader :name
|
41
|
-
# @return [Proc]
|
42
|
-
attr_reader :code
|
43
|
-
# @return [Proc]
|
44
|
-
attr_reader :error_handler
|
45
|
-
# @return [Proc, nil]
|
46
|
-
attr_reader :fallback
|
47
|
-
# @return [Stoplight::Configuration]
|
48
|
-
# @api private
|
49
|
-
attr_reader :configuration
|
50
|
-
|
51
|
-
class << self
|
52
|
-
alias __new_with_configuration__ new
|
53
|
-
|
54
|
-
# It overrides the +Light.new+ method to support an old and a new
|
55
|
-
# way of instantiation.
|
56
|
-
#
|
57
|
-
# @overload new(name, &code)
|
58
|
-
# @param name [String]
|
59
|
-
# @return [Stoplight::Light]
|
60
|
-
#
|
61
|
-
# @overload new(name, configuration)
|
62
|
-
# @param name [String]
|
63
|
-
# @param configuration [Stoplight::Configuration]
|
64
|
-
# @return [Stoplight::Light]
|
65
|
-
#
|
66
|
-
def new(name, configuration = nil, &code)
|
67
|
-
if configuration
|
68
|
-
__new_with_configuration__(name, configuration, &code)
|
69
|
-
else
|
70
|
-
warn '[DEPRECATED] Instantiating `Stoplight::Light` is deprecated. ' \
|
71
|
-
'Please use `Stoplight()` method instead.'
|
72
|
-
Builder.with(name: name).build(&code)
|
73
|
-
end
|
74
|
-
end
|
96
|
+
self
|
75
97
|
end
|
76
98
|
|
77
|
-
#
|
78
|
-
#
|
79
|
-
# @
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
99
|
+
# Unlocks light and sets its state to State::UNLOCKED
|
100
|
+
#
|
101
|
+
# @example
|
102
|
+
# light = Stoplight('example-locked')
|
103
|
+
# light.lock(Stoplight::Color::RED)
|
104
|
+
# light.unlock
|
105
|
+
#
|
106
|
+
# @return [Stoplight::Light] returns unlocked light (circuit breaker)
|
107
|
+
def unlock
|
108
|
+
config.data_store.set_state(config, Stoplight::State::UNLOCKED)
|
87
109
|
|
88
|
-
# @yieldparam error [Exception]
|
89
|
-
# @yieldparam handle [Proc]
|
90
|
-
# @return [Stoplight::CircuitBreaker]
|
91
|
-
def with_error_handler(&error_handler)
|
92
|
-
@error_handler = error_handler
|
93
110
|
self
|
94
111
|
end
|
95
112
|
|
96
|
-
#
|
97
|
-
#
|
98
|
-
|
99
|
-
|
100
|
-
|
113
|
+
# Two lights considered equal if they have the same configuration.
|
114
|
+
#
|
115
|
+
# @param other [any]
|
116
|
+
# @return [Boolean]
|
117
|
+
def ==(other)
|
118
|
+
other.is_a?(self.class) && config == other.config
|
119
|
+
end
|
120
|
+
|
121
|
+
# Reconfigures the light with updated settings and returns a new instance.
|
122
|
+
#
|
123
|
+
# This method allows you to modify the configuration of a +Stoplight::Light+ object
|
124
|
+
# by providing a hash of settings. The original light remains unchanged, and a new
|
125
|
+
# light instance with the updated configuration is returned.
|
126
|
+
#
|
127
|
+
# @param settings [Hash] A hash of configuration options to update.
|
128
|
+
# @option settings [String] :name The name of the light.
|
129
|
+
# @option settings [Numeric] :cool_off_time The cool-off time in seconds before the light attempts recovery.
|
130
|
+
# @option settings [Numeric] :threshold The failure threshold to trigger the red state.
|
131
|
+
# @option settings [Numeric] :window_size The time window in seconds for counting failures.
|
132
|
+
# @option settings [Stoplight::DataStore::Base] :data_store The data store to use for persisting light state.
|
133
|
+
# @option settings [Array<Stoplight::Notifier::Base>] :notifiers A list of notifiers to handle light events.
|
134
|
+
# @option settings [Proc] :error_notifier A custom error notifier to handle exceptions.
|
135
|
+
# @option settings [Array<StandardError>] :tracked_errors A list of errors to track for failure counting.
|
136
|
+
# @option settings [Array<StandardError>] :skipped_errors A list of errors to skip from failure counting.
|
137
|
+
# @return [Stoplight::Light] A new `Stoplight::Light` instance with the updated configuration.
|
138
|
+
#
|
139
|
+
# @example Reconfiguring a light with custom settings
|
140
|
+
# light = Stoplight('payment-api')
|
141
|
+
#
|
142
|
+
# # Create a light for invoices with a higher threshold
|
143
|
+
# invoices_light = light.with(tracked_errors: [TimeoutError], threshold: 10)
|
144
|
+
#
|
145
|
+
# # Create a light for payments with a lower threshold
|
146
|
+
# payment_light = light.with(threshold: 5)
|
147
|
+
#
|
148
|
+
# # Run the lights with their respective configurations
|
149
|
+
# invoices_light.run(->(error) { [] }) { call_invoices_api }
|
150
|
+
# payment_light.run(->(error) { nil }) { call_payment_api }
|
151
|
+
# @see +Stoplight()+
|
152
|
+
def with(**settings)
|
153
|
+
reconfigure(config.with(**settings))
|
101
154
|
end
|
102
155
|
|
103
156
|
private
|
104
157
|
|
105
|
-
def
|
106
|
-
|
107
|
-
|
158
|
+
def state_strategy_factory(color)
|
159
|
+
case color
|
160
|
+
when Color::GREEN
|
161
|
+
green_run_strategy
|
162
|
+
when Color::YELLOW
|
163
|
+
yellow_run_strategy
|
164
|
+
else
|
165
|
+
red_run_strategy
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# @return [Stoplight::Runnable::RunStrategy]
|
170
|
+
def green_run_strategy
|
171
|
+
@green_run_strategy ||= GreenRunStrategy.new(config)
|
172
|
+
end
|
173
|
+
|
174
|
+
# @return [Stoplight::Runnable::RunStrategy]
|
175
|
+
def yellow_run_strategy
|
176
|
+
@yellow_run_strategy ||= YellowRunStrategy.new(config)
|
177
|
+
end
|
178
|
+
|
179
|
+
# @return [Stoplight::Runnable::RunStrategy]
|
180
|
+
def red_run_strategy
|
181
|
+
@red_run_strategy ||= RedRunStrategy.new(config)
|
182
|
+
end
|
183
|
+
|
184
|
+
# @param config [Stoplight::Light::Config]
|
185
|
+
# @return [Stoplight::Light]
|
186
|
+
def reconfigure(config)
|
187
|
+
self.class.new(config)
|
108
188
|
end
|
109
189
|
end
|
110
190
|
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Stoplight
|
4
|
+
# @api private
|
5
|
+
Metadata = Data.define(
|
6
|
+
:successes,
|
7
|
+
:errors,
|
8
|
+
:recovery_probe_successes,
|
9
|
+
:recovery_probe_errors,
|
10
|
+
:last_error_at,
|
11
|
+
:last_success_at,
|
12
|
+
:consecutive_errors,
|
13
|
+
:consecutive_successes,
|
14
|
+
:last_error,
|
15
|
+
:breached_at,
|
16
|
+
:locked_state,
|
17
|
+
:recovery_scheduled_after,
|
18
|
+
:recovery_started_at,
|
19
|
+
:recovered_at
|
20
|
+
) do
|
21
|
+
def initialize(
|
22
|
+
successes: nil,
|
23
|
+
errors: nil,
|
24
|
+
recovery_probe_successes: nil,
|
25
|
+
recovery_probe_errors: nil,
|
26
|
+
last_error_at: nil,
|
27
|
+
last_success_at: nil,
|
28
|
+
consecutive_errors: 0,
|
29
|
+
consecutive_successes: 0,
|
30
|
+
last_error: nil,
|
31
|
+
breached_at: nil,
|
32
|
+
locked_state: nil,
|
33
|
+
recovery_started_at: nil,
|
34
|
+
recovery_scheduled_after: nil,
|
35
|
+
recovered_at: nil
|
36
|
+
)
|
37
|
+
super(
|
38
|
+
recovery_probe_successes:,
|
39
|
+
recovery_probe_errors:,
|
40
|
+
successes:,
|
41
|
+
errors:,
|
42
|
+
last_error_at: (Time.at(Integer(last_error_at)) if last_error_at),
|
43
|
+
last_success_at: (Time.at(Integer(last_success_at)) if last_success_at),
|
44
|
+
consecutive_errors: Integer(consecutive_errors),
|
45
|
+
consecutive_successes: Integer(consecutive_successes),
|
46
|
+
last_error:,
|
47
|
+
breached_at: (Time.at(Integer(breached_at)) if breached_at),
|
48
|
+
locked_state: locked_state || State::UNLOCKED,
|
49
|
+
recovery_scheduled_after: (Time.at(Integer(recovery_scheduled_after)) if recovery_scheduled_after),
|
50
|
+
recovery_started_at: (Time.at(Integer(recovery_started_at)) if recovery_started_at),
|
51
|
+
recovered_at: (Time.at(Integer(recovered_at)) if recovered_at),
|
52
|
+
)
|
53
|
+
end
|
54
|
+
|
55
|
+
# @param at [Time] (Time.now) the moment of time when the color is determined
|
56
|
+
# @return [String] one of +Color::GREEN+, +Color::RED+, or +Color::YELLOW+
|
57
|
+
def color(at: Time.now)
|
58
|
+
if locked_state == State::LOCKED_GREEN
|
59
|
+
Color::GREEN
|
60
|
+
elsif locked_state == State::LOCKED_RED
|
61
|
+
Color::RED
|
62
|
+
elsif (recovery_scheduled_after && recovery_scheduled_after < at) || recovery_started_at
|
63
|
+
Color::YELLOW
|
64
|
+
elsif breached_at
|
65
|
+
Color::RED
|
66
|
+
else
|
67
|
+
Color::GREEN
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -2,14 +2,21 @@
|
|
2
2
|
|
3
3
|
module Stoplight
|
4
4
|
module Notifier
|
5
|
-
#
|
5
|
+
# Base class for creating custom notifiers in Stoplight.
|
6
|
+
# This is an abstract class that defines the interface for notifiers.
|
7
|
+
#
|
8
|
+
# @abstract Subclasses must implement the `notify` method to define custom notification logic.
|
9
|
+
# @see +Stoplight::Notifier::Generic+
|
6
10
|
class Base
|
7
|
-
#
|
8
|
-
#
|
9
|
-
# @param
|
10
|
-
# @param
|
11
|
-
# @
|
12
|
-
|
11
|
+
# Sends a notification when a Stoplight changes state.
|
12
|
+
#
|
13
|
+
# @param config [Stoplight::Light::Config] The Stoplight instance triggering the notification.
|
14
|
+
# @param from_color [String] The previous state color of the Stoplight.
|
15
|
+
# @param to_color [String] The new state color of the Stoplight.
|
16
|
+
# @param error [Exception, nil] The error (if any) that caused the state change.
|
17
|
+
# @return [String] The result of the notification process.
|
18
|
+
#
|
19
|
+
def notify(config, from_color, to_color, error)
|
13
20
|
raise NotImplementedError
|
14
21
|
end
|
15
22
|
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Stoplight
|
4
|
+
module Notifier
|
5
|
+
# A wrapper around a notifier that provides fail-safe mechanisms using a
|
6
|
+
# circuit breaker. It ensures that a notification can gracefully
|
7
|
+
# handle failures.
|
8
|
+
#
|
9
|
+
# @api private
|
10
|
+
class FailSafe < Base
|
11
|
+
# @!attribute [r] notifier
|
12
|
+
# @return [Stoplight::Notifier::Base] The underlying notifier being wrapped.
|
13
|
+
protected attr_reader :notifier
|
14
|
+
|
15
|
+
class << self
|
16
|
+
# Wraps a notifier with fail-safe mechanisms.
|
17
|
+
#
|
18
|
+
# @param notifier [Stoplight::Notifier::Base] The notifier to wrap.
|
19
|
+
# @return [Stoplight::Notifier::FailSafe] The original notifier if it is already
|
20
|
+
# a +FailSafe+ instance, otherwise a new +FailSafe+ instance.
|
21
|
+
def wrap(notifier)
|
22
|
+
case notifier
|
23
|
+
when FailSafe
|
24
|
+
notifier
|
25
|
+
else
|
26
|
+
new(notifier)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Initializes a new instance of the +FailSafe+ class.
|
32
|
+
#
|
33
|
+
# @param notifier [Stoplight::Notifier::Base] The notifier to wrap.
|
34
|
+
def initialize(notifier)
|
35
|
+
@notifier = notifier
|
36
|
+
end
|
37
|
+
|
38
|
+
# Sends a notification using the wrapped notifier with fail-safe mechanisms.
|
39
|
+
#
|
40
|
+
# @param config [Stoplight::Light::Config] The light configuration.
|
41
|
+
# @param from_color [String] The initial color of the light.
|
42
|
+
# @param to_color [String] The target color of the light.
|
43
|
+
# @param error [Exception, nil] An optional error to include in the notification.
|
44
|
+
# @return [void]
|
45
|
+
def notify(config, from_color, to_color, error = nil)
|
46
|
+
fallback = proc do |exception|
|
47
|
+
config.error_notifier.call(exception) if exception
|
48
|
+
nil
|
49
|
+
end
|
50
|
+
|
51
|
+
circuit_breaker.run(fallback) do
|
52
|
+
notifier.notify(config, from_color, to_color, error)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# @return [Boolean]
|
57
|
+
def ==(other)
|
58
|
+
other.is_a?(FailSafe) && notifier == other.notifier
|
59
|
+
end
|
60
|
+
|
61
|
+
# @return [Stoplight] The circuit breaker used to handle failures.
|
62
|
+
private def circuit_breaker
|
63
|
+
@circuit_breaker ||= Stoplight("stoplight:notifier:fail_safe:#{notifier.class.name}", data_store: Default::DATA_STORE, notifiers: [])
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -2,18 +2,63 @@
|
|
2
2
|
|
3
3
|
module Stoplight
|
4
4
|
module Notifier
|
5
|
+
# The Generic module provides a reusable implementation for notifiers in Stoplight.
|
6
|
+
# It includes a formatter for generating notification messages and defines the `notify` method.
|
7
|
+
#
|
8
|
+
# @example Custom Notifier Implementation and Usage
|
9
|
+
# # Custom notifier that writes notifications to a file
|
10
|
+
# class FileNotifier < Stoplight::Notifier::Base
|
11
|
+
# include Stoplight::Notifier::Generic
|
12
|
+
#
|
13
|
+
# def initialize(file_path)
|
14
|
+
# @file = File.open(file_path, 'a')
|
15
|
+
# super(@file)
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# private
|
19
|
+
#
|
20
|
+
# # Writes the notification message to the file
|
21
|
+
# def put(message)
|
22
|
+
# @file.puts(message)
|
23
|
+
# end
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# # Usage example
|
27
|
+
# # Create a custom notifier that writes to 'stoplight.log'
|
28
|
+
# notifier = FileNotifier.new('stoplight.log')
|
29
|
+
#
|
30
|
+
# # Configure Stoplight to use the custom notifier
|
31
|
+
# Stoplight.configure do |config|
|
32
|
+
# config.notifiers += [notifier]
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
# # Create a stoplight and trigger a state change
|
36
|
+
# light = Stoplight('example-light')
|
37
|
+
# light.run { raise 'Simulated failure' } rescue nil
|
38
|
+
# light.run { raise 'Simulated failure' } rescue nil
|
39
|
+
# light.run { raise 'Simulated failure' } rescue nil
|
40
|
+
#
|
5
41
|
module Generic # rubocop:disable Style/Documentation
|
6
|
-
#
|
42
|
+
# @!attribute [r] formatter
|
43
|
+
# @return [Proc] The formatter used to generate notification messages.
|
44
|
+
# @see Stoplight::Default::FORMATTER
|
7
45
|
attr_reader :formatter
|
8
46
|
|
9
|
-
# @param object [Object]
|
10
|
-
# @param formatter [Proc, nil]
|
47
|
+
# @param object [Object] The object used by the notifier (e.g., a logger or external service).
|
48
|
+
# @param formatter [Proc, nil] A custom formatter for generating notification messages.
|
49
|
+
# If no formatter is provided, the default formatter is used.
|
11
50
|
def initialize(object, formatter = nil)
|
12
51
|
@object = object
|
13
52
|
@formatter = formatter || Default::FORMATTER
|
14
53
|
end
|
15
54
|
|
16
|
-
#
|
55
|
+
# Sends a notification when a Stoplight changes state.
|
56
|
+
#
|
57
|
+
# @param light [Light] The Stoplight instance triggering the notification.
|
58
|
+
# @param from_color [String] The previous state color of the Stoplight.
|
59
|
+
# @param to_color [String] The new state color of the Stoplight.
|
60
|
+
# @param error [Exception, nil] The error (if any) that caused the state change.
|
61
|
+
# @return [String] The formatted notification message.
|
17
62
|
def notify(light, from_color, to_color, error)
|
18
63
|
message = formatter.call(light, from_color, to_color, error)
|
19
64
|
put(message)
|
@@ -22,7 +67,11 @@ module Stoplight
|
|
22
67
|
|
23
68
|
private
|
24
69
|
|
25
|
-
|
70
|
+
# Processes the notification message.
|
71
|
+
#
|
72
|
+
# @param message [String] The notification message to be processed.
|
73
|
+
# @raise [NotImplementedError] If the method is not implemented in a subclass.
|
74
|
+
def put(message)
|
26
75
|
raise NotImplementedError
|
27
76
|
end
|
28
77
|
end
|
@@ -1,40 +1,39 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
RSpec.shared_examples
|
4
|
-
it
|
3
|
+
RSpec.shared_examples "a generic notifier" do
|
4
|
+
it "includes Generic" do
|
5
5
|
expect(described_class).to include(Stoplight::Notifier::Generic)
|
6
6
|
end
|
7
7
|
|
8
|
-
describe
|
9
|
-
it
|
8
|
+
describe "#formatter" do
|
9
|
+
it "is initially the default" do
|
10
10
|
formatter = nil
|
11
11
|
expect(described_class.new(nil, formatter).formatter)
|
12
12
|
.to eql(Stoplight::Default::FORMATTER)
|
13
13
|
end
|
14
14
|
|
15
|
-
it
|
15
|
+
it "reads the formatter" do
|
16
16
|
formatter = proc {}
|
17
17
|
expect(described_class.new(nil, formatter).formatter)
|
18
18
|
.to eql(formatter)
|
19
19
|
end
|
20
20
|
end
|
21
21
|
|
22
|
-
describe
|
23
|
-
let(:light) { Stoplight
|
24
|
-
let(:name) { (
|
25
|
-
let(:code) { -> {} }
|
22
|
+
describe "#notify" do
|
23
|
+
let(:light) { Stoplight(name) }
|
24
|
+
let(:name) { ("a".."z").to_a.shuffle.join }
|
26
25
|
let(:from_color) { Stoplight::Color::GREEN }
|
27
26
|
let(:to_color) { Stoplight::Color::RED }
|
28
27
|
let(:notifier) { described_class.new(double.as_null_object) }
|
29
28
|
|
30
|
-
it
|
29
|
+
it "returns the message" do
|
31
30
|
error = nil
|
32
31
|
expect(notifier.notify(light, from_color, to_color, error))
|
33
32
|
.to eql(notifier.formatter.call(light, from_color, to_color, error))
|
34
33
|
end
|
35
34
|
|
36
|
-
it
|
37
|
-
error = ZeroDivisionError.new(
|
35
|
+
it "returns the message with an error" do
|
36
|
+
error = ZeroDivisionError.new("divided by 0")
|
38
37
|
expect(notifier.notify(light, from_color, to_color, error))
|
39
38
|
.to eql(notifier.formatter.call(light, from_color, to_color, error))
|
40
39
|
end
|
data/lib/stoplight/rspec.rb
CHANGED
data/lib/stoplight/state.rb
CHANGED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Stoplight
|
4
|
+
module TrafficControl
|
5
|
+
# Strategies for determining when a Stoplight should change color to red.
|
6
|
+
#
|
7
|
+
# These strategies evaluate the current state and metrics of a Stoplight to decide
|
8
|
+
# if traffic should be stopped (i.e., if the light should turn RED).
|
9
|
+
#
|
10
|
+
# @example Creating a custom strategy
|
11
|
+
# class ErrorRateStrategy < Stoplight::TrafficControl::Base
|
12
|
+
# def stop_traffic?(config, metadata)
|
13
|
+
# total = metadata.successes + metadata.failures
|
14
|
+
# return false if total < 10 # Minimum sample size
|
15
|
+
#
|
16
|
+
# error_rate = metadata.failures.fdiv(total)
|
17
|
+
# error_rate >= 0.5 # Stop traffic when error rate reaches 50%
|
18
|
+
# end
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# @abstract
|
22
|
+
# @api private
|
23
|
+
class Base
|
24
|
+
# Determines whether traffic should be stopped based on the Stoplight's
|
25
|
+
# current state and metrics.
|
26
|
+
#
|
27
|
+
# @param config [Stoplight::Light::Config]
|
28
|
+
# @param metadata [Stoplight::Metadata]
|
29
|
+
# @return [Boolean] true if traffic should be stopped (rec), false otherwise (green)
|
30
|
+
def stop_traffic?(config, metadata)
|
31
|
+
raise NotImplementedError
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Stoplight
|
4
|
+
module TrafficControl
|
5
|
+
# A strategy that stops the traffic based on consecutive failures number.
|
6
|
+
#
|
7
|
+
# This strategy implements two distinct behaviors based on whether a window size
|
8
|
+
# is configured:
|
9
|
+
#
|
10
|
+
# 1. When window_size is set: The Stoplight turns red when the total number of
|
11
|
+
# failures within the window reaches the threshold.
|
12
|
+
#
|
13
|
+
# 2. When window_size is not set: The Stoplight turns red when consecutive failures
|
14
|
+
# reach the threshold.
|
15
|
+
#
|
16
|
+
# @example With window-based configuration
|
17
|
+
# config = Stoplight::Light::Config.new(threshold: 5, window_size: 60)
|
18
|
+
# strategy = Stoplight::TrafficControlStrategy::ConsecutiveFailures.new
|
19
|
+
#
|
20
|
+
# Will switch to red if 5 consecutive failures occur within the 60-second window
|
21
|
+
#
|
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
|
25
|
+
#
|
26
|
+
# Will switch to red only if 5 consecutive failures occur regardless of the time window
|
27
|
+
# @api private
|
28
|
+
class ConsecutiveFailures < Base
|
29
|
+
# Determines if traffic should be stopped based on failure counts.
|
30
|
+
#
|
31
|
+
# @param config [Stoplight::Light::Config]
|
32
|
+
# @param metadata [Stoplight::Metadata]
|
33
|
+
# @return [Boolean] true if failures have reached the threshold, false otherwise
|
34
|
+
def stop_traffic?(config, metadata)
|
35
|
+
if config.window_size
|
36
|
+
[metadata.consecutive_errors, metadata.errors].min >= config.threshold
|
37
|
+
else
|
38
|
+
metadata.consecutive_errors >= config.threshold
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|