stoplight 4.1.0 → 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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +289 -350
  3. data/lib/stoplight/admin/actions/action.rb +24 -0
  4. data/lib/stoplight/admin/actions/lock.rb +23 -0
  5. data/lib/stoplight/admin/actions/lock_all_green.rb +18 -0
  6. data/lib/stoplight/admin/actions/lock_green.rb +23 -0
  7. data/lib/stoplight/admin/actions/lock_red.rb +23 -0
  8. data/lib/stoplight/admin/actions/stats.rb +27 -0
  9. data/lib/stoplight/admin/actions/unlock.rb +23 -0
  10. data/lib/stoplight/admin/dependencies.rb +50 -0
  11. data/lib/stoplight/admin/helpers.rb +27 -0
  12. data/lib/stoplight/admin/lights_repository/light.rb +155 -0
  13. data/lib/stoplight/admin/lights_repository.rb +74 -0
  14. data/lib/stoplight/admin/lights_stats.rb +77 -0
  15. data/lib/stoplight/admin/views/_card.erb +120 -0
  16. data/lib/stoplight/admin/views/index.erb +36 -0
  17. data/lib/stoplight/admin/views/layout.erb +66 -0
  18. data/lib/stoplight/admin.rb +68 -0
  19. data/lib/stoplight/color.rb +3 -3
  20. data/lib/stoplight/config/config_provider.rb +62 -0
  21. data/lib/stoplight/config/library_default_config.rb +29 -0
  22. data/lib/stoplight/config/user_default_config.rb +83 -0
  23. data/lib/stoplight/data_store/base.rb +59 -33
  24. data/lib/stoplight/data_store/fail_safe.rb +105 -0
  25. data/lib/stoplight/data_store/memory.rb +257 -50
  26. data/lib/stoplight/data_store/redis/get_metadata.lua +38 -0
  27. data/lib/stoplight/data_store/redis/lua.rb +23 -0
  28. data/lib/stoplight/data_store/redis/record_failure.lua +36 -0
  29. data/lib/stoplight/data_store/redis/record_success.lua +35 -0
  30. data/lib/stoplight/data_store/redis/transition_to_green.lua +10 -0
  31. data/lib/stoplight/data_store/redis/transition_to_red.lua +10 -0
  32. data/lib/stoplight/data_store/redis/transition_to_yellow.lua +9 -0
  33. data/lib/stoplight/data_store/redis.rb +345 -106
  34. data/lib/stoplight/default.rb +11 -9
  35. data/lib/stoplight/error.rb +1 -13
  36. data/lib/stoplight/failure.rb +14 -13
  37. data/lib/stoplight/light/config.rb +118 -0
  38. data/lib/stoplight/light/configuration_builder_interface.rb +128 -0
  39. data/lib/stoplight/light/green_run_strategy.rb +53 -0
  40. data/lib/stoplight/light/red_run_strategy.rb +26 -0
  41. data/lib/stoplight/light/run_strategy.rb +30 -0
  42. data/lib/stoplight/light/yellow_run_strategy.rb +78 -0
  43. data/lib/stoplight/light.rb +164 -84
  44. data/lib/stoplight/metadata.rb +71 -0
  45. data/lib/stoplight/notifier/base.rb +14 -7
  46. data/lib/stoplight/notifier/fail_safe.rb +67 -0
  47. data/lib/stoplight/notifier/generic.rb +54 -5
  48. data/lib/stoplight/rspec/generic_notifier.rb +11 -12
  49. data/lib/stoplight/rspec.rb +1 -1
  50. data/lib/stoplight/state.rb +3 -3
  51. data/lib/stoplight/traffic_control/base.rb +35 -0
  52. data/lib/stoplight/traffic_control/consecutive_failures.rb +43 -0
  53. data/lib/stoplight/traffic_recovery/base.rb +51 -0
  54. data/lib/stoplight/traffic_recovery/single_success.rb +35 -0
  55. data/lib/stoplight/version.rb +1 -1
  56. data/lib/stoplight.rb +111 -51
  57. metadata +49 -98
  58. data/lib/stoplight/builder.rb +0 -70
  59. data/lib/stoplight/circuit_breaker.rb +0 -102
  60. data/lib/stoplight/configurable.rb +0 -95
  61. data/lib/stoplight/configuration.rb +0 -126
  62. data/lib/stoplight/light/deprecated.rb +0 -44
  63. data/lib/stoplight/light/lockable.rb +0 -45
  64. data/lib/stoplight/light/runnable.rb +0 -127
  65. data/lib/stoplight/notifier.rb +0 -6
  66. data/spec/spec_helper.rb +0 -22
  67. data/spec/stoplight/builder_spec.rb +0 -165
  68. data/spec/stoplight/circuit_breaker_spec.rb +0 -43
  69. data/spec/stoplight/color_spec.rb +0 -39
  70. data/spec/stoplight/configurable_spec.rb +0 -25
  71. data/spec/stoplight/data_store/base_spec.rb +0 -71
  72. data/spec/stoplight/data_store/memory_spec.rb +0 -22
  73. data/spec/stoplight/data_store/redis_spec.rb +0 -45
  74. data/spec/stoplight/data_store_spec.rb +0 -9
  75. data/spec/stoplight/default_spec.rb +0 -80
  76. data/spec/stoplight/error_spec.rb +0 -39
  77. data/spec/stoplight/failure_spec.rb +0 -108
  78. data/spec/stoplight/light/lockable_spec.rb +0 -93
  79. data/spec/stoplight/light/runnable_spec.rb +0 -38
  80. data/spec/stoplight/light_spec.rb +0 -156
  81. data/spec/stoplight/notifier/base_spec.rb +0 -18
  82. data/spec/stoplight/notifier/generic_spec.rb +0 -50
  83. data/spec/stoplight/notifier/io_spec.rb +0 -41
  84. data/spec/stoplight/notifier/logger_spec.rb +0 -75
  85. data/spec/stoplight/notifier_spec.rb +0 -9
  86. data/spec/stoplight/state_spec.rb +0 -39
  87. data/spec/stoplight/version_spec.rb +0 -9
  88. data/spec/stoplight_spec.rb +0 -32
  89. data/spec/support/configurable.rb +0 -69
  90. data/spec/support/data_store/base/clear_failures.rb +0 -18
  91. data/spec/support/data_store/base/clear_state.rb +0 -20
  92. data/spec/support/data_store/base/get_all.rb +0 -44
  93. data/spec/support/data_store/base/get_failures.rb +0 -30
  94. data/spec/support/data_store/base/get_state.rb +0 -7
  95. data/spec/support/data_store/base/names.rb +0 -29
  96. data/spec/support/data_store/base/record_failures.rb +0 -70
  97. data/spec/support/data_store/base/set_state.rb +0 -15
  98. data/spec/support/data_store/base/with_notification_lock.rb +0 -27
  99. data/spec/support/data_store/base.rb +0 -21
  100. data/spec/support/database_cleaner.rb +0 -26
  101. data/spec/support/exception_helpers.rb +0 -9
  102. data/spec/support/light/runnable/color.rb +0 -79
  103. data/spec/support/light/runnable/run.rb +0 -247
  104. data/spec/support/light/runnable/state.rb +0 -31
  105. data/spec/support/light/runnable.rb +0 -5
