retriable 3.5.0 → 3.8.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.
@@ -1,9 +1,12 @@
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
@@ -15,6 +18,19 @@ module Retriable
15
18
  contexts
16
19
  ]).freeze
17
20
 
21
+ TIMEOUT_DEPRECATION_MESSAGE = "NOTE: Retriable's `timeout:` option is deprecated and will be removed in " \
22
+ "Retriable 4.0. It is a thin wrapper around `Timeout.timeout`, which " \
23
+ "can interrupt execution at arbitrary lines and corrupt internal state " \
24
+ "in libraries that are not interrupt-safe. Prefer your library's native " \
25
+ "timeout, or wrap your block in `Timeout.timeout(...)` yourself."
26
+ private_constant :TIMEOUT_DEPRECATION_MESSAGE
27
+
28
+ @timeout_deprecation_warned = false
29
+
30
+ class << self
31
+ attr_accessor :timeout_deprecation_warned
32
+ end
33
+
18
34
  attr_accessor(*ATTRIBUTES)
19
35
 
20
36
  def initialize(opts = {})
@@ -39,6 +55,8 @@ module Retriable
39
55
 
40
56
  instance_variable_set(:"@#{k}", v)
41
57
  end
58
+
59
+ validate!
42
60
  end
43
61
 
44
62
  def to_h
@@ -46,5 +64,90 @@ module Retriable
46
64
  hash[key] = public_send(key)
47
65
  end
48
66
  end
67
+
68
+ def validate!
69
+ warn_timeout_deprecation
70
+ validate_optional_non_negative_number(:timeout, timeout)
71
+ validate_on(on)
72
+ validate_intervals
73
+ if unbounded_tries?(tries)
74
+ validate_unbounded_tries
75
+ else
76
+ validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
77
+ return if intervals
78
+
79
+ validate_positive_integer(:tries, tries)
80
+ end
81
+
82
+ validate_backoff_options
83
+ end
84
+
85
+ private
86
+
87
+ # Emits the `timeout:` deprecation notice at most once per process.
88
+ #
89
+ # On Rubies that support `Kernel#warn(category: :deprecated)` (2.7+), the
90
+ # notice is emitted under the `:deprecated` category, so callers can use the
91
+ # standard controls (`Warning[:deprecated] = false`, `-W:no-deprecated`,
92
+ # `Warning.warn` override) to silence it. On older Rubies the kwarg is not
93
+ # available and we fall back to plain `Kernel.warn`.
94
+ #
95
+ # When the warning is suppressed (either because `Warning[:deprecated]` is
96
+ # false or the runtime has otherwise muted the category), we deliberately
97
+ # leave the once-per-process flag unset so a future call with the category
98
+ # re-enabled still surfaces the notice.
99
+ def warn_timeout_deprecation
100
+ return if timeout.nil?
101
+ return if self.class.timeout_deprecation_warned
102
+
103
+ category_supported = deprecated_warning_category_supported?
104
+ return if category_supported && !deprecated_warnings_enabled?
105
+
106
+ self.class.timeout_deprecation_warned = true
107
+ if category_supported
108
+ Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE, category: :deprecated)
109
+ else
110
+ Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE)
111
+ end
112
+ end
113
+
114
+ def deprecated_warning_category_supported?
115
+ defined?(Warning) && Kernel.method(:warn).parameters.any? { |type, name| type == :key && name == :category }
116
+ end
117
+
118
+ def deprecated_warnings_enabled?
119
+ return true unless defined?(Warning) && Warning.respond_to?(:[])
120
+
121
+ Warning[:deprecated]
122
+ end
123
+
124
+ def validate_backoff_options
125
+ validate_non_negative_number(:base_interval, base_interval)
126
+ validate_non_negative_number(:multiplier, multiplier)
127
+ validate_non_negative_number(:max_interval, max_interval)
128
+ validate_rand_factor
129
+ end
130
+
131
+ def validate_unbounded_tries
132
+ if intervals
133
+ raise ArgumentError,
134
+ "intervals cannot be used with tries: Float::INFINITY"
135
+ end
136
+
137
+ unless finite_number?(max_elapsed_time)
138
+ raise ArgumentError,
139
+ "max_elapsed_time must be a finite number when tries is Float::INFINITY"
140
+ end
141
+
142
+ validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
143
+ end
144
+
145
+ def validate_intervals
146
+ return if intervals.nil?
147
+ raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array)
148
+ return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 }
149
+
150
+ raise ArgumentError, "intervals must contain only non-negative numbers"
151
+ end
49
152
  end
