philiprehberger-retry_kit 0.8.1 → 0.9.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/CHANGELOG.md +11 -1
- data/README.md +6 -0
- data/lib/philiprehberger/retry_kit/circuit_breaker.rb +11 -1
- data/lib/philiprehberger/retry_kit/executor.rb +49 -1
- data/lib/philiprehberger/retry_kit/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58a785fcf8b070442e6a6c0c94800303f540a155296497d6ae3cf10784635dc0
|
|
4
|
+
data.tar.gz: 9061b700576f0fb8c7b20ce6a70b8ebb5a7a268d566e69cba7cdd41323f26e2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 496f17ab9a3170de508f54919596f64824ac8cb9c2612ad073e541bdd6fa296aa46d6a0dacae580f75991fbc23c0643718270d5120763e17633ac4201e93bb9e
|
|
7
|
+
data.tar.gz: abdf61bc3a020b1d1df72ea03bc306267fc3bfa2252c874b277d658c974d0627ede10910cd31708e226105173064d3125e4b37591e67a7fb619d9751b5b72cec
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-07-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Fail-fast option validation in `Executor#initialize` — an unknown `backoff:` or `jitter:` symbol, `max_attempts < 1`, a negative `base_delay`, or a `max_delay` below `base_delay` now raise `ArgumentError` at construction time instead of silently succeeding or misbehaving later.
|
|
14
|
+
- Single-trial half-open circuit breaker — when the circuit transitions to half-open only the first caller probes; concurrent callers receive `OpenError` until the probe resolves (success closes the circuit, failure re-opens it), preventing a thundering herd against a recovering dependency.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Clamp retry sleeps to the remaining `total_timeout` / `deadline` budget — `wait_and_retry` no longer sleeps a full backoff delay when the budget is nearly exhausted (e.g. a 30s backoff under a 5s `total_timeout`). The delay is capped to the time remaining, and `TotalTimeoutError` / `DeadlineExceededError` is raised immediately when no budget remains.
|
|
18
|
+
|
|
10
19
|
## [0.8.1] - 2026-06-14
|
|
11
20
|
|
|
12
21
|
### Changed
|
|
@@ -124,7 +133,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
124
133
|
- Retry executor with max attempts, retryable error filtering, and on_retry callback
|
|
125
134
|
- Convenience `RetryKit.run` class method
|
|
126
135
|
|
|
127
|
-
[Unreleased]: https://github.com/philiprehberger/rb-retry-kit/compare/v0.
|
|
136
|
+
[Unreleased]: https://github.com/philiprehberger/rb-retry-kit/compare/v0.9.0...HEAD
|
|
137
|
+
[0.9.0]: https://github.com/philiprehberger/rb-retry-kit/compare/v0.8.1...v0.9.0
|
|
128
138
|
[0.8.1]: https://github.com/philiprehberger/rb-retry-kit/compare/v0.8.0...v0.8.1
|
|
129
139
|
[0.8.0]: https://github.com/philiprehberger/rb-retry-kit/compare/v0.7.0...v0.8.0
|
|
130
140
|
[0.7.0]: https://github.com/philiprehberger/rb-retry-kit/compare/v0.6.0...v0.7.0
|
data/README.md
CHANGED
|
@@ -52,6 +52,8 @@ Philiprehberger::RetryKit.run(
|
|
|
52
52
|
end
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
Options are validated up front. `Executor.new` / `RetryKit.run` raise `ArgumentError` immediately for an unknown `backoff:` strategy or `jitter:` mode, `max_attempts` below 1, a negative `base_delay`, or a `max_delay` smaller than `base_delay` — so a misconfiguration surfaces at construction time instead of silently succeeding.
|
|
56
|
+
|
|
55
57
|
### Retry Callback
|
|
56
58
|
|
|
57
59
|
```ruby
|
|
@@ -78,6 +80,8 @@ Philiprehberger::RetryKit.run(
|
|
|
78
80
|
end
|
|
79
81
|
```
|
|
80
82
|
|
|
83
|
+
Backoff sleeps are clamped to the remaining budget: if a computed delay would sleep past `total_timeout` (or `deadline`), it is shortened to the time left, and `TotalTimeoutError` / `DeadlineExceededError` is raised as soon as no budget remains — the executor never overshoots the stated bound by sleeping a full backoff delay.
|
|
84
|
+
|
|
81
85
|
### Absolute Deadline
|
|
82
86
|
|
|
83
87
|
Stop retrying once a specific wall-clock `Time` has passed. Useful when the caller has a hard SLA (e.g. "respond before the request times out") rather than a relative budget:
|
|
@@ -292,6 +296,8 @@ breaker.reset # reset to closed
|
|
|
292
296
|
breaker.trip! # force open immediately (operational kill-switch)
|
|
293
297
|
```
|
|
294
298
|
|
|
299
|
+
After the cooldown elapses the circuit becomes half-open and probes with a **single trial**: only the first caller is allowed through, and concurrent callers receive `OpenError` until the probe resolves. A successful probe closes the circuit; a failed probe re-opens it and starts a fresh cooldown. This prevents a thundering herd from hammering a dependency that is still recovering.
|
|
300
|
+
|
|
295
301
|
### Backoff Utilities
|
|
296
302
|
|
|
297
303
|
```ruby
|
|
@@ -23,6 +23,7 @@ module Philiprehberger
|
|
|
23
23
|
@failure_count = 0
|
|
24
24
|
@state = :closed
|
|
25
25
|
@last_failure_time = nil
|
|
26
|
+
@half_open_in_flight = false
|
|
26
27
|
@mutex = Mutex.new
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -50,6 +51,7 @@ module Philiprehberger
|
|
|
50
51
|
def reset
|
|
51
52
|
@mutex.synchronize do
|
|
52
53
|
@failure_count = 0
|
|
54
|
+
@half_open_in_flight = false
|
|
53
55
|
transition_to(:closed)
|
|
54
56
|
@last_failure_time = nil
|
|
55
57
|
end
|
|
@@ -63,6 +65,7 @@ module Philiprehberger
|
|
|
63
65
|
def trip!
|
|
64
66
|
@mutex.synchronize do
|
|
65
67
|
@failure_count = @failure_threshold
|
|
68
|
+
@half_open_in_flight = false
|
|
66
69
|
@last_failure_time = Time.now
|
|
67
70
|
transition_to(:open)
|
|
68
71
|
end
|
|
@@ -79,14 +82,20 @@ module Philiprehberger
|
|
|
79
82
|
end
|
|
80
83
|
|
|
81
84
|
transition_to(:half_open)
|
|
85
|
+
@half_open_in_flight = true
|
|
82
86
|
when :half_open
|
|
83
|
-
#
|
|
87
|
+
# Only the first caller probes; concurrent callers are rejected
|
|
88
|
+
# until the probe resolves (success closes, failure re-opens).
|
|
89
|
+
raise OpenError, 'Circuit is half-open (probe in progress)' if @half_open_in_flight
|
|
90
|
+
|
|
91
|
+
@half_open_in_flight = true
|
|
84
92
|
end
|
|
85
93
|
end
|
|
86
94
|
|
|
87
95
|
def record_success
|
|
88
96
|
@mutex.synchronize do
|
|
89
97
|
@failure_count = 0
|
|
98
|
+
@half_open_in_flight = false
|
|
90
99
|
transition_to(:closed)
|
|
91
100
|
@last_failure_time = nil
|
|
92
101
|
end
|
|
@@ -95,6 +104,7 @@ module Philiprehberger
|
|
|
95
104
|
def record_failure
|
|
96
105
|
@mutex.synchronize do
|
|
97
106
|
@failure_count += 1
|
|
107
|
+
@half_open_in_flight = false
|
|
98
108
|
@last_failure_time = Time.now
|
|
99
109
|
|
|
100
110
|
transition_to(:open) if @failure_count >= @failure_threshold
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
module Philiprehberger
|
|
4
4
|
module RetryKit
|
|
5
5
|
class Executor
|
|
6
|
+
VALID_BACKOFFS = %i[exponential linear constant].freeze
|
|
7
|
+
VALID_JITTERS = %i[full equal none decorrelated].freeze
|
|
8
|
+
|
|
6
9
|
attr_reader :last_attempts, :last_total_delay
|
|
7
10
|
|
|
8
11
|
def initialize(**options)
|
|
9
12
|
assign_options(options)
|
|
13
|
+
validate_options!
|
|
10
14
|
@last_attempts = 0
|
|
11
15
|
@last_total_delay = 0.0
|
|
12
16
|
end
|
|
@@ -69,13 +73,36 @@ module Philiprehberger
|
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
def wait_and_retry(error, attempt, &)
|
|
72
|
-
delay = compute_delay(attempt)
|
|
76
|
+
delay = clamp_delay(compute_delay(attempt))
|
|
73
77
|
@last_total_delay += delay
|
|
74
78
|
@on_retry&.call(error, attempt + 1, delay)
|
|
75
79
|
sleep(delay)
|
|
76
80
|
attempt_with_retries(attempt + 1, &)
|
|
77
81
|
end
|
|
78
82
|
|
|
83
|
+
# Cap the computed backoff delay to whatever time budget remains so a
|
|
84
|
+
# long backoff never sleeps past +total_timeout+/+deadline+. Raises the
|
|
85
|
+
# appropriate error immediately when the budget is already exhausted
|
|
86
|
+
# rather than sleeping.
|
|
87
|
+
def clamp_delay(delay)
|
|
88
|
+
check_total_timeout!
|
|
89
|
+
check_deadline!
|
|
90
|
+
|
|
91
|
+
[delay, remaining_timeout, remaining_deadline].compact.min
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def remaining_timeout
|
|
95
|
+
return nil unless @start_time
|
|
96
|
+
|
|
97
|
+
@total_timeout - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def remaining_deadline
|
|
101
|
+
return nil unless @deadline
|
|
102
|
+
|
|
103
|
+
@deadline - Time.now
|
|
104
|
+
end
|
|
105
|
+
|
|
79
106
|
def assign_options(options)
|
|
80
107
|
assign_core_options(options)
|
|
81
108
|
assign_callback_options(options)
|
|
@@ -103,6 +130,27 @@ module Philiprehberger
|
|
|
103
130
|
@deadline = options[:deadline]
|
|
104
131
|
end
|
|
105
132
|
|
|
133
|
+
# Fail fast on invalid configuration so misconfigured retries surface at
|
|
134
|
+
# construction time instead of silently succeeding or misbehaving later.
|
|
135
|
+
def validate_options!
|
|
136
|
+
unless VALID_BACKOFFS.include?(@backoff)
|
|
137
|
+
raise ArgumentError, "Unknown backoff strategy: #{@backoff.inspect}. Use :exponential, :linear, or :constant"
|
|
138
|
+
end
|
|
139
|
+
unless VALID_JITTERS.include?(@jitter)
|
|
140
|
+
raise ArgumentError, "Unknown jitter mode: #{@jitter.inspect}. Use :full, :equal, :none, or :decorrelated"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
validate_numeric_options!
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_numeric_options!
|
|
147
|
+
raise ArgumentError, "max_attempts must be >= 1 (got #{@max_attempts})" if @max_attempts < 1
|
|
148
|
+
raise ArgumentError, "base_delay must be >= 0 (got #{@base_delay})" if @base_delay.negative?
|
|
149
|
+
return unless @max_delay < @base_delay
|
|
150
|
+
|
|
151
|
+
raise ArgumentError, "max_delay (#{@max_delay}) must be >= base_delay (#{@base_delay})"
|
|
152
|
+
end
|
|
153
|
+
|
|
106
154
|
def check_total_timeout!
|
|
107
155
|
return unless @start_time
|
|
108
156
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-retry_kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philip Rehberger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A lightweight retry library with exponential/linear/constant backoff,
|
|
14
14
|
configurable jitter strategies, and an optional circuit breaker for resilient Ruby
|