@@ -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
- extend Deprecated
11
- include CircuitBreaker
12
- include Lockable
13
- include Runnable
14
-
15
- # @!attribute [r] data_store
16
- # @return [Stoplight::DataStore::Base]
17
- def_delegator :configuration, :data_store
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
- # @!attribute [r] threshold
20
- # @return [Integer]
21
- def_delegator :configuration, :threshold
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
- # @!attribute [r] cool_off_time
24
- # @return [Fixnum]
25
- def_delegator :configuration, :cool_off_time
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
- # @!attribute [r] window_size
28
- # @return [Float]
29
- def_delegator :configuration, :window_size
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
- # @!attribute [r] notifiers
32
- # # @return [Array<Notifier::Base>]
33
- def_delegator :configuration, :notifiers
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
- # @!attribute [r] error_notifier
36
- # # @return [Proc]
37
- def_delegator :configuration, :error_notifier
94
+ config.data_store.set_state(config, state)
38
95
 
39
- # @return [String]
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
- # @param name [String]
78
- # @param configuration [Stoplight::Configuration]
79
- # @yield []
80
- def initialize(name, configuration, &code)
81
- @configuration = configuration
82
- @name = name
83
- @code = code
84
- @error_handler = Default::ERROR_HANDLER
85
- @fallback = Default::FALLBACK
86
- end
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
- # @yieldparam error [Exception, nil]
97
- # @return [Stoplight::CircuitBreaker]
98
- def with_fallback(&fallback)
99
- @fallback = fallback
100
- self
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 reconfigure(configuration)
106
- @configuration = configuration
107
- self
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
- # @abstract
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
- # @param _light [Light]
8
- # @param _from_color [String]
9
- # @param _to_color [String]
10
- # @param _error [Exception, nil]
11
- # @return [String]
12
- def notify(_light, _from_color, _to_color, _error)
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
- # @return [Proc]
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
- # @see Base#notify
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
- def put(_message)
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 'a generic notifier' do
4
- it 'includes Generic' do
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 '#formatter' do
9
- it 'is initially the default' do
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 'reads the formatter' do
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 '#notify' do
23
- let(:light) { Stoplight::Light.new(name, &code) }
24
- let(:name) { ('a'..'z').to_a.shuffle.join }
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 'returns the message' do
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 'returns the message with an error' do
37
- error = ZeroDivisionError.new('divided by 0')
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
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'stoplight/rspec/generic_notifier'
3
+ require "stoplight/rspec/generic_notifier"
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Stoplight
4
4
  module State
5
- UNLOCKED = 'unlocked'
6
- LOCKED_GREEN = 'locked_green'
7
- LOCKED_RED = 'locked_red'
5
+ UNLOCKED = "unlocked"
6
+ LOCKED_GREEN = "locked_green"
7
+ LOCKED_RED = "locked_red"
8
8
  end
9
9
  end
@@ -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