50
153
  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,91 @@
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_rand_factor
32
+ return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1
33
+
34
+ raise ArgumentError, "rand_factor must be between 0 and 1"
35
+ end
36
+
37
+ def finite_number?(value)
38
+ value.is_a?(Numeric) && value.to_f.finite?
39
+ end
40
+
41
+ def unbounded_tries?(value)
42
+ value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
43
+ end
44
+
45
+ module_function :unbounded_tries?
46
+
47
+ # Validates an `on:` value. Acceptable shapes:
48
+ # - a Class that descends from Exception
49
+ # - an Array whose elements are Classes that descend from Exception
50
+ # - a Hash whose keys are such Classes and whose values are nil,
51
+ # a Regexp, or an Array of Regexps
52
+ #
53
+ # Without this validation, callers can pass values like `Object` or
54
+ # `Kernel` and silently retry process-critical exceptions such as
55
+ # SystemExit and Interrupt, because every Exception's ancestor chain
56
+ # includes both. Hash values that are not Regexps (e.g. plain Strings)
57
+ # also silently fail to match in #hash_exception_match?, so we require
58
+ # Regexp values explicitly.
59
+ def validate_on(value)
60
+ case value
61
+ when Hash
62
+ value.each do |klass, pattern|
63
+ validate_on_class(klass)
64
+ validate_on_hash_value(klass, pattern)
65
+ end
66
+ when Array
67
+ value.each { |klass| validate_on_class(klass) }
68
+ else
69
+ validate_on_class(value)
70
+ end
71
+ end
72
+
73
+ def validate_on_class(klass)
74
+ return if klass.is_a?(Class) && klass <= Exception
75
+
76
+ raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}"
77
+ end
78
+
79
+ def validate_on_hash_value(klass, pattern)
80
+ return if pattern.nil?
81
+ return if pattern.is_a?(Regexp)
82
+ # Ruby 2.3 does not support Enumerable#all?(pattern).
83
+ # rubocop:disable Style/PredicateWithKind
84
+ return if pattern.is_a?(Array) && pattern.all? { |p| p.is_a?(Regexp) }
85
+ # rubocop:enable Style/PredicateWithKind
86
+
87
+ raise ArgumentError,
88
+ "on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
89
+ end
90
+ end
91
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.5.0"
4
+ VERSION = "3.8.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -6,6 +6,16 @@ require_relative "retriable/exponential_backoff"
6
6
  require_relative "retriable/version"
7
7
 
8
8
  module Retriable
9
+ # Thread-local storage key for the active #with_override block.
10
+ # We deliberately use Thread#thread_variable_set/get (true thread-local)
11
+ # rather than Thread.current[] (fiber-local) so that fibers within a thread
12
+ # share the same override. Changing this to Thread.current[] would silently
13
+ # break callers that use fiber-based concurrency.
14
+ OVERRIDE_THREAD_KEY = :retriable_override
15
+
16
+ RetryPlan = Struct.new(:max_tries, :interval_for)
17
+ private_constant :RetryPlan
18
+
9
19
  module_function
10
20
 
11
21
  def configure
@@ -16,15 +26,19 @@ module Retriable
16
26
  @config ||= Config.new
17
27
  end
18
28
 
19
- def override(opts = {})
20
- raise ArgumentError, "empty override options are not allowed; use reset_override instead" if opts.empty?
29
+ def with_override(opts = {})
30
+ raise ArgumentError, "empty override options are not allowed" if opts.empty?
31
+ raise ArgumentError, "with_override requires a block" unless block_given?
21
32
 
22
33
  validate_override_options(opts)
23
- @override_config = opts
24
- end
25
34
 
