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