stoplight 5.3.8 → 5.4.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 +15 -0
- data/lib/generators/stoplight/install/templates/stoplight.rb.erb +2 -0
- data/lib/stoplight/config/system_config.rb +4 -1
- data/lib/stoplight/data_store/fail_safe.rb +41 -42
- data/lib/stoplight/data_store/memory/sliding_window.rb +77 -0
- data/lib/stoplight/data_store/memory.rb +44 -70
- data/lib/stoplight/data_store/redis.rb +21 -24
- data/lib/stoplight/error.rb +30 -1
- data/lib/stoplight/light/config.rb +1 -0
- data/lib/stoplight/light/red_run_strategy.rb +5 -1
- data/lib/stoplight/traffic_control/consecutive_errors.rb +2 -2
- data/lib/stoplight/traffic_control/error_rate.rb +2 -2
- data/lib/stoplight/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2629a7cdd83dce508860ffe0c7fe3da4cad02eab605e0cb1b4f1e4c0f7db509d
|
|
4
|
+
data.tar.gz: 50f13d3625ab327dd07815fd3da1a9384a0cf0f1d75af2db853b098c3b90ba2e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9aa893f78f602c6d71f82f15deb41d6aa0bb4bc0ca37bf3756003699e2b158e1d3a857ee0f1fdd6cebcbb35fb787e5f515a36f89dd5560c000297302cd0e7e18
|
|
7
|
+
data.tar.gz: 772938b653645a1ad3c8ca5e9e5958871c1b8802da6ce947d38fd9d5390fa9e336d8838941571569aed102cd0656c58389224b417bcf059f4569295c42894d6d
|
data/README.md
CHANGED
|
@@ -99,6 +99,19 @@ light.run { 1 / 0 } #=> raises Stoplight::Error::RedLight: example-zero
|
|
|
99
99
|
light.color # => "red"
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
The `Stoplight::Error::RedLight` provides metadata about the error:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
def run_request
|
|
106
|
+
light = Stoplight("Example", cool_off_time: 10)
|
|
107
|
+
light.run { 1 / 0 } #=> raises Stoplight::Error::RedLight
|
|
108
|
+
rescue Stoplight::Error::RedLight => error
|
|
109
|
+
puts error.light_name #=> "Example"
|
|
110
|
+
puts error.cool_off_time #=> 10
|
|
111
|
+
puts error.retry_after #=> Absolute Time after which a recovery attempt can occur (e.g., "2025-10-21 15:39:50.672414 +0600")
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
102
115
|
After one minute, the light transitions to yellow, allowing a test execution:
|
|
103
116
|
|
|
104
117
|
```ruby
|
|
@@ -131,6 +144,8 @@ receives `nil`. In both cases, the return value of the fallback becomes the retu
|
|
|
131
144
|
|
|
132
145
|
Stoplight comes with a built-in Admin Panel that can track all active Lights and manually lock them in the desired state (`Green` or `Red`). Locking lights in certain states might be helpful in scenarios like E2E testing.
|
|
133
146
|
|
|
147
|
+

|
|
148
|
+
|
|
134
149
|
To add Admin Panel protected by basic authentication to your Rails project, add this configuration to your `config/routes.rb` file.
|
|
135
150
|
|
|
136
151
|
```ruby
|
|
@@ -12,7 +12,9 @@ require "redis"
|
|
|
12
12
|
# "redis://admin:p4ssw0rd@10.0.1.1:6380/15"
|
|
13
13
|
redis = Redis.new
|
|
14
14
|
data_store = Stoplight::DataStore::Redis.new(redis)
|
|
15
|
+
error_notifier = Rails.error.method(:report)
|
|
15
16
|
|
|
16
17
|
Stoplight.configure do |config|
|
|
17
18
|
config.data_store = data_store
|
|
19
|
+
config.error_notifier = error_notifier
|
|
18
20
|
end
|
|
@@ -10,10 +10,6 @@ module Stoplight
|
|
|
10
10
|
#
|
|
11
11
|
# @api private
|
|
12
12
|
class FailSafe < Base
|
|
13
|
-
# @!attribute [r] data_store
|
|
14
|
-
# @return [Stoplight::DataStore::Base] The underlying data store being wrapped.
|
|
15
|
-
protected attr_reader :data_store
|
|
16
|
-
|
|
17
13
|
class << self
|
|
18
14
|
# Wraps a data store with fail-safe mechanisms.
|
|
19
15
|
#
|
|
@@ -30,62 +26,70 @@ module Stoplight
|
|
|
30
26
|
end
|
|
31
27
|
end
|
|
32
28
|
|
|
29
|
+
# @!attribute data_store
|
|
30
|
+
# @return [Stoplight::DataStore::Base] The underlying primary data store being used
|
|
31
|
+
protected attr_reader :data_store
|
|
32
|
+
|
|
33
|
+
# @!attribute failover_data_store
|
|
34
|
+
# @return [Stoplight::DataStore::Base] The fallback data store used when the primary fails.
|
|
35
|
+
private attr_reader :failover_data_store
|
|
36
|
+
|
|
37
|
+
# @!attribute circuit_breaker
|
|
38
|
+
# @return [Stoplight::Light] The circuit breaker used to handle data store failures.
|
|
39
|
+
private attr_reader :circuit_breaker
|
|
40
|
+
|
|
33
41
|
# @param data_store [Stoplight::DataStore::Base]
|
|
34
|
-
def initialize(data_store)
|
|
42
|
+
def initialize(data_store, failover_data_store: Default::DATA_STORE)
|
|
35
43
|
@data_store = data_store
|
|
36
|
-
@
|
|
37
|
-
|
|
38
|
-
data_store: Default::DATA_STORE,
|
|
39
|
-
traffic_control: TrafficControl::ConsecutiveErrors.new,
|
|
40
|
-
threshold: Default::THRESHOLD
|
|
41
|
-
)
|
|
44
|
+
@failover_data_store = failover_data_store
|
|
45
|
+
@circuit_breaker = Stoplight.system_light("stoplight:data_store:fail_safe:#{data_store.class.name}")
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def names
|
|
45
|
-
with_fallback(
|
|
49
|
+
with_fallback(:names) do
|
|
46
50
|
data_store.names
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
|
|
50
|
-
def get_metadata(config)
|
|
51
|
-
with_fallback(
|
|
52
|
-
data_store.get_metadata(config)
|
|
54
|
+
def get_metadata(config, *args, **kwargs)
|
|
55
|
+
with_fallback(:get_metadata, config, *args, **kwargs) do
|
|
56
|
+
data_store.get_metadata(config, *args, **kwargs)
|
|
53
57
|
end
|
|
54
58
|
end
|
|
55
59
|
|
|
56
|
-
def record_failure(config,
|
|
57
|
-
with_fallback(
|
|
58
|
-
data_store.record_failure(config,
|
|
60
|
+
def record_failure(config, *args, **kwargs)
|
|
61
|
+
with_fallback(:record_failure, config, *args, **kwargs) do
|
|
62
|
+
data_store.record_failure(config, *args, **kwargs)
|
|
59
63
|
end
|
|
60
64
|
end
|
|
61
65
|
|
|
62
|
-
def record_success(config, **
|
|
63
|
-
with_fallback(
|
|
64
|
-
data_store.record_success(config, **
|
|
66
|
+
def record_success(config, *args, **kwargs)
|
|
67
|
+
with_fallback(:record_success, config, *args, **kwargs) do
|
|
68
|
+
data_store.record_success(config, *args, **kwargs)
|
|
65
69
|
end
|
|
66
70
|
end
|
|
67
71
|
|
|
68
|
-
def record_recovery_probe_success(config, **
|
|
69
|
-
with_fallback(
|
|
70
|
-
data_store.record_recovery_probe_success(config, **
|
|
72
|
+
def record_recovery_probe_success(config, *args, **kwargs)
|
|
73
|
+
with_fallback(:record_recovery_probe_success, config, *args, **kwargs) do
|
|
74
|
+
data_store.record_recovery_probe_success(config, *args, **kwargs)
|
|
71
75
|
end
|
|
72
76
|
end
|
|
73
77
|
|
|
74
|
-
def record_recovery_probe_failure(config,
|
|
75
|
-
with_fallback(
|
|
76
|
-
data_store.record_recovery_probe_failure(config,
|
|
78
|
+
def record_recovery_probe_failure(config, *args, **kwargs)
|
|
79
|
+
with_fallback(:record_recovery_probe_failure, config, *args, **kwargs) do
|
|
80
|
+
data_store.record_recovery_probe_failure(config, *args, **kwargs)
|
|
77
81
|
end
|
|
78
82
|
end
|
|
79
83
|
|
|
80
|
-
def set_state(config,
|
|
81
|
-
with_fallback(
|
|
82
|
-
data_store.set_state(config,
|
|
84
|
+
def set_state(config, *args, **kwargs)
|
|
85
|
+
with_fallback(:set_state, config, *args, **kwargs) do
|
|
86
|
+
data_store.set_state(config, *args, **kwargs)
|
|
83
87
|
end
|
|
84
88
|
end
|
|
85
89
|
|
|
86
|
-
def transition_to_color(config,
|
|
87
|
-
with_fallback(
|
|
88
|
-
data_store.transition_to_color(config,
|
|
90
|
+
def transition_to_color(config, *args, **kwargs)
|
|
91
|
+
with_fallback(:transition_to_color, config, *args, **kwargs) do
|
|
92
|
+
data_store.transition_to_color(config, *args, **kwargs)
|
|
89
93
|
end
|
|
90
94
|
end
|
|
91
95
|
|
|
@@ -93,21 +97,16 @@ module Stoplight
|
|
|
93
97
|
other.is_a?(self.class) && other.data_store == data_store
|
|
94
98
|
end
|
|
95
99
|
|
|
96
|
-
# @param
|
|
97
|
-
|
|
98
|
-
private def with_fallback(default = nil, config = nil, &code)
|
|
100
|
+
# @param method_name [Symbol] protected method name
|
|
101
|
+
private def with_fallback(method_name, *args, **kwargs, &code)
|
|
99
102
|
fallback = proc do |error|
|
|
103
|
+
config = args.first
|
|
100
104
|
config.error_notifier.call(error) if config && error
|
|
101
|
-
|
|
105
|
+
@failover_data_store.public_send(method_name, *args, **kwargs)
|
|
102
106
|
end
|
|
103
107
|
|
|
104
108
|
circuit_breaker.run(fallback, &code)
|
|
105
109
|
end
|
|
106
|
-
|
|
107
|
-
# @return [Stoplight::Light] The circuit breaker used to handle failures.
|
|
108
|
-
private def circuit_breaker
|
|
109
|
-
@circuit_breaker ||= Stoplight.system_light("stoplight:data_store:fail_safe:#{data_store.class.name}")
|
|
110
|
-
end
|
|
111
110
|
end
|
|
112
111
|
end
|
|
113
112
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module DataStore
|
|
5
|
+
class Memory < Base
|
|
6
|
+
# Hash-based sliding window for O(1) amortized operations.
|
|
7
|
+
#
|
|
8
|
+
# Maintains a running sum and stores per-second counts in a Hash. Ruby's Hash
|
|
9
|
+
# preserves insertion order (FIFO), allowing efficient removal of expired
|
|
10
|
+
# buckets from the front via +Hash#shift+, with their counts subtracted from
|
|
11
|
+
# the running sum.
|
|
12
|
+
#
|
|
13
|
+
# Performance: O(1) amortized for both reads and writes
|
|
14
|
+
# Memory: Bounded to the number of buckets
|
|
15
|
+
#
|
|
16
|
+
# @note Not thread-safe; synchronization must be handled externally
|
|
17
|
+
# @api private
|
|
18
|
+
class SlidingWindow
|
|
19
|
+
# @!attribute buckets
|
|
20
|
+
# @return [Hash<Integer, Integer>] A hash mapping time buckets to their counts
|
|
21
|
+
private attr_reader :buckets
|
|
22
|
+
|
|
23
|
+
# @!attribute running_sum
|
|
24
|
+
# @return [Integer] The running sum of all increments in the current window
|
|
25
|
+
private attr_accessor :running_sum
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@buckets = Hash.new { |buckets, bucket| buckets[bucket] = 0 }
|
|
29
|
+
@running_sum = 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Increment the count at a given timestamp
|
|
33
|
+
def increment
|
|
34
|
+
buckets[current_bucket] += 1
|
|
35
|
+
self.running_sum += 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param window_start [Time]
|
|
39
|
+
# @return [Integer]
|
|
40
|
+
def sum_in_window(window_start)
|
|
41
|
+
slide_window!(window_start)
|
|
42
|
+
self.running_sum
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private def slide_window!(window_start)
|
|
46
|
+
window_start_ts = window_start.to_i
|
|
47
|
+
|
|
48
|
+
loop do
|
|
49
|
+
timestamp, sum = buckets.first
|
|
50
|
+
if timestamp.nil? || timestamp >= window_start_ts
|
|
51
|
+
break
|
|
52
|
+
else
|
|
53
|
+
self.running_sum -= sum
|
|
54
|
+
buckets.shift
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private def current_bucket
|
|
60
|
+
bucket_for_time(current_time)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private def bucket_for_time(time)
|
|
64
|
+
time.to_i
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private def current_time
|
|
68
|
+
Time.now
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inspect
|
|
72
|
+
"#<#{self.class.name} #{buckets}>"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -11,11 +11,11 @@ module Stoplight
|
|
|
11
11
|
KEY_SEPARATOR = ":"
|
|
12
12
|
|
|
13
13
|
def initialize
|
|
14
|
-
@errors = Hash.new { |
|
|
15
|
-
@successes = Hash.new { |
|
|
14
|
+
@errors = Hash.new { |errors, light_name| errors[light_name] = SlidingWindow.new }
|
|
15
|
+
@successes = Hash.new { |successes, light_name| successes[light_name] = SlidingWindow.new }
|
|
16
16
|
|
|
17
|
-
@recovery_probe_errors = Hash.new { |
|
|
18
|
-
@recovery_probe_successes = Hash.new { |
|
|
17
|
+
@recovery_probe_errors = Hash.new { |recovery_probe_errors, light_name| recovery_probe_errors[light_name] = SlidingWindow.new }
|
|
18
|
+
@recovery_probe_successes = Hash.new { |recovery_probe_successes, light_name| recovery_probe_successes[light_name] = SlidingWindow.new }
|
|
19
19
|
|
|
20
20
|
@metadata = Hash.new { |h, k| h[k] = Metadata.new }
|
|
21
21
|
super # MonitorMixin
|
|
@@ -32,65 +32,39 @@ module Stoplight
|
|
|
32
32
|
light_name = config.name
|
|
33
33
|
|
|
34
34
|
synchronize do
|
|
35
|
-
current_time =
|
|
36
|
-
|
|
35
|
+
current_time = self.current_time
|
|
36
|
+
recovery_window_start = (current_time - config.cool_off_time)
|
|
37
37
|
recovered_at = @metadata[light_name].recovered_at
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
(window_start..current_time)
|
|
38
|
+
window_start = if config.window_size
|
|
39
|
+
[recovered_at, (current_time - config.window_size)].compact.max
|
|
41
40
|
else
|
|
42
|
-
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
errors = @errors[config.name].count do |request_time|
|
|
46
|
-
window.cover?(request_time)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
successes = @successes[config.name].count do |request_time|
|
|
50
|
-
window.cover?(request_time)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
recovery_probe_errors = @recovery_probe_errors[config.name].count do |request_time|
|
|
54
|
-
recovery_window.cover?(request_time)
|
|
55
|
-
end
|
|
56
|
-
recovery_probe_successes = @recovery_probe_successes[config.name].count do |request_time|
|
|
57
|
-
recovery_window.cover?(request_time)
|
|
41
|
+
current_time
|
|
58
42
|
end
|
|
59
43
|
|
|
60
44
|
@metadata[light_name].with(
|
|
61
45
|
current_time:,
|
|
62
|
-
errors
|
|
63
|
-
successes
|
|
64
|
-
recovery_probe_errors
|
|
65
|
-
recovery_probe_successes:
|
|
46
|
+
errors: @errors[config.name].sum_in_window(window_start),
|
|
47
|
+
successes: @successes[config.name].sum_in_window(window_start),
|
|
48
|
+
recovery_probe_errors: @recovery_probe_errors[config.name].sum_in_window(recovery_window_start),
|
|
49
|
+
recovery_probe_successes: @recovery_probe_successes[config.name].sum_in_window(recovery_window_start)
|
|
66
50
|
)
|
|
67
51
|
end
|
|
68
52
|
end
|
|
69
53
|
|
|
70
|
-
# @param metrics [<Time>]
|
|
71
|
-
# @param window_size [Numeric, nil]
|
|
72
|
-
# @return [void]
|
|
73
|
-
def cleanup(metrics, window_size:)
|
|
74
|
-
min_age = Time.now - [window_size&.*(3), METRICS_RETENTION_TIME].compact.min
|
|
75
|
-
|
|
76
|
-
metrics.reject! { _1 < min_age }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
54
|
# @param config [Stoplight::Light::Config]
|
|
80
55
|
# @param failure [Stoplight::Failure]
|
|
81
56
|
# @return [Stoplight::Metadata]
|
|
82
57
|
def record_failure(config, failure)
|
|
58
|
+
current_time = self.current_time
|
|
83
59
|
light_name = config.name
|
|
84
60
|
|
|
85
61
|
synchronize do
|
|
86
|
-
@errors[light_name].
|
|
87
|
-
|
|
88
|
-
cleanup(@errors[light_name], window_size: config.window_size)
|
|
62
|
+
@errors[light_name].increment if config.window_size
|
|
89
63
|
|
|
90
64
|
metadata = @metadata[light_name]
|
|
91
|
-
@metadata[light_name] = if metadata.last_error_at.nil? ||
|
|
65
|
+
@metadata[light_name] = if metadata.last_error_at.nil? || current_time > metadata.last_error_at
|
|
92
66
|
metadata.with(
|
|
93
|
-
last_error_at:
|
|
67
|
+
last_error_at: current_time,
|
|
94
68
|
last_error: failure,
|
|
95
69
|
consecutive_errors: metadata.consecutive_errors.succ,
|
|
96
70
|
consecutive_successes: 0
|
|
@@ -106,20 +80,18 @@ module Stoplight
|
|
|
106
80
|
end
|
|
107
81
|
|
|
108
82
|
# @param config [Stoplight::Light::Config]
|
|
109
|
-
# @param request_id [String]
|
|
110
|
-
# @param request_time [Time]
|
|
111
83
|
# @return [void]
|
|
112
|
-
def record_success(config
|
|
84
|
+
def record_success(config)
|
|
113
85
|
light_name = config.name
|
|
86
|
+
current_time = self.current_time
|
|
114
87
|
|
|
115
88
|
synchronize do
|
|
116
|
-
@successes[light_name].
|
|
117
|
-
cleanup(@successes[light_name], window_size: config.window_size)
|
|
89
|
+
@successes[light_name].increment if config.window_size
|
|
118
90
|
|
|
119
91
|
metadata = @metadata[light_name]
|
|
120
|
-
@metadata[light_name] = if metadata.last_success_at.nil? ||
|
|
92
|
+
@metadata[light_name] = if metadata.last_success_at.nil? || current_time > metadata.last_success_at
|
|
121
93
|
metadata.with(
|
|
122
|
-
last_success_at:
|
|
94
|
+
last_success_at: current_time,
|
|
123
95
|
consecutive_errors: 0,
|
|
124
96
|
consecutive_successes: metadata.consecutive_successes.succ
|
|
125
97
|
)
|
|
@@ -137,15 +109,15 @@ module Stoplight
|
|
|
137
109
|
# @return [Stoplight::Metadata]
|
|
138
110
|
def record_recovery_probe_failure(config, failure)
|
|
139
111
|
light_name = config.name
|
|
112
|
+
current_time = self.current_time
|
|
140
113
|
|
|
141
114
|
synchronize do
|
|
142
|
-
@recovery_probe_errors[light_name].
|
|
143
|
-
cleanup(@recovery_probe_errors[light_name], window_size: config.cool_off_time)
|
|
115
|
+
@recovery_probe_errors[light_name].increment
|
|
144
116
|
|
|
145
117
|
metadata = @metadata[light_name]
|
|
146
|
-
@metadata[light_name] = if metadata.last_error_at.nil? ||
|
|
118
|
+
@metadata[light_name] = if metadata.last_error_at.nil? || current_time > metadata.last_error_at
|
|
147
119
|
metadata.with(
|
|
148
|
-
last_error_at:
|
|
120
|
+
last_error_at: current_time,
|
|
149
121
|
last_error: failure,
|
|
150
122
|
consecutive_errors: metadata.consecutive_errors.succ,
|
|
151
123
|
consecutive_successes: 0
|
|
@@ -161,20 +133,18 @@ module Stoplight
|
|
|
161
133
|
end
|
|
162
134
|
|
|
163
135
|
# @param config [Stoplight::Light::Config]
|
|
164
|
-
# @param request_id [String]
|
|
165
|
-
# @param request_time [Time]
|
|
166
136
|
# @return [Stoplight::Metadata]
|
|
167
|
-
def record_recovery_probe_success(config
|
|
137
|
+
def record_recovery_probe_success(config)
|
|
168
138
|
light_name = config.name
|
|
139
|
+
current_time = self.current_time
|
|
169
140
|
|
|
170
141
|
synchronize do
|
|
171
|
-
@recovery_probe_successes[light_name].
|
|
172
|
-
cleanup(@recovery_probe_successes[light_name], window_size: config.cool_off_time)
|
|
142
|
+
@recovery_probe_successes[light_name].increment
|
|
173
143
|
|
|
174
144
|
metadata = @metadata[light_name]
|
|
175
|
-
@metadata[light_name] = if metadata.last_success_at.nil? ||
|
|
145
|
+
@metadata[light_name] = if metadata.last_success_at.nil? || current_time > metadata.last_success_at
|
|
176
146
|
metadata.with(
|
|
177
|
-
last_success_at:
|
|
147
|
+
last_success_at: current_time,
|
|
178
148
|
consecutive_errors: 0,
|
|
179
149
|
consecutive_successes: metadata.consecutive_successes.succ
|
|
180
150
|
)
|
|
@@ -210,16 +180,15 @@ module Stoplight
|
|
|
210
180
|
#
|
|
211
181
|
# @param config [Stoplight::Light::Config] The light configuration
|
|
212
182
|
# @param color [String] The color to transition to ("GREEN", "YELLOW", or "RED")
|
|
213
|
-
# @param current_time [Time]
|
|
214
183
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
215
|
-
def transition_to_color(config, color
|
|
184
|
+
def transition_to_color(config, color)
|
|
216
185
|
case color
|
|
217
186
|
when Color::GREEN
|
|
218
187
|
transition_to_green(config)
|
|
219
188
|
when Color::YELLOW
|
|
220
|
-
transition_to_yellow(config
|
|
189
|
+
transition_to_yellow(config)
|
|
221
190
|
when Color::RED
|
|
222
|
-
transition_to_red(config
|
|
191
|
+
transition_to_red(config)
|
|
223
192
|
else
|
|
224
193
|
raise ArgumentError, "Invalid color: #{color}"
|
|
225
194
|
end
|
|
@@ -229,8 +198,9 @@ module Stoplight
|
|
|
229
198
|
#
|
|
230
199
|
# @param config [Stoplight::Light::Config] The light configuration
|
|
231
200
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
232
|
-
private def transition_to_green(config
|
|
201
|
+
private def transition_to_green(config)
|
|
233
202
|
light_name = config.name
|
|
203
|
+
current_time = self.current_time
|
|
234
204
|
|
|
235
205
|
synchronize do
|
|
236
206
|
metadata = @metadata[light_name]
|
|
@@ -251,10 +221,10 @@ module Stoplight
|
|
|
251
221
|
# Transitions to YELLOW (recovery) state and ensures only one notification
|
|
252
222
|
#
|
|
253
223
|
# @param config [Stoplight::Light::Config] The light configuration
|
|
254
|
-
# @param current_time [Time]
|
|
255
224
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
256
|
-
private def transition_to_yellow(config
|
|
225
|
+
private def transition_to_yellow(config)
|
|
257
226
|
light_name = config.name
|
|
227
|
+
current_time = self.current_time
|
|
258
228
|
|
|
259
229
|
synchronize do
|
|
260
230
|
metadata = @metadata[light_name]
|
|
@@ -280,10 +250,10 @@ module Stoplight
|
|
|
280
250
|
# Transitions to RED state and ensures only one notification
|
|
281
251
|
#
|
|
282
252
|
# @param config [Stoplight::Light::Config] The light configuration
|
|
283
|
-
# @param current_time [Time]
|
|
284
253
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
285
|
-
private def transition_to_red(config
|
|
254
|
+
private def transition_to_red(config)
|
|
286
255
|
light_name = config.name
|
|
256
|
+
current_time = self.current_time
|
|
287
257
|
recovery_scheduled_after = current_time + config.cool_off_time
|
|
288
258
|
|
|
289
259
|
synchronize do
|
|
@@ -306,6 +276,10 @@ module Stoplight
|
|
|
306
276
|
end
|
|
307
277
|
end
|
|
308
278
|
end
|
|
279
|
+
|
|
280
|
+
private def current_time
|
|
281
|
+
Time.now
|
|
282
|
+
end
|
|
309
283
|
end
|
|
310
284
|
end
|
|
311
285
|
end
|
|
@@ -94,7 +94,6 @@ module Stoplight
|
|
|
94
94
|
def get_metadata(config)
|
|
95
95
|
detect_clock_skew
|
|
96
96
|
|
|
97
|
-
current_time = Time.now
|
|
98
97
|
window_end_ts = current_time.to_i
|
|
99
98
|
window_start_ts = window_end_ts - [config.window_size, Base::METRICS_RETENTION_TIME].compact.min.to_i
|
|
100
99
|
recovery_window_start_ts = window_end_ts - config.cool_off_time.to_i
|
|
@@ -147,7 +146,7 @@ module Stoplight
|
|
|
147
146
|
# @param failure [Stoplight::Failure] The failure to record.
|
|
148
147
|
# @return [Stoplight::Metadata] The updated metadata after recording the failure.
|
|
149
148
|
def record_failure(config, failure)
|
|
150
|
-
current_ts =
|
|
149
|
+
current_ts = current_time.to_i
|
|
151
150
|
failure_json = failure.to_json
|
|
152
151
|
|
|
153
152
|
@redis.then do |client|
|
|
@@ -163,16 +162,16 @@ module Stoplight
|
|
|
163
162
|
get_metadata(config)
|
|
164
163
|
end
|
|
165
164
|
|
|
166
|
-
def record_success(config, request_id: SecureRandom.hex(12)
|
|
167
|
-
|
|
165
|
+
def record_success(config, request_id: SecureRandom.hex(12))
|
|
166
|
+
current_ts = current_time.to_i
|
|
168
167
|
|
|
169
168
|
@redis.then do |client|
|
|
170
169
|
client.evalsha(
|
|
171
170
|
record_success_sha,
|
|
172
|
-
argv: [
|
|
171
|
+
argv: [current_ts, request_id, metrics_ttl, metadata_ttl],
|
|
173
172
|
keys: [
|
|
174
173
|
metadata_key(config),
|
|
175
|
-
config.window_size && successes_key(config, time:
|
|
174
|
+
config.window_size && successes_key(config, time: current_ts)
|
|
176
175
|
].compact
|
|
177
176
|
)
|
|
178
177
|
end
|
|
@@ -184,7 +183,7 @@ module Stoplight
|
|
|
184
183
|
# @param failure [Failure] The failure to record.
|
|
185
184
|
# @return [Stoplight::Metadata] The updated metadata after recording the failure.
|
|
186
185
|
def record_recovery_probe_failure(config, failure)
|
|
187
|
-
current_ts =
|
|
186
|
+
current_ts = current_time.to_i
|
|
188
187
|
failure_json = failure.to_json
|
|
189
188
|
|
|
190
189
|
@redis.then do |client|
|
|
@@ -204,18 +203,17 @@ module Stoplight
|
|
|
204
203
|
#
|
|
205
204
|
# @param config [Stoplight::Light::Config] The light configuration.
|
|
206
205
|
# @param request_id [String] The unique identifier for the request
|
|
207
|
-
# @param request_time [Time] The time of the request
|
|
208
206
|
# @return [Stoplight::Metadata] The updated metadata after recording the success.
|
|
209
|
-
def record_recovery_probe_success(config, request_id: SecureRandom.hex(12)
|
|
210
|
-
|
|
207
|
+
def record_recovery_probe_success(config, request_id: SecureRandom.hex(12))
|
|
208
|
+
current_ts = current_time.to_i
|
|
211
209
|
|
|
212
210
|
@redis.then do |client|
|
|
213
211
|
client.evalsha(
|
|
214
212
|
record_success_sha,
|
|
215
|
-
argv: [
|
|
213
|
+
argv: [current_ts, request_id, metrics_ttl, metadata_ttl],
|
|
216
214
|
keys: [
|
|
217
215
|
metadata_key(config),
|
|
218
|
-
recovery_probe_successes_key(config, time:
|
|
216
|
+
recovery_probe_successes_key(config, time: current_ts)
|
|
219
217
|
].compact
|
|
220
218
|
)
|
|
221
219
|
end
|
|
@@ -237,18 +235,15 @@ module Stoplight
|
|
|
237
235
|
#
|
|
238
236
|
# @param config [Stoplight::Light::Config] The light configuration
|
|
239
237
|
# @param color [String] The color to transition to ("green", "yellow", or "red")
|
|
240
|
-
# @param current_time [Time] Current timestamp
|
|
241
238
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
242
|
-
def transition_to_color(config, color
|
|
243
|
-
current_time.to_i
|
|
244
|
-
|
|
239
|
+
def transition_to_color(config, color)
|
|
245
240
|
case color
|
|
246
241
|
when Color::GREEN
|
|
247
242
|
transition_to_green(config)
|
|
248
243
|
when Color::YELLOW
|
|
249
|
-
transition_to_yellow(config
|
|
244
|
+
transition_to_yellow(config)
|
|
250
245
|
when Color::RED
|
|
251
|
-
transition_to_red(config
|
|
246
|
+
transition_to_red(config)
|
|
252
247
|
else
|
|
253
248
|
raise ArgumentError, "Invalid color: #{color}"
|
|
254
249
|
end
|
|
@@ -258,7 +253,7 @@ module Stoplight
|
|
|
258
253
|
#
|
|
259
254
|
# @param config [Stoplight::Light::Config] The light configuration
|
|
260
255
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
261
|
-
private def transition_to_green(config
|
|
256
|
+
private def transition_to_green(config)
|
|
262
257
|
current_ts = current_time.to_i
|
|
263
258
|
meta_key = metadata_key(config)
|
|
264
259
|
|
|
@@ -275,9 +270,8 @@ module Stoplight
|
|
|
275
270
|
# Transitions to YELLOW (recovery) state and ensures only one notification
|
|
276
271
|
#
|
|
277
272
|
# @param config [Stoplight::Light::Config] The light configuration
|
|
278
|
-
# @param current_time [Time] Current timestamp
|
|
279
273
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
280
|
-
private def transition_to_yellow(config
|
|
274
|
+
private def transition_to_yellow(config)
|
|
281
275
|
current_ts = current_time.to_i
|
|
282
276
|
meta_key = metadata_key(config)
|
|
283
277
|
|
|
@@ -294,9 +288,8 @@ module Stoplight
|
|
|
294
288
|
# Transitions to RED state and ensures only one notification
|
|
295
289
|
#
|
|
296
290
|
# @param config [Stoplight::Light::Config] The light configuration
|
|
297
|
-
# @param current_time [Time] Current timestamp
|
|
298
291
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
299
|
-
private def transition_to_red(config
|
|
292
|
+
private def transition_to_red(config)
|
|
300
293
|
current_ts = current_time.to_i
|
|
301
294
|
meta_key = metadata_key(config)
|
|
302
295
|
recovery_scheduled_after_ts = current_ts + config.cool_off_time
|
|
@@ -399,7 +392,7 @@ module Stoplight
|
|
|
399
392
|
return unless should_sample?(0.01) # 1% chance
|
|
400
393
|
|
|
401
394
|
redis_seconds, _redis_millis = @redis.then(&:time)
|
|
402
|
-
app_seconds =
|
|
395
|
+
app_seconds = current_time.to_i
|
|
403
396
|
if (redis_seconds - app_seconds).abs > SKEW_TOLERANCE
|
|
404
397
|
warn("Detected clock skew between Redis and the application server. Redis time: #{redis_seconds}, Application time: #{app_seconds}. See https://github.com/bolshakov/stoplight/wiki/Clock-Skew-and-Stoplight-Reliability")
|
|
405
398
|
end
|
|
@@ -444,6 +437,10 @@ module Stoplight
|
|
|
444
437
|
client.script("load", Lua::RECORD_FAILURE)
|
|
445
438
|
end
|
|
446
439
|
end
|
|
440
|
+
|
|
441
|
+
private def current_time
|
|
442
|
+
Time.now
|
|
443
|
+
end
|
|
447
444
|
end
|
|
448
445
|
end
|
|
449
446
|
end
|
data/lib/stoplight/error.rb
CHANGED
|
@@ -5,6 +5,35 @@ module Stoplight
|
|
|
5
5
|
Base = Class.new(StandardError)
|
|
6
6
|
ConfigurationError = Class.new(Base)
|
|
7
7
|
IncorrectColor = Class.new(Base)
|
|
8
|
-
RedLight
|
|
8
|
+
class RedLight < Base
|
|
9
|
+
# @!attribute light_name
|
|
10
|
+
# @return [String] The light's name
|
|
11
|
+
attr_reader :light_name
|
|
12
|
+
|
|
13
|
+
# @!attribute cool_off_time
|
|
14
|
+
# @return [Numeric] Cool-off period in seconds
|
|
15
|
+
attr_reader :cool_off_time
|
|
16
|
+
|
|
17
|
+
# @!attribute retry_after
|
|
18
|
+
# @return [Time] Absolute Time after which a recovery attempt can occur
|
|
19
|
+
attr_reader :retry_after
|
|
20
|
+
|
|
21
|
+
# Initializes a new RedLight error.
|
|
22
|
+
#
|
|
23
|
+
# @param light_name [String] The light's name
|
|
24
|
+
#
|
|
25
|
+
# @option cool_off_time [Numeric] Cool-off period in seconds
|
|
26
|
+
#
|
|
27
|
+
# @option retry_after [Time] Absolute Time after which a recovery attempt can occur
|
|
28
|
+
#
|
|
29
|
+
# @return [Stoplight::Error::RedLight]
|
|
30
|
+
def initialize(light_name, cool_off_time:, retry_after:)
|
|
31
|
+
@light_name = light_name
|
|
32
|
+
@cool_off_time = cool_off_time
|
|
33
|
+
@retry_after = retry_after
|
|
34
|
+
|
|
35
|
+
super(light_name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
9
38
|
end
|
|
10
39
|
end
|
|
@@ -19,7 +19,11 @@ module Stoplight
|
|
|
19
19
|
if fallback
|
|
20
20
|
fallback.call(nil)
|
|
21
21
|
else
|
|
22
|
-
raise Error::RedLight
|
|
22
|
+
raise Error::RedLight.new(
|
|
23
|
+
config.name,
|
|
24
|
+
cool_off_time: config.cool_off_time,
|
|
25
|
+
retry_after: metadata.recovery_scheduled_after
|
|
26
|
+
)
|
|
23
27
|
end
|
|
24
28
|
end
|
|
25
29
|
end
|
|
@@ -14,13 +14,13 @@ module Stoplight
|
|
|
14
14
|
# reach the threshold.
|
|
15
15
|
#
|
|
16
16
|
# @example With window-based configuration
|
|
17
|
-
# traffic_control = Stoplight::
|
|
17
|
+
# traffic_control = Stoplight::TrafficControl::ConsecutiveErrors.new
|
|
18
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
|
-
# traffic_control = Stoplight::
|
|
23
|
+
# traffic_control = Stoplight::TrafficControl::ConsecutiveErrors.new
|
|
24
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
|
|
@@ -5,14 +5,14 @@ module Stoplight
|
|
|
5
5
|
# A strategy that stops the traffic based on error rate.
|
|
6
6
|
#
|
|
7
7
|
# @example
|
|
8
|
-
# traffic_control = Stoplight::
|
|
8
|
+
# traffic_control = Stoplight::TrafficControl::ErrorRate.new
|
|
9
9
|
# config = Stoplight::Light::Config.new(threshold: 0.6, window_size: 300, traffic_control:)
|
|
10
10
|
#
|
|
11
11
|
# Will switch to red if 60% error rate reached within the 5-minute (300 seconds) sliding window.
|
|
12
12
|
# By default this traffic control strategy starts evaluating only after 10 requests have been made. You can
|
|
13
13
|
# adjust this by passing a different value for `min_requests` when initializing the strategy.
|
|
14
14
|
#
|
|
15
|
-
# traffic_control = Stoplight::
|
|
15
|
+
# traffic_control = Stoplight::TrafficControl::ErrorRate.new(min_requests: 100)
|
|
16
16
|
#
|
|
17
17
|
# @api private
|
|
18
18
|
class ErrorRate < Base
|
data/lib/stoplight/version.rb
CHANGED
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cameron Desautels
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- lib/stoplight/data_store/base.rb
|
|
68
68
|
- lib/stoplight/data_store/fail_safe.rb
|
|
69
69
|
- lib/stoplight/data_store/memory.rb
|
|
70
|
+
- lib/stoplight/data_store/memory/sliding_window.rb
|
|
70
71
|
- lib/stoplight/data_store/redis.rb
|
|
71
72
|
- lib/stoplight/data_store/redis/get_metadata.lua
|
|
72
73
|
- lib/stoplight/data_store/redis/lua.rb
|