26
- def reset_override
27
- @override_config = nil
35
+ previous = Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
36
+ Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, opts)
37
+ begin
38
+ yield
39
+ ensure
40
+ Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, previous)
41
+ end
28
42
  end
29
43
 
30
44
  def with_context(context_key, options = {}, &block)
@@ -41,14 +55,17 @@ module Retriable
41
55
  end
42
56
 
43
57
  def retriable(opts = {}, &block)
44
- local_config = if opts.empty? && !@override_config
58
+ override_config = current_override
59
+ local_config = if opts.empty? && !override_config
45
60
  config
46
61
  else
47
- Config.new(apply_override_options(config.to_h.merge(opts), @override_config))
62
+ Config.new(apply_override_options(config.to_h.merge(opts), override_config))
48
63
  end
49
64
 
50
- tries = local_config.tries
51
- intervals = build_intervals(local_config, tries)
65
+ # Config is mutable through `configure`, so validate again immediately before use.
66
+ local_config.validate!
67
+
68
+ plan = retry_plan(local_config)
52
69
  timeout = local_config.timeout
53
70
  on = local_config.on
54
71
  retry_if = local_config.retry_if
@@ -61,10 +78,8 @@ module Retriable
61
78
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
79
  elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
63
80
 
64
- tries = intervals.size + 1
65
-
66
81
  execute_tries(
67
- tries: tries, intervals: intervals, timeout: timeout,
82
+ max_tries: plan.max_tries, interval_for: plan.interval_for, timeout: timeout,
68
83
  exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
69
84
  elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
70
85
  sleep_disabled: sleep_disabled, &block
@@ -72,38 +87,49 @@ module Retriable
72
87
  end
73
88
 
74
89
  def execute_tries( # rubocop:disable Metrics/ParameterLists
75
- tries:, intervals:, timeout:, exception_list:,
90
+ max_tries:, interval_for:, timeout:, exception_list:,
76
91
  on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
77
92
  )
78
- tries.times do |index|
79
- try = index + 1
80
-
93
+ try = 0
94
+ loop do
95
+ try += 1
81
96
  begin
82
97
  return call_with_timeout(timeout, try, &block)
83
98
  rescue *exception_list => e
84
99
  raise unless retriable_exception?(e, on, exception_list, retry_if)
85
100
 
86
- interval = intervals[index]
101
+ interval = interval_for.call(try - 1)
87
102
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
88
103
 
89
104
  elapsed_interval = sleep_disabled == true ? 0 : interval
90
- raise unless can_retry?(try, tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
105
+ raise unless can_retry?(try, max_tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
91
106
 
92
107
  sleep interval if sleep_disabled != true
93
108
  end
94
109
  end
95
110
  end
96
111
 
97
- def build_intervals(local_config, tries)
98
- return local_config.intervals if local_config.intervals
112
+ def retry_plan(local_config)
113
+ return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
114
+
115
+ if local_config.intervals
116
+ intervals = local_config.intervals
117
+ return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] })
118
+ end
119
+
120
+ max_tries = local_config.tries
121
+ provider = interval_provider(local_config)
122
+
123
+ RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
124
+ end
99
125
 
126
+ def interval_provider(local_config)
100
127
  ExponentialBackoff.new(
101
- tries: tries - 1,
102
128
  base_interval: local_config.base_interval,
103
129
  multiplier: local_config.multiplier,
104
130
  max_interval: local_config.max_interval,
105
131
  rand_factor: local_config.rand_factor,
106
- ).intervals
132
+ ).interval_provider
107
133
  end
108
134
 
109
135
  def call_with_timeout(timeout, try)
@@ -118,8 +144,8 @@ module Retriable
118
144
  on_retry.call(exception, try, elapsed_time, interval)
119
145
  end
120
146
 
121
- def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time)
122
- return false unless try < tries
147
+ def can_retry?(try, max_tries, elapsed_time, interval, max_elapsed_time)
148
+ return false if max_tries && try >= max_tries
123
149
  return true if max_elapsed_time.nil?
124
150
 
125
151
  (elapsed_time + interval) <= max_elapsed_time
@@ -151,16 +177,23 @@ module Retriable
151
177
  raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
152
178
  end
153
179
 
