retriable 3.4.1 → 4.1.1

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.
data/docs/testing.md ADDED
@@ -0,0 +1,212 @@
1
+ # Testing with Retriable
2
+
3
+ `Retriable.with_override` exists primarily for tests. It lets a test force
4
+ retry options like `tries: 1` or `base_interval: 0` so the suite runs quickly
5
+ and predictably, regardless of what the application's `Retriable.configure`
6
+ defaults are.
7
+
8
+ `with_override` is block-scoped: the override is active inside the block and
9
+ restored to its previous value (which is usually "no override") when the block
10
+ exits, even if the block raises. It is also thread-local — overrides set in
11
+ one thread do not affect other threads — so it is safe for parallel test
12
+ runners. See the README for the full API contract.
13
+
14
+ ## RSpec
15
+
16
+ ### Apply an override to every test
17
+
18
+ Use `around(:each)` in `RSpec.configure` so every test in the suite runs inside
19
+ the override. This is the most common pattern:
20
+
21
+ ```ruby
22
+ RSpec.configure do |config|
23
+ config.around(:each) do |example|
24
+ Retriable.with_override(tries: 1, base_interval: 0) do
25
+ example.run
26
+ end
27
+ end
28
+ end
29
+ ```
30
+
31
+ ### Apply an override to a specific context
32
+
33
+ ```ruby
34
+ describe MyClient do
35
+ context "when external calls should not retry" do
36
+ around(:each) do |example|
37
+ Retriable.with_override(tries: 1) { example.run }
38
+ end
39
+
40
+ it "fails fast" do
41
+ # `with_override(tries: 1)` is active here
42
+ end
43
+ end
44
+ end
45
+ ```
46
+
47
+ ### Apply an override to a single test
48
+
49
+ Wrap the test body directly:
50
+
51
+ ```ruby
52
+ it "does the thing without waiting" do
53
+ Retriable.with_override(tries: 1, base_interval: 0) do
54
+ # test body
55
+ end
56
+ end
57
+ ```
58
+
59
+ ### Reusable helper
60
+
61
+ Wrap a common configuration in a helper to keep tests readable:
62
+
63
+ ```ruby
64
+ module RetriableHelpers
65
+ def with_fast_retries(&block)
66
+ Retriable.with_override(tries: 1, base_interval: 0, &block)
67
+ end
68
+ end
69
+
70
+ RSpec.configure do |config|
71
+ config.include RetriableHelpers
72
+ end
73
+
74
+ # In a spec:
75
+ it "does the thing" do
76
+ with_fast_retries do
77
+ # test body
78
+ end
79
+ end
80
+ ```
81
+
82
+ ## Minitest
83
+
84
+ ```ruby
85
+ class MyClientTest < Minitest::Test
86
+ def around
87
+ Retriable.with_override(tries: 1, base_interval: 0) { yield }
88
+ end
89
+
90
+ def test_fails_fast
91
+ # `with_override(tries: 1)` is active here
92
+ end
93
+ end
94
+ ```
95
+
96
+ Older Minitest versions without `around` can wrap the test body directly:
97
+
98
+ ```ruby
99
+ def test_fails_fast
100
+ Retriable.with_override(tries: 1) do
101
+ # test body
102
+ end
103
+ end
104
+ ```
105
+
106
+ ## Short-Circuiting Retriable in Your Test Suite
107
+
108
+ When you are running tests for your app, the default retry behavior (3 tries
109
+ with exponential backoff) makes failing blocks take a long time. To short-circuit
110
+ retries — including calls that pass local options — set `tries: 1` and disable
111
+ backoff using `with_override`.
112
+
113
+ ### Under Rails
114
+
115
+ Keep shared defaults in `Retriable.configure` and apply test-only overrides via
116
+ RSpec's `around` hook (or your test framework's equivalent):
117
+
118
+ ```ruby
119
+ # config/initializers/retriable.rb
120
+ Retriable.configure do |c|
121
+ c.tries = 3
122
+ c.base_interval = 0.5
123
+ c.rand_factor = 0.5
124
+ end
125
+
126
+ # spec/spec_helper.rb (or equivalent)
127
+ RSpec.configure do |config|
128
+ config.around(:each) do |example|
129
+ Retriable.with_override(tries: 1, base_interval: 0, rand_factor: 0) do
130
+ example.run
131
+ end
132
+ end
133
+ end
134
+ ```
135
+
136
+ If a specific test needs normal retry behavior, opt out by running outside the
137
+ `around` hook. The cleanest way is to tag the example and skip the hook for
138
+ tagged examples:
139
+
140
+ ```ruby
141
+ config.around(:each, retriable: :real) { |example| example.run }
142
+ config.around(:each) do |example|
143
+ next example.run if example.metadata[:retriable] == :real
144
+
145
+ Retriable.with_override(tries: 1, base_interval: 0, rand_factor: 0) do
146
+ example.run
147
+ end
148
+ end
149
+
150
+ it "exercises the real retry behavior", retriable: :real do
151
+ # `with_override` is not applied here
152
+ end
153
+ ```
154
+
155
+ ### Overriding Configured Contexts in Tests
156
+
157
+ If you have configured contexts, top-level override values (such as `tries: 1`)
158
+ already take precedence over context-specific values. To override
159
+ context-specific options as well (for example, clearing a context's
160
+ `:intervals` array or shrinking its `:on` exception list), pass `:contexts` to
161
+ `with_override`.
162
+
163
+ Given a configured `google_api` context:
164
+
165
+ ```ruby
166
+ # config/initializers/retriable.rb
167
+ Retriable.configure do |c|
168
+ c.contexts[:google_api] = {
169
+ tries: 5,
170
+ base_interval: 3,
171
+ on: [
172
+ Net::ReadTimeout,
173
+ Signet::AuthorizationError,
174
+ Errno::ECONNRESET,
175
+ OpenSSL::SSL::SSLError,
176
+ ],
177
+ }
178
+ end
179
+ ```
180
+
181
+ You can override both top-level defaults and per-context options in your
182
+ test setup:
183
+
184
+ ```ruby
185
+ RSpec.configure do |config|
186
+ config.around(:each) do |example|
187
+ context_overrides = Retriable.config.contexts.each_key.with_object({}) do |key, h|
188
+ h[key] = { tries: 1, base_interval: 0 }
189
+ end
190
+
191
+ Retriable.with_override(
192
+ multiplier: 1.0,
193
+ rand_factor: 0.0,
194
+ base_interval: 0,
195
+ contexts: context_overrides,
196
+ ) do
197
+ example.run
198
+ end
199
+ end
200
+ end
201
+ ```
202
+
203
+ ## Notes
204
+
205
+ - The override is automatically cleared when the block exits, including when
206
+ the block raises. You do not need to clean up after the block.
207
+ - `with_override` calls nest: an inner block temporarily replaces the active
208
+ override, and the outer override is restored when the inner block exits.
209
+ - Overrides are thread-local. Child threads spawned inside the block do not
210
+ inherit it. If a test spawns background threads that themselves call
211
+ `Retriable.retriable`, wrap each background thread's body in its own
212
+ `with_override` call.
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "exponential_backoff"
4
+ require_relative "validation"
4
5
 
