stoplight 5.3.1 → 5.3.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d9c236d51c7ba9b67ccbc0be19c1836b90b2a1b1f6b9e38cdfcfa28cfd85d5e
4
- data.tar.gz: 85f04de041217bfd4ad1d0892d637847db62dba16e3dab8b00b5cb87b0c73725
3
+ metadata.gz: e14702003b76fde01b28e5266b392f5d55b2924c83365a0ab122f11aebf2e4a7
4
+ data.tar.gz: e9c1b348b2637aa66408de62a2bbbc24b9b73927ca9712a8b4137446e336cd0a
5
5
  SHA512:
6
- metadata.gz: 6c5f9dfe0b88fb11ce8e2f830d73101ad39ed5a3c398d08e0ab4afd90c55246a4f62717f1eca4c1e463669ef123d8496e8525fc935b84f323ec7c15469d1db24
7
- data.tar.gz: 267bac02ac372e70646820ffee6e6209fe6de8fd26658c2975cd08f079676cb5862e41a5154e1f58d6897f4ede79b5284fad1678310cc458867fcedc5466f874
6
+ metadata.gz: 1601dd8a7fae2e9c8f89a52556c8f931d3712d71648300aef0e3dae226c9998b7b1fa35e7fa0ce151d943e961473e012ca382b718bb50b44000f5a43e4d667b8
7
+ data.tar.gz: 7c4e9696009e01bea1908cd3212d4625cc0ac3e03205d1cd69e4471f9aec4f3add7f101e58b393df210658e6be2c2d42b5d4a2ddedd954f832053505e291f232
data/README.md CHANGED
@@ -3,9 +3,8 @@
3
3
  [![Version badge][]][version]
4
4
  [![Build badge][]][build]
5
5
  [![Coverage badge][]][coverage]
6
- [![Climate badge][]][climate]
7
6
 
8
- Stoplight is traffic control for code. It's an implementation of the circuit breaker pattern in Ruby.
7
+ Stoplight is a traffic control for code. It's an implementation of the circuit breaker pattern in Ruby.
9
8
 
10
9
  ---
11
10
 
@@ -103,7 +102,7 @@ light.color # => "red"
103
102
  After one minute, the light transitions to yellow, allowing a test execution:
104
103
 
105
104
  ```ruby
106
- # Wait for the cool off time
105
+ # Wait for the cool-off time
107
106
  sleep 60
108
107
  light.run { 1 / 1 } #=> 1
109
108
  ```
@@ -130,7 +129,7 @@ receives `nil`. In both cases, the return value of the fallback becomes the retu
130
129
 
131
130
  ## Admin Panel
132
131
 
133
- Stoplight goes 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.
132
+ 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.
134
133
 
135
134
  To add Admin Panel protected by basic authentication to your Rails project, add this configuration to your `config/routes.rb` file.
136
135
 
@@ -378,7 +377,12 @@ single successful recovery probe will resume traffic flow.
378
377
 
379
378
  ### Data Store
380
379
 
381
- Stoplight uses an in-memory data store out of the box:
380
+ Stoplight officially supports three data stores:
381
+ - In-memory data store
382
+ - Redis
383
+ - Valkey
384
+
385
+ By default, Stoplight uses an in-memory data store:
382
386
 
383
387
  ```ruby
384
388
  require "stoplight"
@@ -386,7 +390,9 @@ Stoplight::Default::DATA_STORE
386
390
  # => #<Stoplight::DataStore::Memory:...>
387
391
  ```
388
392
 
389
- For production environments, you'll likely want to use a persistent data store. Currently, [Redis] is the supported option:
393
+ #### Redis for Production
394
+
395
+ For production environments, you'll likely want to use a persistent data store. One of the supported options is [Redis].
390
396
 
391
397
  ```ruby
392
398
  # Configure Redis as the data store
@@ -399,9 +405,32 @@ Stoplight.configure do |config|
399
405
  end
400
406
  ```
401
407
 
402
- #### Connection Pooling with Redis
408
+ #### Valkey Support
403
409
 