180
+ return unless opts.key?(:contexts)
181
+
154
182
  contexts = opts[:contexts]
155
- return unless contexts.is_a?(Hash)
183
+ return if contexts.nil?
184
+
185
+ raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
156
186
 
157
- contexts.each_value do |context_options|
158
- validate_context_override_options(context_options)
187
+ contexts.each do |context_key, context_options|
188
+ validate_context_override_options(context_key, context_options)
159
189
  end
160
190
  end
161
191
 
162
- def validate_context_override_options(context_options)
163
- return unless context_options.is_a?(Hash)
192
+ def validate_context_override_options(context_key, context_options)
193
+ unless context_options.is_a?(Hash)
194
+ raise ArgumentError,
195
+ "contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}"
196
+ end
164
197
 
165
198
  context_attributes = Config::ATTRIBUTES - [:contexts]
166
199
  context_options.each_key do |k|
@@ -196,15 +229,21 @@ module Retriable
196
229
  end
197
230
 
198
231
  def override_contexts
199
- contexts = @override_config && @override_config[:contexts]
232
+ override_config = current_override
233
+ contexts = override_config && override_config[:contexts]
200
234
  contexts.is_a?(Hash) ? contexts : {}
201
235
  end
202
236
 
237
+ def current_override
238
+ Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
239
+ end
240
+
203
241
  private_class_method(
204
242
  :validate_override_options,
205
243
  :validate_context_override_options,
206
244
  :execute_tries,
207
- :build_intervals,
245
+ :retry_plan,
246
+ :interval_provider,
208
247
  :call_with_timeout,
209
248
  :call_on_retry,
210
249
  :can_retry?,
@@ -215,5 +254,6 @@ module Retriable
215
254
  :context_options_for,
216
255
  :config_contexts,
217
256
  :override_contexts,
257
+ :current_override,
218
258
  )
219
259
  end
data/spec/config_spec.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
4
+
3
5
  describe Retriable::Config do
4
6
  let(:default_config) { described_class.new }
5
7
 
@@ -56,4 +58,189 @@ describe Retriable::Config do
56
58
  it "raises errors on invalid configuration" do
57
59
  expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
58
60
  end