5
6
  module Retriable
6
7
  class Config
8
+ include Validation
9
+
7
10
  ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[
8
11
  sleep_disabled
9
12
  max_elapsed_time
10
13
  intervals
11
- timeout
12
14
  on
13
15
  retry_if
14
16
  on_retry
17
+ on_give_up
15
18
  contexts
16
19
  ]).freeze
17
20
 
@@ -28,10 +31,10 @@ module Retriable
28
31
  @sleep_disabled = false
29
32
  @max_elapsed_time = 900 # 15 min
30
33
  @intervals = nil
31
- @timeout = nil
32
34
  @on = [StandardError]
33
35
  @retry_if = nil
34
36
  @on_retry = nil
37
+ @on_give_up = nil
35
38
  @contexts = {}
36
39
 
37
40
  opts.each do |k, v|
@@ -39,12 +42,61 @@ module Retriable
39
42
 
40
43
  instance_variable_set(:"@#{k}", v)
41
44
  end
45
+
46
+ validate!
42
47
  end
43
48
 
44
49
  def to_h
45
- ATTRIBUTES.each_with_object({}) do |key, hash|
46
- hash[key] = public_send(key)
50
+ ATTRIBUTES.to_h { |key| [key, public_send(key)] }
51
+ end
52
+
53
+ def validate!
54
+ validate_callable(:retry_if, retry_if)
55
+ validate_callable(:on_retry, on_retry)
56
+ validate_callable(:on_give_up, on_give_up)
57
+ validate_on(on)
58
+ validate_intervals
59
+ if unbounded_tries?(tries)
60
+ validate_unbounded_tries
61
+ else
62
+ validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
63
+ return if intervals
64
+
65
+ validate_positive_integer(:tries, tries)
66
+ end
67
+
68
+ validate_backoff_options
69
+ end
70
+
71
+ private
72
+
73
+ def validate_backoff_options
74
+ validate_non_negative_number(:base_interval, base_interval)
75
+ validate_non_negative_number(:multiplier, multiplier)
76
+ validate_non_negative_number(:max_interval, max_interval)
77
+ validate_rand_factor
78
+ end
79
+
80
+ def validate_unbounded_tries
81
+ if intervals
82
+ raise ArgumentError,
83
+ "intervals cannot be used with tries: Float::INFINITY"
84
+ end
85
+
86
+ unless finite_number?(max_elapsed_time)
87
+ raise ArgumentError,
88
+ "max_elapsed_time must be a finite number when tries is Float::INFINITY"
47
89
  end