404
- For high-traffic applications or when you want to control a number of opened connections to Redis:
410
+ Stoplight also supports [Valkey], a drop-in replacement for Redis.
411
+ Just point your Redis client to a Valkey instance and configure Stoplight as usual:
412
+
413
+ ```ruby
414
+ # ...
415
+ # We assume that Valkey is available on 127.0.0.1:6379 address
416
+ valkey = Redis.new(url: "redis://127.0.0.1:6379")
417
+ data_store = Stoplight::DataStore::Redis.new(valkey)
418
+
419
+ Stoplight.configure do |config|
420
+ config.data_store = data_store
421
+ # ...
422
+ end
423
+ ```
424
+
425
+ #### DragonflyDB Support
426
+
427
+ Although Stoplight does not officially support [DragonflyDB], it can be used with it. For details, you may refer to the official [DragonflyDB documentation].
428
+
429
+ **NOTE**: Compatibility with [DragonflyDB] is not guaranteed, and results may vary. However, you are welcome to contribute to the project if you find any issues.
430
+
431
+ #### Connection Pooling
432
+
433
+ For high-traffic applications or when you want to control the number of open connections to the Data Store:
405
434
 
406
435
  ```ruby
407
436
  require "connection_pool"
@@ -415,7 +444,7 @@ end
415
444
 
416
445
  ### Notifiers
417
446
 
418
- Stoplight notifies when lights change state. Configure how these notifications are delivered:
447
+ Stoplight notifies when the lights change state. Configure how these notifications are delivered:
419
448
 
420
449
  ```ruby
421
450
  # Log to a specific logger
@@ -540,9 +569,45 @@ stoplight = Stoplight("test-#{rand}")
540
569
 
541
570
  ## Maintenance Policy
542
571
 
