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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb6f9448eb54f6b7d540f0fbce8e867316e559c7b65f0c4f22d25d2380a1d913
4
- data.tar.gz: 504733ba0b309f42dd95a7a79de9113b5caac7d84172d9e2b239e467b366c335
3
+ metadata.gz: 5396e04754dc4723f9c7b19fa77aa89661ad290f580a4c2c7450a61b91af6805
4
+ data.tar.gz: c64abc10a925d3dffdac7c879a007e4199b8807946e3f3488813cb8dd7b2a8ee
5
5
  SHA512:
6
- metadata.gz: 7f49a0afb554d1142b0372c1da8fa1c0165cdbefe538b6af73987c1aeecc35a3c2725e6fd5f7272dd1091b46e48ee0b23f0f09c5dd70d24bced6ab49bf61a3d5
7
- data.tar.gz: e75f35e7fc516765fee442e48119b00e5f6e1395a98b0edd3b3d152004ff65e9411a8efac276c4c1c44ad2c71b3160c568031ad7169ea627f55bc34fa895789b
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
- # Number of attempts in the last execution.
8
- # @return [Integer]
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
- execute_attempt(&)
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
- raise e if attempt + 1 >= @max_attempts
44
+ [nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - start, e]
45
+ end
56
46
 
57
- wait_and_retry(e, attempt, &)
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
- if @circuit_breaker
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
- raw = backoff_delay(attempt)
100
- Backoff.jitter(raw, mode: @jitter)
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module RetryKit
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -21,5 +21,6 @@ end
21
21
 
22
22
  require_relative "retry_kit/version"
23
23
  require_relative "retry_kit/backoff"
24
+ require_relative "retry_kit/budget"
24
25
  require_relative "retry_kit/circuit_breaker"
25
26
  require_relative "retry_kit/executor"
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.2.2
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-16 00:00:00.000000000 Z
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