retriable 3.8.0 → 5.0.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/spec/config_spec.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
4
-
5
3
  describe Retriable::Config do
6
4
  let(:default_config) { described_class.new }
7
5
 
@@ -34,10 +32,6 @@ describe Retriable::Config do
34
32
  expect(default_config.intervals).to be_nil
35
33
  end
36
34
 
37
- it "timeout defaults to nil" do
38
- expect(default_config.timeout).to be_nil
39
- end
40
-
41
35
  it "on defaults to [StandardError]" do
42
36
  expect(default_config.on).to eq([StandardError])
43
37
  end
@@ -50,6 +44,10 @@ describe Retriable::Config do
50
44
  expect(default_config.on_retry).to be_nil
51
45
  end
52
46
 
47
+ it "on_give_up handler defaults to nil" do
48
+ expect(default_config.on_give_up).to be_nil
49
+ end
50
+
53
51
  it "contexts defaults to {}" do
54
52
  expect(default_config.contexts).to eq({})
55
53
  end
@@ -59,99 +57,12 @@ describe Retriable::Config do
59
57
  expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
60
58
  end
61
59
 
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
60
+ it "rejects timeout as an unknown option" do
61
+ expect { described_class.new(timeout: 5) }.to raise_error(ArgumentError, /not a valid option/)
67
62
  end
68
63
 
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
64
+ it "raises errors on invalid timing configuration" do
65
+ expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
155
66
  end
156
67
 
157
68
  it "raises errors when intervals is not an array" do
@@ -191,6 +102,15 @@ describe Retriable::Config do
191
102
  expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
192
103
  end
193
104
 
105
+ it "accepts a Set of Exception subclasses" do
106
+ expect { described_class.new(on: Set[StandardError, RuntimeError]) }.not_to raise_error
107
+ end
108
+
109
+ it "rejects a Set containing a non-Exception class" do
110
+ expect { described_class.new(on: Set[StandardError, Kernel]) }
111
+ .to raise_error(ArgumentError, /on must be an Exception class/)
112
+ end
113
+
194
114
  it "accepts a hash with nil pattern values" do
195
115
  expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
196
116
  end
@@ -243,4 +163,199 @@ describe Retriable::Config do
243
163
  .to raise_error(ArgumentError, /on must be an Exception class/)
244
164
  end
245
165
  end
