philiprehberger-retry_kit 0.2.2 → 0.3.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 +15 -5
- data/README.md +84 -0
- data/lib/philiprehberger/retry_kit/backoff.rb +10 -0
- data/lib/philiprehberger/retry_kit/budget.rb +64 -0
- data/lib/philiprehberger/retry_kit/executor.rb +53 -34
- data/lib/philiprehberger/retry_kit/version.rb +1 -1
- data/lib/philiprehberger/retry_kit.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5396e04754dc4723f9c7b19fa77aa89661ad290f580a4c2c7450a61b91af6805
|
|
4
|
+
data.tar.gz: c64abc10a925d3dffdac7c879a007e4199b8807946e3f3488813cb8dd7b2a8ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aee34894d6b70dcbf1e05bf320da0d32090ed3f64bdded686f38115013f4a6532b32015347a2ba939981bb466cfa06403485cf08455fe8213791de35f616a97f
|
|
7
|
+
data.tar.gz: 97aaf45c7d40ad94c87c87cd9680c4a8a8e9de6efffea4ea2c4a8fe08bfca97a94044a7f44b175f2bd09ea4bc713bf6c43dc121b36355ad79e4cf009f8f4e780
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.2.2
|
|
4
|
-
|
|
5
|
-
- Add License badge to README
|
|
6
|
-
- Add bug_tracker_uri to gemspec
|
|
7
|
-
|
|
8
3
|
All notable changes to this gem will be documented in this file.
|
|
9
4
|
|
|
10
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
@@ -12,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
12
7
|
|
|
13
8
|
## [Unreleased]
|
|
14
9
|
|
|
10
|
+
## [0.3.0] - 2026-03-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Decorrelated jitter mode (`jitter: :decorrelated`) — AWS-style algorithm with better delay distribution
|
|
14
|
+
- Fallback handler (`fallback:`) — execute alternative code when all retries are exhausted instead of raising
|
|
15
|
+
- Retry predicate (`retry_if:`) — custom predicate for fine-grained retry decisions beyond exception class filtering
|
|
16
|
+
- Per-attempt callback (`on_attempt:`) — hook called after every attempt for metrics/logging
|
|
17
|
+
- Retry budget (`Budget`) — thread-safe sliding window counter shared across executors to prevent retry storms
|
|
18
|
+
|
|
19
|
+
## [0.2.2] - 2026-03-12
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Add License badge to README
|
|
23
|
+
- Add bug_tracker_uri to gemspec
|
|
24
|
+
|
|
15
25
|
## [0.2.1] - 2026-03-12
|
|
16
26
|
|
|
17
27
|
### Fixed
|
data/README.md
CHANGED
|
@@ -94,6 +94,85 @@ executor.last_attempts # => 3 (number of attempts made)
|
|
|
94
94
|
executor.last_total_delay # => 3.5 (total seconds spent in backoff sleeps)
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
### Decorrelated Jitter
|
|
98
|
+
|
|
99
|
+
AWS-style decorrelated jitter provides better delay distribution than full or equal jitter:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
Philiprehberger::RetryKit.run(
|
|
103
|
+
backoff: :exponential,
|
|
104
|
+
jitter: :decorrelated,
|
|
105
|
+
base_delay: 0.5,
|
|
106
|
+
max_delay: 30
|
|
107
|
+
) do
|
|
108
|
+
api_call
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Each delay is randomized between `base_delay` and `3 * last_sleep`, capped at `max_delay`.
|
|
113
|
+
|
|
114
|
+
### Fallback Handler
|
|
115
|
+
|
|
116
|
+
Execute alternative code when all retries are exhausted instead of raising:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
result = Philiprehberger::RetryKit.run(
|
|
120
|
+
max_attempts: 3,
|
|
121
|
+
fallback: ->(error) { default_value }
|
|
122
|
+
) do
|
|
123
|
+
unreliable_call
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The fallback proc receives the last error and its return value becomes the result of `run`.
|
|
128
|
+
|
|
129
|
+
### Retry Predicate
|
|
130
|
+
|
|
131
|
+
Custom predicate for fine-grained retry decisions beyond exception class filtering:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
Philiprehberger::RetryKit.run(
|
|
135
|
+
retry_if: ->(error, attempt) { error.message.include?("timeout") && attempt < 3 }
|
|
136
|
+
) do
|
|
137
|
+
api_call
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
If `retry_if` returns false, retrying stops immediately even if `max_attempts` has not been reached. Works in addition to the `on:` exception class filter (both must pass).
|
|
142
|
+
|
|
143
|
+
### Per-Attempt Callback
|
|
144
|
+
|
|
145
|
+
Hook called after every attempt (not just retries) for metrics and logging:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
Philiprehberger::RetryKit.run(
|
|
149
|
+
on_attempt: ->(attempt, duration, error) {
|
|
150
|
+
puts "Attempt #{attempt} took #{duration}s#{error ? " (failed: #{error.message})" : ""}"
|
|
151
|
+
}
|
|
152
|
+
) do
|
|
153
|
+
api_call
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Called after each attempt with: attempt number (1-based), duration in seconds, and error (`nil` on success).
|
|
158
|
+
|
|
159
|
+
### Retry Budget
|
|
160
|
+
|
|
161
|
+
Global retry budget shared across executors to prevent retry storms:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
budget = Philiprehberger::RetryKit::Budget.new(max_retries: 100, window: 60)
|
|
165
|
+
|
|
166
|
+
# Multiple executors share the budget
|
|
167
|
+
Philiprehberger::RetryKit.run(budget: budget) { call_a }
|
|
168
|
+
Philiprehberger::RetryKit.run(budget: budget) { call_b }
|
|
169
|
+
|
|
170
|
+
budget.remaining # => remaining retry count
|
|
171
|
+
budget.exhausted? # => true/false
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Thread-safe sliding window counter. When the budget is exhausted, retries are skipped and the error is raised immediately (or the fallback is invoked if provided).
|
|
175
|
+
|
|
97
176
|
### Backoff Strategies
|
|
98
177
|
|
|
99
178
|
```ruby
|
|
@@ -155,6 +234,11 @@ Philiprehberger::RetryKit::Backoff.jitter(4.0, mode: :full)
|
|
|
155
234
|
| `Backoff.linear(attempt, base_delay:, max_delay:)` | Calculate linear delay |
|
|
156
235
|
| `Backoff.constant(attempt, delay:)` | Calculate constant delay |
|
|
157
236
|
| `Backoff.jitter(delay, mode:)` | Apply jitter to a delay (`:full`, `:equal`, `:none`) |
|
|
237
|
+
| `Backoff.decorrelated(last_delay, base_delay:, max_delay:)` | Calculate decorrelated jitter delay |
|
|
238
|
+
| `Budget.new(max_retries:, window:)` | Create a shared retry budget |
|
|
239
|
+
| `Budget#acquire` | Consume one retry from the budget |
|
|
240
|
+
| `Budget#remaining` | Remaining retries in the current window |
|
|
241
|
+
| `Budget#exhausted?` | Whether the budget is exhausted |
|
|
158
242
|
| `Executor#last_attempts` | Number of attempts in the last execution |
|
|
159
243
|
| `Executor#last_total_delay` | Total backoff sleep time (seconds) in the last execution |
|
|
160
244
|
| `TotalTimeoutError` | Raised when `total_timeout` is exceeded |
|
|
@@ -54,6 +54,16 @@ module Philiprehberger
|
|
|
54
54
|
raise ArgumentError, "Unknown jitter mode: #{mode}. Use :full, :equal, or :none"
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
|
+
|
|
58
|
+
# Compute decorrelated jitter delay (AWS-style).
|
|
59
|
+
#
|
|
60
|
+
# @param last_delay [Numeric] the previous sleep duration
|
|
61
|
+
# @param base_delay [Numeric] the base delay in seconds
|
|
62
|
+
# @param max_delay [Numeric] the maximum delay cap in seconds
|
|
63
|
+
# @return [Float] decorrelated jitter delay
|
|
64
|
+
def decorrelated(last_delay, base_delay: 0.5, max_delay: 30)
|
|
65
|
+
[max_delay, rand(base_delay.to_f..(last_delay * 3).to_f)].min
|
|
66
|
+
end
|
|
57
67
|
end
|
|
58
68
|
end
|
|
59
69
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module RetryKit
|
|
5
|
+
# Thread-safe sliding window retry budget to prevent retry storms.
|
|
6
|
+
#
|
|
7
|
+
# Multiple executors can share a single budget instance. When the budget
|
|
8
|
+
# is exhausted, retries are skipped and the error is raised immediately.
|
|
9
|
+
class Budget
|
|
10
|
+
# Raised when the retry budget is exhausted.
|
|
11
|
+
class ExhaustedError < Error; end
|
|
12
|
+
|
|
13
|
+
# @param max_retries [Integer] maximum number of retries allowed in the window
|
|
14
|
+
# @param window [Numeric] sliding window duration in seconds
|
|
15
|
+
def initialize(max_retries:, window:)
|
|
16
|
+
@max_retries = max_retries
|
|
17
|
+
@window = window
|
|
18
|
+
@timestamps = []
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Try to consume one retry from the budget.
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean] true if a retry was consumed, false if budget is exhausted
|
|
25
|
+
def acquire
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
prune
|
|
28
|
+
return false if @timestamps.length >= @max_retries
|
|
29
|
+
|
|
30
|
+
@timestamps << now
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the number of remaining retries in the current window.
|
|
36
|
+
#
|
|
37
|
+
# @return [Integer]
|
|
38
|
+
def remaining
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
prune
|
|
41
|
+
@max_retries - @timestamps.length
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns whether the budget is exhausted.
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def exhausted?
|
|
49
|
+
remaining <= 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def prune
|
|
55
|
+
cutoff = now - @window
|
|
56
|
+
@timestamps.reject! { |t| t < cutoff }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def now
|
|
60
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -2,42 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Philiprehberger
|
|
4
4
|
module RetryKit
|
|
5
|
-
# Executes a block with configurable retry logic, backoff, and optional circuit breaker.
|
|
6
5
|
class Executor
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
attr_reader :last_attempts
|
|
10
|
-
|
|
11
|
-
# Total delay (seconds) spent sleeping across retries in the last execution.
|
|
12
|
-
# @return [Float]
|
|
13
|
-
attr_reader :last_total_delay
|
|
14
|
-
|
|
15
|
-
# @param options [Hash] retry configuration options
|
|
16
|
-
# @option options [Integer] :max_attempts (3) maximum number of attempts
|
|
17
|
-
# @option options [Symbol] :backoff (:exponential) backoff strategy
|
|
18
|
-
# @option options [Numeric] :base_delay (0.5) base delay in seconds
|
|
19
|
-
# @option options [Numeric] :max_delay (30) maximum delay cap in seconds
|
|
20
|
-
# @option options [Symbol] :jitter (:full) jitter mode
|
|
21
|
-
# @option options [Array<Class>] :on ([StandardError]) exception classes to retry on
|
|
22
|
-
# @option options [CircuitBreaker, nil] :circuit_breaker (nil) optional circuit breaker
|
|
23
|
-
# @option options [Proc, nil] :on_retry (nil) callback before each retry
|
|
24
|
-
# @option options [Numeric, nil] :total_timeout (nil) max total seconds across all attempts
|
|
6
|
+
attr_reader :last_attempts, :last_total_delay
|
|
7
|
+
|
|
25
8
|
def initialize(**options)
|
|
26
9
|
assign_options(options)
|
|
27
10
|
@last_attempts = 0
|
|
28
11
|
@last_total_delay = 0.0
|
|
29
12
|
end
|
|
30
13
|
|
|
31
|
-
# Execute the block with retry logic.
|
|
32
|
-
#
|
|
33
|
-
# @yield the block to execute
|
|
34
|
-
# @return the block's return value
|
|
35
|
-
# @raise the last exception if all attempts are exhausted
|
|
36
14
|
def call(&block)
|
|
37
15
|
raise ArgumentError, "Block required" unless block
|
|
38
16
|
|
|
39
17
|
@last_attempts = 0
|
|
40
18
|
@last_total_delay = 0.0
|
|
19
|
+
@last_decorrelated_delay = @base_delay
|
|
41
20
|
@start_time = @total_timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
|
|
42
21
|
|
|
43
22
|
attempt_with_retries(0, &block)
|
|
@@ -48,13 +27,39 @@ module Philiprehberger
|
|
|
48
27
|
def attempt_with_retries(attempt, &)
|
|
49
28
|
@last_attempts = attempt + 1
|
|
50
29
|
check_total_timeout!
|
|
51
|
-
|
|
30
|
+
result, duration, error = timed_attempt(&)
|
|
31
|
+
@on_attempt&.call(attempt + 1, duration, error)
|
|
32
|
+
return result unless error
|
|
33
|
+
|
|
34
|
+
handle_failure(error, attempt, &)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def timed_attempt(&)
|
|
38
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
39
|
+
result = execute_attempt(&)
|
|
40
|
+
[result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - start, nil]
|
|
52
41
|
rescue CircuitBreaker::OpenError, TotalTimeoutError
|
|
53
42
|
raise
|
|
54
43
|
rescue *@retryable_errors => e
|
|
55
|
-
|
|
44
|
+
[nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - start, e]
|
|
45
|
+
end
|
|
56
46
|
|
|
57
|
-
|
|
47
|
+
def handle_failure(error, attempt, &)
|
|
48
|
+
if should_stop?(error, attempt)
|
|
49
|
+
return @fallback.call(error) if @fallback
|
|
50
|
+
|
|
51
|
+
raise error
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
wait_and_retry(error, attempt, &)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def should_stop?(error, attempt)
|
|
58
|
+
return true if attempt + 1 >= @max_attempts
|
|
59
|
+
return true if @retry_if && !@retry_if.call(error, attempt + 1)
|
|
60
|
+
return true if @budget && !@budget.acquire
|
|
61
|
+
|
|
62
|
+
false
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
def wait_and_retry(error, attempt, &)
|
|
@@ -66,15 +71,27 @@ module Philiprehberger
|
|
|
66
71
|
end
|
|
67
72
|
|
|
68
73
|
def assign_options(options)
|
|
74
|
+
assign_core_options(options)
|
|
75
|
+
assign_callback_options(options)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def assign_core_options(options)
|
|
69
79
|
@max_attempts = options.fetch(:max_attempts, 3)
|
|
70
80
|
@backoff = options.fetch(:backoff, :exponential)
|
|
71
81
|
@base_delay = options.fetch(:base_delay, 0.5)
|
|
72
82
|
@max_delay = options.fetch(:max_delay, 30)
|
|
73
83
|
@jitter = options.fetch(:jitter, :full)
|
|
74
84
|
@retryable_errors = Array(options.fetch(:on, [StandardError]))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def assign_callback_options(options)
|
|
75
88
|
@circuit_breaker = options[:circuit_breaker]
|
|
76
89
|
@on_retry = options[:on_retry]
|
|
77
90
|
@total_timeout = options[:total_timeout]
|
|
91
|
+
@fallback = options[:fallback]
|
|
92
|
+
@retry_if = options[:retry_if]
|
|
93
|
+
@on_attempt = options[:on_attempt]
|
|
94
|
+
@budget = options[:budget]
|
|
78
95
|
end
|
|
79
96
|
|
|
80
97
|
def check_total_timeout!
|
|
@@ -88,16 +105,18 @@ module Philiprehberger
|
|
|
88
105
|
end
|
|
89
106
|
|
|
90
107
|
def execute_attempt(&)
|
|
91
|
-
|
|
92
|
-
@circuit_breaker.call(&)
|
|
93
|
-
else
|
|
94
|
-
yield
|
|
95
|
-
end
|
|
108
|
+
@circuit_breaker ? @circuit_breaker.call(&) : yield
|
|
96
109
|
end
|
|
97
110
|
|
|
98
111
|
def compute_delay(attempt)
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
return compute_decorrelated_delay if @jitter == :decorrelated
|
|
113
|
+
|
|
114
|
+
Backoff.jitter(backoff_delay(attempt), mode: @jitter)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def compute_decorrelated_delay
|
|
118
|
+
delay = Backoff.decorrelated(@last_decorrelated_delay, base_delay: @base_delay, max_delay: @max_delay)
|
|
119
|
+
@last_decorrelated_delay = delay
|
|
101
120
|
end
|
|
102
121
|
|
|
103
122
|
def backoff_delay(attempt)
|
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.3.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-03-
|
|
11
|
+
date: 2026-03-17 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
|
|
@@ -24,6 +24,7 @@ files:
|
|
|
24
24
|
- README.md
|
|
25
25
|
- lib/philiprehberger/retry_kit.rb
|
|
26
26
|
- lib/philiprehberger/retry_kit/backoff.rb
|
|
27
|
+
- lib/philiprehberger/retry_kit/budget.rb
|
|
27
28
|
- lib/philiprehberger/retry_kit/circuit_breaker.rb
|
|
28
29
|
- lib/philiprehberger/retry_kit/executor.rb
|
|
29
30
|
- lib/philiprehberger/retry_kit/version.rb
|