90
+
91
+ validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
92
+ end
93
+
94
+ def validate_intervals
95
+ return if intervals.nil?
96
+ raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array)
97
+ return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 }
98
+
99
+ raise ArgumentError, "intervals must contain only non-negative numbers"
48
100
  end
49
101
  end
50
102
  end
@@ -3,11 +3,11 @@
3
3
  require_relative "../../retriable"
4
4
 
5
5
  module Kernel
6
- def retriable(opts = {}, &block)
7
- Retriable.retriable(opts, &block)
6
+ def retriable(opts = {}, &)
7
+ Retriable.retriable(opts, &)
8
8
  end
9
9
 
10
- def retriable_with_context(context_key, opts = {}, &block)
11
- Retriable.with_context(context_key, opts, &block)
10
+ def retriable_with_context(context_key, opts = {}, &)
11
+ Retriable.with_context(context_key, opts, &)
12
12
  end
13
13
  end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "validation"
4
+
3
5
  module Retriable
4
6
  class ExponentialBackoff
7
+ include Validation
8
+
5
9
  ATTRIBUTES = %i[
6
10
  tries
7
11
  base_interval
@@ -24,20 +28,42 @@ module Retriable
24
28
 
25
29
  instance_variable_set(:"@#{k}", v)
26
30
  end
31
+
32
+ validate!
27
33
  end
28
34
 
29
35
  def intervals
30
- intervals = Array.new(tries) do |iteration|
31
- [base_interval * (multiplier**iteration), max_interval].min
32
- end
36
+ provider = interval_provider
37
+ Array.new(tries) { |iteration| provider.call(iteration) }
38
+ end
39
+
40
+ def interval_provider
41
+ raw_interval = base_interval
33
42
 
34
- return intervals if rand_factor.zero?
43
+ lambda do |_iteration|
44
+ interval = [raw_interval, max_interval].min
45
+ raw_interval = next_raw_interval(raw_interval)
35
46
 
36
- intervals.map { |i| randomize(i) }
47
+ rand_factor.zero? ? interval : randomize(interval)
48
+ end
37
49
  end
38
50
 
39
51
  private
40
52
 
53
+ def validate!
54
+ validate_non_negative_integer(:tries, tries)
55
+ validate_non_negative_number(:base_interval, base_interval)
56
+ validate_non_negative_number(:multiplier, multiplier)
57
+ validate_non_negative_number(:max_interval, max_interval)
58
+ validate_rand_factor
59
+ end
60
+
61
+ def next_raw_interval(raw_interval)
62
+ return max_interval if multiplier >= 1 && raw_interval >= max_interval
63
+
64
+ raw_interval * multiplier
65
+ end
66
+
41
67
  def randomize(interval)
42
68
  delta = rand_factor * interval.to_f
43
69
  min = interval - delta
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retriable
4
+ module Validation
5
+ private
6
+
7
+ def validate_positive_integer(name, value)
8
+ return if value.is_a?(Integer) && value.positive?
9
+
10
+ raise ArgumentError, "#{name} must be a positive integer"
11
+ end
12
+
13
+ def validate_non_negative_integer(name, value)
14
+ return if value.is_a?(Integer) && value >= 0
15
+
16
+ raise ArgumentError, "#{name} must be a non-negative integer"
17
+ end
18
+
19
+ def validate_non_negative_number(name, value)
20
+ return if finite_number?(value) && value >= 0
21
+
22
+ raise ArgumentError, "#{name} must be a non-negative number"
23
+ end
24
+
25
+ def validate_optional_non_negative_number(name, value)
26
+ return if value.nil?
27
+
28
+ validate_non_negative_number(name, value)
29
+ end
30
+
31
+ def validate_callable(name, value)
32
+ return unless value # nil/false disable the callback
33
+ return if value.respond_to?(:call)
34
+
35
+ raise ArgumentError, "#{name} must respond to #call or be nil"
36
+ end
37
+
38
+ def validate_rand_factor
39
+ return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1
40
+
41
+ raise ArgumentError, "rand_factor must be between 0 and 1"
42
+ end
43
+
44
+ def finite_number?(value)
45
+ value.is_a?(Numeric) && value.to_f.finite?
46
+ end
47
+
48
+ def unbounded_tries?(value)
49
+ value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
50
+ end
51
+
52
+ module_function :unbounded_tries?
53
+
54
+ # Validates an `on:` value. Acceptable shapes:
55
+ # - a Class that descends from Exception
56
+ # - an Array or Set whose elements are Classes that descend from Exception
57
+ # - a Hash whose keys are such Classes and whose values are nil,
58
+ # a Regexp, or an Array of Regexps
59
+ #
60
+ # Without this validation, callers can pass values like `Object` or
61
+ # `Kernel` and silently retry process-critical exceptions such as
62
+ # SystemExit and Interrupt, because every Exception's ancestor chain
63
+ # includes both. Hash values that are not Regexps (e.g. plain Strings)
64
+ # also silently fail to match in #hash_exception_match?, so we require
65
+ # Regexp values explicitly.
66
+ def validate_on(value)
67
+ case value
68
+ in Hash
69
+ value.each do |klass, pattern|
70
+ validate_on_class(klass)
71
+ validate_on_hash_value(klass, pattern)
72
+ end
73
+ in Array | Set
74
+ value.each { |klass| validate_on_class(klass) }
75
+ else
76
+ validate_on_class(value)
77
+ end
78
+ end
79
+
80
+ def validate_on_class(klass)
81
+ return if klass.is_a?(Class) && klass <= Exception
82
+
83
+ raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}"
84
+ end
85
+
86
+ def validate_on_hash_value(klass, pattern)
87
+ return if pattern.nil?
88
+ return if pattern.is_a?(Regexp)
89
+ return if pattern.is_a?(Array) && pattern.all?(Regexp)
90
+
91
+ raise ArgumentError,
92
+ "on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
93
+ end
94
+ end
95
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.4.1"
4
+ VERSION = "4.1.1"
5
5
  end