166
+
167
+ context "callable option validation" do
168
+ %i[retry_if on_retry on_give_up].each do |opt|
169
+ it "accepts a callable for #{opt}" do
170
+ expect { described_class.new(opt => ->(*) {}) }.not_to raise_error
171
+ end
172
+
173
+ it "accepts nil and false for #{opt}" do
174
+ expect { described_class.new(opt => nil) }.not_to raise_error
175
+ expect { described_class.new(opt => false) }.not_to raise_error
176
+ end
177
+
178
+ it "rejects a non-callable truthy value for #{opt}" do
179
+ expect { described_class.new(opt => 5) }.to raise_error(ArgumentError, /#{opt}.*#call/)
180
+ end
181
+ end
182
+ end
183
+
184
+ context "context structure validation" do
185
+ it "rejects a context whose options contain a nested :contexts key" do
186
+ expect { described_class.new(contexts: { api: { contexts: {} } }) }
187
+ .to raise_error(ArgumentError, /contexts is not a valid option/)
188
+ end
189
+
190
+ it "rejects a context with an unknown option key" do
191
+ expect { described_class.new(contexts: { api: { does_not_exist: 1 } }) }
192
+ .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
193
+ end
194
+
195
+ it "validates context structure even when intervals is provided" do
196
+ expect { described_class.new(intervals: [0.1], contexts: { api: { contexts: {} } }) }
197
+ .to raise_error(ArgumentError, /contexts is not a valid option/)
198
+ end
199
+
200
+ it "accepts a non-Hash context value (treated as empty options)" do
201
+ expect { described_class.new(contexts: { broken: nil }) }.not_to raise_error
202
+ end
203
+
204
+ it "accepts nil contexts" do
205
+ expect { described_class.new(contexts: nil) }.not_to raise_error
206
+ end
207
+
208
+ it "accepts a valid context" do
209
+ expect { described_class.new(contexts: { api: { tries: 3, base_interval: 1.0 } }) }.not_to raise_error
210
+ end
211
+ end
212
+
213
+ context "#dup (copy-on-write isolation)" do
214
+ it "deep-copies contexts so mutating the copy leaves the original intact" do
215
+ original = described_class.new(contexts: { sql: { tries: 1 } })
216
+ copy = original.dup
217
+
218
+ copy.contexts[:http] = { tries: 2 }
219
+ copy.contexts[:sql][:tries] = 99
220
+
221
+ expect(original.contexts).to eq(sql: { tries: 1 })
222
+ end
223
+
224
+ it "deep-copies on and intervals collections" do
225
+ original = described_class.new(on: [StandardError], intervals: [1, 2])
226
+ copy = original.dup
227
+
228
+ copy.on << ArgumentError
229
+ copy.intervals << 3
230
+
231
+ expect(original.on).to eq([StandardError])
232
+ expect(original.intervals).to eq([1, 2])
233
+ end
234
+
235
+ it "preserves a non-collection on value (Exception class) without duping it" do
236
+ original = described_class.new(on: StandardError)
237
+ expect(original.dup.on).to be(StandardError)
238
+ end
239
+
240
+ it "deep-copies a Hash on value so the copy is a distinct hash" do
241
+ original = described_class.new(on: { StandardError => /boom/ })
242
+ copy = original.dup
243
+
244
+ copy.on[ArgumentError] = /other/
245
+
246
+ expect(original.on).to eq(StandardError => /boom/)
247
+ end
248
+
249
+ it "deep-copies mutable values nested inside a context's options" do
250
+ original = described_class.new(contexts: { api: { intervals: [1, 2] } })
251
+ copy = original.dup
252
+
253
+ copy.contexts[:api][:intervals] << 3
254
+
255
+ expect(original.contexts[:api][:intervals]).to eq([1, 2])
256
+ end
257
+
258
+ it "deep-copies the collection values of a Hash on" do
259
+ original = described_class.new(on: { StandardError => [/boom/] })
260
+ copy = original.dup
261
+
262
+ copy.on[StandardError] << /bang/
263
+
264
+ expect(original.on[StandardError]).to eq([/boom/])
265
+ end
266
+
267
+ it "preserves the container class of a Hash subclass" do
268
+ subclass = Class.new(Hash)
269
+ contexts = subclass.new
270
+ contexts[:api] = { tries: 1 }
271
+
272
+ expect(described_class.new(contexts: contexts).dup.contexts).to be_a(subclass)
273
+ end
274
+
275
+ it "preserves a contexts default_proc so absent keys still resolve" do
276
+ contexts = Hash.new { |hash, key| hash[key] = { tries: 7 } }
277
+ copy = described_class.new(contexts: contexts).dup
278
+
279
+ expect(copy.contexts[:never_set]).to eq(tries: 7)
280
+ end
281
+
282
+ it "deep-copies a mutable Hash default" do
283
+ fallback = []
284
+ contexts = Hash.new(fallback)
285
+ copy = described_class.new(contexts: contexts).dup
286
+
287
+ copy.contexts.default << :copy_only
288
+
289
+ expect(copy.contexts.default).not_to equal(fallback)
290
+ expect(fallback).to be_empty
291
+ end
292
+
293
+ it "preserves a self-referential Hash default" do
294
+ contexts = {}
295
+ contexts.default = contexts
296
+ copy = described_class.new(contexts: contexts).dup
297
+
298
+ expect(copy.contexts.default).to equal(copy.contexts)
299
+ end
300
+
301
+ it "unfreezes copied containers so a configure block can mutate them" do
302
+ original = described_class.new(contexts: { api: { tries: 1 } }.freeze)
303
+
304
+ expect { original.dup.contexts[:added] = { tries: 2 } }.not_to raise_error
305
+ end
306
+
307
+ it "terminates on a self-referential contexts structure" do
308
+ original = described_class.new
309
+ original.contexts[:api] = { tries: 1 }
310
+ original.contexts[:api][:cycle] = original.contexts
311
+
312
+ copy = original.dup
313
+
314
+ expect(copy.contexts).not_to equal(original.contexts)
315
+ expect(copy.contexts[:api][:cycle]).to equal(copy.contexts)
316
+ end
317
+ end
318
+
319
+ context "#freeze (published snapshot immutability)" do
320
+ it "rejects mutation of the config itself" do
321
+ config = described_class.new.freeze
322
+
323
+ expect { config.tries = 99 }.to raise_error(FrozenError)
324
+ end
325
+
326
+ it "rejects mutation one level down, inside contexts" do
327
+ config = described_class.new(contexts: { api: { tries: 1 } }).freeze
328
+
329
+ expect { config.contexts[:api][:tries] = 99 }.to raise_error(FrozenError)
330
+ end
331
+
332
+ it "rejects mutation of the on collection" do
333
+ config = described_class.new(on: [StandardError]).freeze
334
+
335
+ expect { config.on << ArgumentError }.to raise_error(FrozenError)
336
+ end
337
+
338
+ it "freezes a mutable Hash default" do
339
+ fallback = []
340
+ contexts = Hash.new(fallback)
341
+ contexts[:api] = { tries: 1 }
342
+ config = described_class.new(contexts: contexts).freeze
343
+
344
+ expect(config.contexts.default).to be_frozen
345
+ end
346
+
347
+ it "leaves leaves such as procs untouched" do
348
+ handler = ->(_exception) { true }
349
+ described_class.new(retry_if: handler).freeze
350
+
351
+ expect(handler).not_to be_frozen
352
+ end
353
+
354
+ it "is idempotent" do
355
+ config = described_class.new.freeze
356
+
357
+ expect { config.freeze }.not_to raise_error
358
+ expect(config.freeze).to equal(config)
359
+ end
360
+ end
246
361
  end