61
+
62
+ it "raises errors on invalid timing configuration" do
63
+ expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
64
+ expect do
65
+ expect { described_class.new(timeout: -1) }.to raise_error(ArgumentError, /timeout/)
66
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
67
+ end
68
+
69
+ context "timeout deprecation" do
70
+ it "warns when timeout is configured" do
71
+ expect do
72
+ described_class.new(timeout: 5)
73
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
74
+ end
75
+
76
+ it "warns when timeout is set before validation" do
77
+ config = described_class.new
78
+ config.timeout = 5
79
+
80
+ expect do
81
+ config.validate!
82
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
83
+ end
84
+
85
+ it "does not warn when timeout is nil" do
86
+ expect do
87
+ described_class.new(timeout: nil)
88
+ end.not_to output.to_stderr
89
+ end
90
+
91
+ it "does not warn when timeout is omitted" do
92
+ expect do
93
+ described_class.new
94
+ end.not_to output.to_stderr
95
+ end
96
+
97
+ it "warns at most once per process" do
98
+ original_stderr = $stderr
99
+ stderr = StringIO.new
100
+ begin
101
+ $stderr = stderr
102
+
103
+ described_class.new(timeout: 5)
104
+ described_class.new(timeout: 5)
105
+
106
+ config = described_class.new
107
+ config.timeout = 5
108
+ config.validate!
109
+ ensure
110
+ $stderr = original_stderr
111
+ end
112
+
113
+ expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
114
+ end
115
+
116
+ it "emits the warning under the :deprecated category when supported", if: WARN_CATEGORY_SUPPORTED do
117
+ captured = []
118
+ allow(Warning).to receive(:warn) do |message, category: nil|
119
+ captured << [message, category]
120
+ end
121
+
122
+ described_class.new(timeout: 5)
123
+
124
+ expect(captured.size).to eq(1)
125
+ message, category = captured.first
126
+ expect(message).to match(/timeout.*deprecated.*Retriable 4\.0/i)
127
+ expect(category).to eq(:deprecated)
128
+ end
129
+
130
+ it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
131
+ original = Warning[:deprecated]
132
+ begin
133
+ Warning[:deprecated] = false
134
+ expect do
135
+ described_class.new(timeout: 5)
136
+ end.not_to output.to_stderr
137
+ ensure
138
+ Warning[:deprecated] = original
139
+ end
140
+ end
141
+
142
+ it "remains armed when silenced via Warning[:deprecated]", if: WARN_CATEGORY_SUPPORTED do
143
+ original = Warning[:deprecated]
144
+ begin
145
+ Warning[:deprecated] = false
146
+ described_class.new(timeout: 5)
147
+ ensure
148
+ Warning[:deprecated] = original
149
+ end
150
+
151
+ expect do
152
+ described_class.new(timeout: 5)
153
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
154
+ end
155
+ end
156
+
157
+ it "raises errors when intervals is not an array" do
158
+ expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
159
+ end
160
+
161
+ it "requires a finite max_elapsed_time when tries is Float::INFINITY" do
162
+ expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) }
163
+ .to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
164
+ end
165
+
166
+ it "rejects intervals combined with tries: Float::INFINITY" do
167
+ expect do
168
+ described_class.new(
169
+ tries: Float::INFINITY,
170
+ max_elapsed_time: 60,
171
+ intervals: [0.1, 0.2],
172
+ )
173
+ end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
174
+ end
175
+
176
+ it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do
177
+ expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) }
178
+ .not_to raise_error
179
+ end
180
+
181
+ context "on: option validation" do
182
+ it "accepts a single Exception subclass" do
183
+ expect { described_class.new(on: StandardError) }.not_to raise_error
184
+ end
185
+
186
+ it "accepts Exception itself" do
187
+ expect { described_class.new(on: Exception) }.not_to raise_error
188
+ end
189
+
190
+ it "accepts an array of Exception subclasses" do
191
+ expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
192
+ end
193
+
194
+ it "accepts a hash with nil pattern values" do
195
+ expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
196
+ end
197
+
198
+ it "accepts a hash with Regexp pattern values" do
199
+ expect { described_class.new(on: { StandardError => /boom/ }) }.not_to raise_error
200
+ end
201
+
202
+ it "accepts a hash with Array-of-Regexp pattern values" do
203
+ expect { described_class.new(on: { StandardError => [/a/, /b/] }) }.not_to raise_error
204
+ end
205
+
206
+ it "rejects Object as on:" do
207
+ expect { described_class.new(on: Object) }
208
+ .to raise_error(ArgumentError, /on must be an Exception class/)
209
+ end
210
+
211
+ it "rejects Kernel as on:" do
212
+ expect { described_class.new(on: Kernel) }
213
+ .to raise_error(ArgumentError, /on must be an Exception class/)
214
+ end
215
+
216
+ it "rejects an array containing a non-Exception class" do
217
+ expect { described_class.new(on: [StandardError, Kernel]) }
218
+ .to raise_error(ArgumentError, /on must be an Exception class/)
219
+ end
220
+
221
+ it "rejects a hash key that is not an Exception class" do
222
+ expect { described_class.new(on: { Kernel => nil }) }
223
+ .to raise_error(ArgumentError, /on must be an Exception class/)
224
+ end
225
+
226
+ it "rejects a hash value that is a String" do
227
+ expect { described_class.new(on: { StandardError => "boom" }) }
228
+ .to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
229
+ end
230
+
231
+ it "rejects a hash value that is an Array containing a non-Regexp" do
232
+ expect { described_class.new(on: { StandardError => [/a/, "b"] }) }
233
+ .to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
234
+ end
235
+
236
+ it "rejects a string passed as on:" do
237
+ expect { described_class.new(on: "StandardError") }
238
+ .to raise_error(ArgumentError, /on must be an Exception class/)
239
+ end
240
+
241
+ it "validates on: even when intervals is provided" do
242
+ expect { described_class.new(intervals: [0.1], on: Object) }
243
+ .to raise_error(ArgumentError, /on must be an Exception class/)
244
+ end
245
+ end
59
246
  end