543
- Stoplight supports the latest three minor versions of Ruby, which currently are: `3.2.x`, `3.3.x`, and `3.4.x`. Changing
544
- the minimum supported Ruby version is not considered a breaking change. We support the current stable Redis
545
- version (`7.4.x`) and the latest release of the previous major version (`6.2.x`)
572
+ We focus on supporting current, secure versions rather than maintaining extensive backwards compatibility. We follow
573
+ semantic versioning and give reasonable notice before dropping support.
574
+
575
+ ### Stoplight Version Support
576
+
577
+ We only actively support the latest major version of Stoplight.
578
+
579
+ * ✅ Bug fixes and new features go to the current major version only (e.g., if you're on 4.x, upgrade to 5.x for fixes)
580
+ * ✅ Upgrade guidance is provided in release notes for major version changes
581
+ * ✅ We won't break compatibility in patch/minor releases
582
+ * ✅ We may accept community-contributed security patches for the previous major version
583
+ * ❌ We don't backport fixes to old Stoplight major versions
584
+ * ❌ We don't add new features to old Stoplight major versions
585
+
586
+ ### What We Support
587
+
588
+ **Ruby**: Major versions that receive security updates (see [Ruby Maintenance Branches]):
589
+
590
+ * Currently: Ruby 3.2.x, 3.3.x and 3.4.x
591
+ * We test against these versions in CI
592
+
593
+ **Data Stores**: Current supported versions from upstream (versions that receive security updates):
594
+
595
+ * Redis: 8.0.x, 7.4.x, 7.2.x, 6.2.x (following [Redis's support policy])
596
+ * Valkey: 8.0.x, 7.2.x (following [Valkey's support policy])
597
+ * We test against the latest version of each major release
598
+
599
+ For dependencies:
600
+ * ✅ We test all supported dependency combinations in CI
601
+ * ✅ We investigate bug reports on supported dependency versions
602
+ * ❌ We don't test unsupported dependency versions (e.g., Ruby 3.1, Redis 5.x)
603
+ * ❌ We don't fix bugs specific to unsupported dependencies
604
+
605
+ ### When We Drop Support
606
+
607
+ * Ruby: When Ruby core team ends security support, we drop it in our next major release
608
+ * Data Stores: When Redis/Valkey ends maintenance, we drop it in our next major release
609
+
610
+ Example: "Ruby 3.2 reaches end-of-life in March 2026, so Stoplight 6.0 will require Ruby 3.3+"
546
611
 
547
612
  ## Development
548
613
 
@@ -563,14 +628,12 @@ Fowler’s [CircuitBreaker][] article.
563
628
  [build]: https://github.com/bolshakov/stoplight/actions?query=branch%3Amaster
564
629
  [Coverage badge]: https://img.shields.io/coveralls/bolshakov/stoplight/master.svg?label=coverage
565
630
  [coverage]: https://coveralls.io/r/bolshakov/stoplight
566
- [Climate badge]: https://api.codeclimate.com/v1/badges/3451c2d281ffa345441a/maintainability
567
- [climate]: https://codeclimate.com/github/bolshakov/stoplight
568
631
  [stoplight-admin]: https://github.com/bolshakov/stoplight-admin
569
632
  [Semantic Versioning]: http://semver.org/spec/v2.0.0.html
570
633
  [the change log]: CHANGELOG.md
571
634
  [stoplight-sentry]: https://github.com/bolshakov/stoplight-sentry
572
635
  [stoplight-honeybadger]: https://github.com/qoqa/stoplight-honeybadger
573
- [notifier interface documentation]: https://github.com/bolshakov/stoplight/blob/master/lib/stoplight/notifier/generic.rb
636
+ [notifier interface documentation]: https://github.com/bolshakov/stoplight/blob/main/lib/stoplight/notifier/generic.rb
574
637
  [camdez]: https://github.com/camdez
575
638
  [tfausak]: https://github.com/tfausak
576
639
  [bolshakov]: https://github.com/bolshakov
@@ -579,3 +642,9 @@ Fowler’s [CircuitBreaker][] article.
579
642
  [CircuitBreaker]: http://martinfowler.com/bliki/CircuitBreaker.html
580
643
  [Redis]: https://redis.io/
581
644
  [Git Flow wiki page]: https://github.com/bolshakov/stoplight/wiki/Git-Flow
645
+ [Valkey]: https://valkey.io/
646
+ [Ruby Maintenance Branches]: https://www.ruby-lang.org/en/downloads/branches/
647
+ [Redis's support policy]: https://redis.io/about/releases/
648
+ [Valkey's support policy]: https://valkey.io/topics/releases/
649
+ [DragonflyDB]: https://www.dragonflydb.io/
650
+ [DragonflyDB documentation]: https://www.dragonflydb.io/docs/managing-dragonfly/scripting#script-flags
@@ -54,7 +54,7 @@ module Stoplight
54
54
  end
55
55
 
56
56
  def record_failure(config, failure)
57
- with_fallback(nil, config) do
57
+ with_fallback(Metadata.new, config) do
58
58
  data_store.record_failure(config, failure)
59
59
  end
60
60
  end
@@ -66,13 +66,13 @@ module Stoplight
66
66
  end
67
67
 
68
68
  def record_recovery_probe_success(config, **args)
69
- with_fallback(nil, config) do
69
+ with_fallback(Metadata.new, config) do
70
70
  data_store.record_recovery_probe_success(config, **args)
71
71
  end
72
72
  end
73
73
 
74
74
  def record_recovery_probe_failure(config, failure)
75
- with_fallback(nil, config) do
75
+ with_fallback(Metadata.new, config) do
76
76
  data_store.record_recovery_probe_failure(config, failure)
77
77
  end
78
78
  end
@@ -7,6 +7,7 @@ module Stoplight
7
7
  # @see Base
8
8
  class Memory < Base
9
9
  include MonitorMixin
10
+
10
11
  KEY_SEPARATOR = ":"
11
12
 
12
13
  def initialize
@@ -29,16 +30,16 @@ module Stoplight
29
30
  # @return [Stoplight::Metadata]
30
31
  def get_metadata(config)
31
32
  light_name = config.name
32
- window_end = Time.now
33
- recovery_window = (window_end - config.cool_off_time + 1)..window_end
34
33
 
35
34
  synchronize do
35
+ current_time = Time.now
36
+ recovery_window = (current_time - config.cool_off_time)..current_time
36
37
  recovered_at = @metadata[light_name].recovered_at
37
38
  window = if config.window_size
38
- window_start = [recovered_at, (window_end - config.window_size + 1)].compact.max
39
- (window_start..window_end)
39
+ window_start = [recovered_at, (current_time - config.window_size)].compact.max
40
+ (window_start..current_time)
40
41
  else
41
- (..window_end)
42
+ (..current_time)
42
43
  end
43
44
 
44
45
  errors = @errors[config.name].count do |request_time|
@@ -57,6 +58,7 @@ module Stoplight
57
58
  end
58
59
 
59
60
  @metadata[light_name].with(
61
+ current_time:,
60
62
  errors:,
61
63
  successes:,
62
64
  recovery_probe_errors:,
@@ -170,17 +172,14 @@ module Stoplight
170
172
  cleanup(@recovery_probe_successes[light_name], window_size: config.cool_off_time)
171
173
 
172
174
  metadata = @metadata[light_name]
173
- recovery_started_at = metadata.recovery_started_at || request_time
174
175
  @metadata[light_name] = if metadata.last_success_at.nil? || request_time > metadata.last_success_at
175
176
  metadata.with(
176
177
  last_success_at: request_time,
177
- recovery_started_at:,
178
178
  consecutive_errors: 0,
179
179
  consecutive_successes: metadata.consecutive_successes.succ
180
180
  )
181
181
  else
182
182
  metadata.with(
183
- recovery_started_at:,
184
183
  consecutive_errors: 0,
185
184
  consecutive_successes: metadata.consecutive_successes.succ
186
185
  )
@@ -259,9 +258,7 @@ module Stoplight
259
258
 
260
259
  synchronize do
261
260
  metadata = @metadata[light_name]
262
- if metadata.recovery_started_at
263
- false
264
- else
261
+ if metadata.recovery_started_at.nil?
265
262
  @metadata[light_name] = metadata.with(
266
263
  recovery_started_at: current_time,
267
264
  recovery_scheduled_after: nil,
@@ -269,6 +266,13 @@ module Stoplight
269
266
  breached_at: nil
270
267
  )
271
268
  true
269
+ else
270
+ @metadata[light_name] = metadata.with(
271
+ recovery_scheduled_after: nil,
272
+ recovered_at: nil,
273
+ breached_at: nil
274
+ )
275
+ false
272
276
  end
273
277
  end
274
278
  end
@@ -11,7 +11,7 @@ local metadata_key = KEYS[1]
11
11
  -- we need to limit the start time of the window to the time of the last recovery.
12
12
  local recovered_at = redis.call('HGET', metadata_key, "recovered_at")
13
13
  if recovered_at then
14
- window_start_ts = math.max(window_start_ts, recovered_at)
14
+ window_start_ts = math.max(window_start_ts, tonumber(recovered_at))
15
15
  end
16
16
 
17
17
  local function count_events(start_idx, bucket_count, start_ts)
@@ -94,8 +94,8 @@ module Stoplight
94
94
  def get_metadata(config)
95
95
  detect_clock_skew
96
96
 
97
- window_end = Time.now
98
- window_end_ts = window_end.to_i
97
+ current_time = Time.now
98
+ window_end_ts = current_time.to_i
99
99
  window_start_ts = window_end_ts - [config.window_size, Base::METRICS_RETENTION_TIME].compact.min.to_i
100
100
  recovery_window_start_ts = window_end_ts - config.cool_off_time.to_i
101
101
 
@@ -133,10 +133,11 @@ module Stoplight
133
133
  last_error = normalize_failure(last_error_json, config.error_notifier) if last_error_json
134
134
 
135
135
  Metadata.new(
136
- successes: successes,
137
- errors: errors,
138
- recovery_probe_successes: recovery_probe_successes,
139
- recovery_probe_errors: recovery_probe_errors,
136
+ current_time:,
137
+ successes:,
138
+ errors:,
139
+ recovery_probe_successes:,
140
+ recovery_probe_errors:,
140
141
  last_error:,
141
142
  **meta_hash
142
143
  )
@@ -12,10 +12,11 @@ module Stoplight
12
12
  # Executes the provided code block when the light is in the green state.
13
13
  #
14
14
  # @param fallback [Proc, nil] A fallback proc to execute in case of an error.
15
+ # @param metadata [Stoplight::Metadata] Metadata capturing the current state of the light.
15
16
  # @yield The code block to execute.
16
17
  # @return [Object] The result of the code block if successful.
17
18
  # @raise [Exception] Re-raises the error if it is not tracked or no fallback is provided.
18
- def execute(fallback, &code)
19
+ def execute(fallback, metadata:, &code)
19
20
  # TODO: Consider implementing sampling rate to limit the memory footprint
20
21
  code.call.tap { record_success }
21
22
  rescue => error
@@ -12,9 +12,10 @@ module Stoplight
12
12
  # Executes the fallback proc when the light is in the red state.
13
13
  #
14
14
  # @param fallback [Proc, nil] A fallback proc to execute instead of the code block.
15
+ # @param metadata [Stoplight::Metadata] Metadata capturing the current state of the light.
15
16
  # @return [Object, nil] The result of the fallback proc if provided.
16
17
  # @raise [Stoplight::Error::RedLight] Raises an error if no fallback is provided.
17
- def execute(fallback)
18
+ def execute(fallback, metadata:)
18
19
  if fallback
19
20
  fallback.call(nil)
20
21
  else
@@ -22,7 +22,9 @@ module Stoplight
22
22
  @data_store = config.data_store
23
23
  end
24
24
 
25
- def execute(fallback, &code)
25
+ # @param fallback [Proc, nil] A fallback proc to execute in case of an error.
26
+ # @param metadata [Stoplight::Metadata] Metadata capturing the current state of the light.
27
+ def execute(fallback, metadata:, &code)
26
28
  raise NotImplementedError, "Subclasses must implement the execute method"
27
29
  end
28
30
  end
@@ -13,10 +13,12 @@ module Stoplight
13
13
  # Executes the provided code block when the light is in the yellow state.
14
14
  #
15
15
  # @param fallback [Proc, nil] A fallback proc to execute in case of an error.
16
+ # @param metadata [Stoplight::Metadata] Metadata capturing the current state of the light.
16
17
  # @yield The code block to execute.
17
18
  # @return [Object] The result of the code block if successful.
18
19
  # @raise [Exception] Re-raises the error if it is not tracked or no fallback is provided.
19
- def execute(fallback, &code)
20
+ def execute(fallback, metadata:, &code)
21
+ transition_to_yellow(metadata:)
20
22
  # TODO: We need to employ a probabilistic approach here to avoid "thundering herd" problem
21
23
  code.call.tap { record_recovery_probe_success }
22
24
  rescue => error
@@ -47,28 +49,42 @@ module Stoplight
47
49
  recover(metadata)
48
50
  end
49
51
 
52
+ # @param metadata [Stoplight::Metadata]
53
+ # @return [void]
54
+ def transition_to_yellow(metadata:)
55
+ return unless metadata.color == Color::YELLOW
56
+
57
+ if metadata.recovery_scheduled_after && config.data_store.transition_to_color(config, Color::YELLOW)
58
+ config.notifiers.each do |notifier|
59
+ notifier.notify(config, Color::RED, Color::YELLOW, nil)
60
+ end
61
+ end
62
+ end
63
+
50
64
  private def recover(metadata)
51
65
  recovery_result = config.traffic_recovery.determine_color(config, metadata)
52
66
 
53
67
  case recovery_result
54
- when Color::GREEN
68
+ when TrafficRecovery::GREEN
55
69
  if data_store.transition_to_color(config, Color::GREEN)
56
70
  config.notifiers.each do |notifier|
57
71
  notifier.notify(config, Color::YELLOW, Color::GREEN, nil)
58
72
  end
59
73
  end
60
- when Color::YELLOW
74
+ when TrafficRecovery::YELLOW
61
75
  if data_store.transition_to_color(config, Color::YELLOW)
62
76
  config.notifiers.each do |notifier|
63
- notifier.notify(config, Color::GREEN, Color::YELLOW, nil)
77
+ notifier.notify(config, Color::RED, Color::YELLOW, nil)
64
78
  end
65
79
  end
66
- when Color::RED
80
+ when TrafficRecovery::RED
67
81
  if data_store.transition_to_color(config, Color::RED)
68
82
  config.notifiers.each do |notifier|
69
83
  notifier.notify(config, Color::YELLOW, Color::RED, nil)
70
84
  end
71
85
  end
86
+ when TrafficRecovery::PASS
87
+ # No state change, do nothing
72
88
  else
73
89
  raise "recovery strategy returned an expected color: #{recovery_result}"
74
90
  end
@@ -32,10 +32,7 @@ module Stoplight
32
32
  #
33
33
  # @return [String]
34
34
  def state
35
- config
36
- .data_store
37
- .get_metadata(config)
38
- .locked_state
35
+ metadata.locked_state
39
36
  end
40
37
 
41
38
  # Returns current color:
@@ -49,10 +46,7 @@ module Stoplight
49
46
  #
50
47
  # @return [String] returns current light color
51
48
  def color
52
- config
53
- .data_store
54
- .get_metadata(config)
55
- .color
49
+ metadata.color
56
50
  end
57
51
 
58
52
  # Runs the given block of code with this circuit breaker
@@ -72,8 +66,10 @@ module Stoplight
72
66
  def run(fallback = nil, &code)
73
67
  raise ArgumentError, "nothing to run. Please, pass a block into `Light#run`" unless block_given?
74
68
 
75
- strategy = state_strategy_factory(color)
76
- strategy.execute(fallback, &code)
69
+ metadata.then do |metadata|
70
+ strategy = state_strategy_factory(metadata.color)
71
+ strategy.execute(fallback, metadata:, &code)
72
+ end
77
73
  end
78
74
 
79
75
  # Locks light in either +State::LOCKED_RED+ or +State::LOCKED_GREEN+
@@ -186,5 +182,10 @@ module Stoplight
186
182
  def reconfigure(config)
187
183
  self.class.new(config)
188
184
  end
185
+
186
+ # @return [Stoplight::Metadata]
187
+ def metadata
188
+ config.data_store.get_metadata(config)
189
+ end
189
190
  end
190
191
  end
@@ -16,13 +16,15 @@ module Stoplight
16
16
  :locked_state,
17
17
  :recovery_scheduled_after,
18
18
  :recovery_started_at,
19
- :recovered_at
19
+ :recovered_at,
20
+ :current_time
20
21
  ) do
21
22
  def initialize(
22
- successes: nil,
23
- errors: nil,
24
- recovery_probe_successes: nil,
25
- recovery_probe_errors: nil,
23
+ current_time: Time.now,
24
+ successes: 0,
25
+ errors: 0,
26
+ recovery_probe_successes: 0,
27
+ recovery_probe_errors: 0,
26
28
  last_error_at: nil,
27
29
  last_success_at: nil,
28
30
  consecutive_errors: 0,
@@ -35,31 +37,41 @@ module Stoplight
35
37
  recovered_at: nil
36
38
  )
37
39
  super(
38
- recovery_probe_successes:,
39
- recovery_probe_errors:,
40
- successes:,
41
- errors:,
40
+ recovery_probe_successes: recovery_probe_successes.to_i,
41
+ recovery_probe_errors: recovery_probe_errors.to_i,
42
+ successes: successes.to_i,
43
+ errors: errors.to_i,
42
44
  last_error_at: (Time.at(Integer(last_error_at)) if last_error_at),
43
45
  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
+ consecutive_errors: consecutive_errors.to_i,
47
+ consecutive_successes: consecutive_successes.to_i,
46
48
  last_error:,
47
49
  breached_at: (Time.at(Integer(breached_at)) if breached_at),
48
50
  locked_state: locked_state || State::UNLOCKED,
49
51
  recovery_scheduled_after: (Time.at(Integer(recovery_scheduled_after)) if recovery_scheduled_after),
50
52
  recovery_started_at: (Time.at(Integer(recovery_started_at)) if recovery_started_at),
51
53
  recovered_at: (Time.at(Integer(recovered_at)) if recovered_at),
54
+ current_time:,
52
55
  )
53
56
  end
54
57
 
55
- # @param at [Time] (Time.now) the moment of time when the color is determined
58
+ # Creates a new Metadata instance with updated attributes. This method overrides
59
+ # the default +with+ method provided by +Data.define+ to ensure constructor
60
+ # logic is applied.
61
+ #
62
+ # @param kwargs [Hash{Symbol => Object}]
63
+ # @return [Metadata]
64
+ def with(**kwargs)
65
+ self.class.new(**to_h.merge(current_time: Time.now, **kwargs))
66
+ end
67
+
56
68
  # @return [String] one of +Color::GREEN+, +Color::RED+, or +Color::YELLOW+
57
- def color(at: Time.now)
69
+ def color
58
70
  if locked_state == State::LOCKED_GREEN
59
71
  Color::GREEN
60
72
  elsif locked_state == State::LOCKED_RED
61
73
  Color::RED
62
- elsif (recovery_scheduled_after && recovery_scheduled_after < at) || recovery_started_at
74
+ elsif (recovery_scheduled_after && recovery_scheduled_after < current_time) || recovery_started_at
63
75
  Color::YELLOW
64
76
  elsif breached_at
65
77
  Color::RED
@@ -72,7 +84,7 @@ module Stoplight
72
84
  #
73
85
  # @return [Float]
74
86
  def error_rate
75
- if successes.nil? || errors.nil? || (successes + errors).zero?
87
+ if (successes + errors).zero?
76
88
  0.0
77
89
  else
78
90
  errors.fdiv(successes + errors)
@@ -49,10 +49,7 @@ module Stoplight
49
49
  #
50
50
  # @param config [Stoplight::Light::Config]
51
51
  # @param metadata [Stoplight::Metadata]
52
- # @return [String] One of the Stoplight::Color constants:
53
- # - Stoplight::Color::RED: Recovery failed, block all traffic
54
- # - Stoplight::Color::YELLOW: Continue recovery process
55
- # - Stoplight::Color::GREEN: Recovery successful, return to normal traffic flow
52
+ # @return [TrafficRecovery::Decision]
56
53
  def determine_color(config, metadata)
57
54
  raise NotImplementedError
58
55
  end
@@ -49,16 +49,18 @@ module Stoplight
49
49
  #
50
50
  # @param config [Stoplight::Light::Config]
51
51
  # @param metadata [Stoplight::Metadata]
52
- # @return [String]
52
+ # @return [TrafficRecovery::Decision]
53
53
  def determine_color(config, metadata)
54
+ return TrafficRecovery::PASS if metadata.color != Color::YELLOW
55
+
54
56
  recovery_started_at = metadata.recovery_started_at || metadata.recovery_scheduled_after
55
57
 
56
58
  if metadata.last_error_at && metadata.last_error_at >= recovery_started_at
57
- Color::RED
59
+ TrafficRecovery::RED
58
60
  elsif [metadata.consecutive_successes, metadata.recovery_probe_successes].min >= config.recovery_threshold
59
- Color::GREEN
61
+ TrafficRecovery::GREEN
60
62
  else
61
- Color::YELLOW
63
+ TrafficRecovery::YELLOW
62
64
  end
63
65
  end
64
66
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module TrafficRecovery
5
+ Decision = Data.define(:decision)
6
+ GREEN = Decision.new("green")
7
+ YELLOW = Decision.new("yellow")
8
+ RED = Decision.new("red")
9
+ PASS = Decision.new("pass")
10
+ end
11
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stoplight
4
- VERSION = Gem::Version.new("5.3.1")
4
+ VERSION = Gem::Version.new("5.3.8")
5
5
  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.3.1
4
+ version: 5.3.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cameron Desautels
@@ -97,6 +97,7 @@ files:
97
97
  - lib/stoplight/traffic_control/base.rb
98
98
  - lib/stoplight/traffic_control/consecutive_errors.rb
99
99
  - lib/stoplight/traffic_control/error_rate.rb
100
+ - lib/stoplight/traffic_recovery.rb
100
101
  - lib/stoplight/traffic_recovery/base.rb
101
102
  - lib/stoplight/traffic_recovery/consecutive_successes.rb
102
103
  - lib/stoplight/version.rb