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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc53b4c9ddcf5c348f93838536633fac3016058065db1b2cc2a7f0f3b2848b50
4
- data.tar.gz: 5df8b82142c4b2174f671a1041666c02cfc71f04556135c3b2db011032d6a805
3
+ metadata.gz: 58a785fcf8b070442e6a6c0c94800303f540a155296497d6ae3cf10784635dc0
4
+ data.tar.gz: 9061b700576f0fb8c7b20ce6a70b8ebb5a7a268d566e69cba7cdd41323f26e2b
5
5
  SHA512:
6
- metadata.gz: 361994d3aa900d24f4f248ea5bd48d0a390da49ef2ec97e354f069a953092b9142c55458d8702ec7a520eace4f45180df4935b1523d8c067fd0e31a85d640f89
7
- data.tar.gz: 3f71e9a5bbd244abb592f66d8dfaa5100e8e198e52c5e4f92877e6dc3db62af9859f3c071b1b27bd3b924d8615163bf9e8f8291225d6b1a60d6ecb6c7886be9a
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.8.1...HEAD
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
- # Allow one attempt through
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module RetryKit
5
- VERSION = '0.8.1'
5
+ VERSION = '0.9.0'
6
6
  end
7
7
  end
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.8.1
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-06-15 00:00:00.000000000 Z
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