retriable 4.1.1 → 5.0.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.
data/spec/config_spec.rb CHANGED
@@ -180,4 +180,182 @@ describe Retriable::Config do
180
180
  end
181
181
  end
182
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
183
361
  end
@@ -8,7 +8,10 @@ describe Retriable do
8
8
  end
9
9
 
10
10
  before(:each) do
11
- described_class.instance_variable_set(:@config, nil)
11
+ # Reset to a pristine published snapshot, matching what load-time init
12
+ # publishes. Resetting to nil instead would force #config to carry a
13
+ # nil-guard that only the suite can reach.
14
+ described_class.instance_variable_set(:@config, Retriable::Config.new.freeze)
12
15
  Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
13
16
  described_class.configure { |c| c.sleep_disabled = true }
14
17
  @tries = 0
@@ -25,6 +28,31 @@ describe Retriable do
25
28
  raise exception_class, "#{exception_class} occurred"
26
29
  end
27
30
 
31
+ # Wall clock appears in the concurrency specs only as a deadlock backstop,
32
+ # never as a timing assertion. Every worker they start finishes in
33
+ # milliseconds, so this budget is approached only when a regression leaves a
34
+ # thread genuinely stuck.
35
+ let(:deadlock_backstop_seconds) { 10 }
36
+
37
+ # Joins each thread within the backstop, failing the example instead of hanging
38
+ # the suite when one never finishes. A bare Thread#join turns a serialization
39
+ # regression into a run that never terminates, which CI reports as a timeout
40
+ # with no failing example to point at.
41
+ #
42
+ # The kill matters for the examples that follow: Ruby releases a Mutex held by
43
+ # a killed thread, so one stuck worker cannot wedge every later `configure`.
44
+ # Kill is asynchronous, so wait briefly for it to land before failing —
45
+ # otherwise the mutex is still held when the next example starts.
46
+ def join_without_deadlock(*threads)
47
+ threads.flatten.compact.each do |thread|
48
+ next if thread.join(deadlock_backstop_seconds)
49
+
50
+ thread.kill
51
+ thread.join(1)
52
+ raise "a worker thread did not finish within #{deadlock_backstop_seconds}s; suspect a deadlock"
53
+ end
54
+ end
55
+
28
56
  context "global scope extension" do
29
57
  it "cannot be called in the global scope without requiring the core_ext/kernel" do
30
58
  script = "require 'retriable'; begin; retriable {}; rescue NoMethodError; exit 0; end; exit 1"
@@ -52,9 +80,9 @@ describe Retriable do
52
80
 
53
81
  # These two specs lock in the anonymous block forwarding (`&`) semantics
54
82
  # across both delegation layers: Kernel#retriable_with_context ->
55
- # Retriable.with_context. If the `&` is dropped at either layer, the
56
- # block is not forwarded and the inner `block_given?` check at
57
- # lib/retriable.rb:51 short-circuits, causing the block to never run.
83
+ # Retriable.with_context. If the `&` is dropped at either layer, the block
84
+ # is not forwarded and the `block_given?` guard in with_context raises
85
+ # ArgumentError instead of running the block.
58
86
  it "forwards a block through Kernel#retriable_with_context" do
59
87
  require_relative "../lib/retriable/core_ext/kernel"
60
88
  Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
@@ -64,13 +92,23 @@ describe Retriable do
64
92
  expect(@tries).to eq(1)
65
93
  end
66
94
 
67
- it "returns nil when Kernel#retriable_with_context is called without a block" do
95
+ it "raises an ArgumentError when Kernel#retriable_with_context is called without a block" do
68
96
  require_relative "../lib/retriable/core_ext/kernel"
69
97
  Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
70
98
 
71
- expect(retriable_with_context(:sql)).to be_nil
99
+ expect { retriable_with_context(:sql) }
100
+ .to raise_error(ArgumentError, /with_context requires a block/)
72
101
  expect(@tries).to eq(0)
73
102
  end
103
+
104
+ it "is not callable with an explicit receiver" do
105
+ require_relative "../lib/retriable/core_ext/kernel"
106
+
107
+ expect { "foo".retriable { increment_tries } }
108
+ .to raise_error(NoMethodError, /private method/)
109
+ expect { "foo".retriable_with_context(:sql) { increment_tries } }
110
+ .to raise_error(NoMethodError, /private method/)
111
+ end
74
112
  end
75
113
 
76
114
  context "#retriable" do
@@ -758,6 +796,242 @@ describe Retriable do
758
796
  it "raises NoMethodError on invalid configuration" do
759
797
  expect { described_class.configure { |c| c.does_not_exist = 123 } }.to raise_error(NoMethodError)
760
798
  end
799
+
800
+ it "returns the configure block's result" do
801
+ result = described_class.configure do |c|
802
+ c.tries = 7
803
+ :configured
804
+ end
805
+
806
+ expect(result).to eq(:configured)
807
+ expect(described_class.config.tries).to eq(7)
808
+ end
809
+ end
810
+
811
+ context "#configure thread safety (copy-on-write)" do
812
+ it "eagerly initializes @config at load time, before any configure/config call" do
813
+ script = "require 'retriable'; " \
814
+ "exit(Retriable.instance_variable_get(:@config).is_a?(Retriable::Config) ? 0 : 1)"
815
+ expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true)
816
+ end
817
+
818
+ it "publishes a new Config object on configure instead of mutating in place" do
819
+ before = described_class.config
820
+ described_class.configure { |c| c.tries = 7 }
821
+ after = described_class.config
822
+
823
+ expect(after).not_to equal(before)
824
+ expect(after.tries).to eq(7)
825
+ expect(before.tries).not_to eq(7)
826
+ end
827
+
828
+ it "keeps an already-captured snapshot stable across later configures" do
829
+ described_class.configure { |c| c.contexts[:sql] = { tries: 1 } }
830
+ snapshot = described_class.config
831
+
832
+ described_class.configure { |c| c.contexts[:http] = { tries: 2 } }
833
+ described_class.configure { |c| c.contexts[:sql][:tries] = 99 }
834
+
835
+ expect(snapshot.contexts).to eq(sql: { tries: 1 })
836
+ end
837
+
838
+ it "publishes a frozen snapshot, so direct mutation fails loudly" do
839
+ described_class.configure { |c| c.contexts[:api] = { tries: 1 } }
840
+
841
+ expect { described_class.config.tries = 99 }.to raise_error(FrozenError)
842
+ expect { described_class.config.contexts[:api][:tries] = 99 }.to raise_error(FrozenError)
843
+ expect(described_class.config.contexts[:api]).to eq(tries: 1)
844
+ end
845
+
846
+ it "still hands the configure block a mutable candidate" do
847
+ expect do
848
+ described_class.configure do |c|
849
+ c.contexts[:api] = { tries: 1 }
850
+ c.contexts[:api][:tries] = 2
851
+ c.on = [StandardError]
852
+ c.on << ArgumentError
853
+ end
854
+ end.not_to raise_error
855
+
856
+ expect(described_class.config.contexts[:api]).to eq(tries: 2)
857
+ end
858
+
859
+ it "does not freeze collections the caller still owns" do
860
+ caller_owned = [StandardError]
861
+
862
+ described_class.configure { |c| c.on = caller_owned }
863
+
864
+ expect(caller_owned).not_to be_frozen
865
+ expect(described_class.config.on).to be_frozen
866
+ expect(described_class.config.on).not_to equal(caller_owned)
867
+ end
868
+
869
+ it "owns and freezes a copy of a mutable Hash default" do
870
+ fallback = []
871
+ contexts = Hash.new(fallback)
872
+ contexts[:api] = { tries: 1 }
873
+
874
+ described_class.configure { |c| c.contexts = contexts }
875
+ published_default = described_class.config.contexts.default
876
+
877
+ expect(published_default).not_to equal(fallback)
878
+ expect(published_default).to be_frozen
879
+ expect(fallback).not_to be_frozen
880
+ end
881
+
882
+ it "does not let a published default leak mutations into the caller's object" do
883
+ fallback = []
884
+ described_class.configure { |c| c.contexts = Hash.new(fallback) }
885
+
886
+ expect { described_class.config.contexts[:absent] << :leaked }.to raise_error(FrozenError)
887
+ expect(fallback).to be_empty
888
+ end
889
+
890
+ it "does not drop updates when configured concurrently from many threads" do
891
+ keys = (0...50).map { |i| :"ctx_#{i}" }
892
+ release = Queue.new
893
+
894
+ threads = keys.map do |key|
895
+ Thread.new do
896
+ release.pop
897
+ described_class.configure { |c| c.contexts[key] = { tries: 1 } }
898
+ end
899
+ end
900
+
901
+ keys.size.times { release << true }
902
+ join_without_deadlock(threads)
903
+
904
+ expect(described_class.config.contexts.keys).to match_array(keys)
905
+ end
906
+
907
+ it "lets nested configure calls join the outer transaction" do
908
+ inner_candidate = nil
909
+
910
+ result = described_class.configure do |outer|
911
+ outer.tries = 7
912
+ nested_result = described_class.configure do |inner|
913
+ inner_candidate = inner
914
+ inner.base_interval = 1
915
+ :nested_result
916
+ end
917
+
918
+ expect(nested_result).to eq(:nested_result)
919
+ expect(inner_candidate).to equal(outer)
920
+ :outer_result
921
+ end
922
+
923
+ expect(result).to eq(:outer_result)
924
+ expect(described_class.config.tries).to eq(7)
925
+ expect(described_class.config.base_interval).to eq(1)
926
+ end
927
+
928
+ it "rolls back nested changes when the outer configure raises" do
929
+ described_class.configure { |c| c.tries = 4 }
930
+
931
+ expect do
932
+ described_class.configure do |outer|
933
+ outer.tries = 7
934
+ described_class.configure { |inner| inner.base_interval = 1 }
935
+ raise "outer failed"
936
+ end
937
+ end.to raise_error(RuntimeError, "outer failed")
938
+
939
+ expect(described_class.config.tries).to eq(4)
940
+ expect(described_class.config.base_interval).to eq(0.5)
941
+ end
942
+
943
+ it "lets configure calls from a fiber join the outer transaction" do
944
+ described_class.configure do |outer|
945
+ fiber_candidate = Fiber.new do
946
+ described_class.configure do |inner|
947
+ inner.tries = 9
948
+ inner
949
+ end
950
+ end.resume
951
+
952
+ expect(fiber_candidate).to equal(outer)
953
+ end
954
+
955
+ expect(described_class.config.tries).to eq(9)
956
+ end
957
+
958
+ it "exposes the in-progress config to the configuring thread" do
959
+ described_class.configure do |c|
960
+ c.tries = 7
961
+ expect(described_class.config.tries).to eq(7)
962
+ end
963
+
964
+ expect(described_class.config.tries).to eq(7)
965
+ end
966
+
967
+ it "uses the in-progress config for retriable" do
968
+ attempts = 0
969
+
970
+ described_class.configure do |c|
971
+ c.tries = 1
972
+ c.sleep_disabled = true
973
+
974
+ expect do
975
+ described_class.retriable do
976
+ attempts += 1
977
+ raise StandardError
978
+ end
979
+ end.to raise_error(StandardError)
980
+ end
981
+
982
+ expect(attempts).to eq(1)
983
+ end
984
+
985
+ it "uses candidate-only contexts for with_context" do
986
+ described_class.configure do |c|
987
+ c.contexts[:candidate] = { tries: 1 }
988
+
989
+ expect(described_class.with_context(:candidate) { :found }).to eq(:found)
990
+ end
991
+ end
992
+
993
+ it "shares the in-progress config with fibers in the configuring thread" do
994
+ described_class.configure do |c|
995
+ c.tries = 7
996
+
997
+ expect(Fiber.new { described_class.config.tries }.resume).to eq(7)
998
+ end
999
+ end
1000
+
1001
+ it "does not block readers while configure is in progress" do
1002
+ published_tries = described_class.config.tries
1003
+ published_base_interval = described_class.config.base_interval
1004
+ configuring = Queue.new
1005
+ release = Queue.new
1006
+ writer = Thread.new do
1007
+ described_class.configure do |c|
1008
+ c.tries = published_tries + 1
1009
+ described_class.configure { |inner| inner.base_interval = published_base_interval + 1 }
1010
+ configuring << true
1011
+ release.pop
1012
+ end
1013
+ end
1014
+ reader = nil
1015
+
1016
+ begin
1017
+ configuring.pop
1018
+ reader = Thread.new do
1019
+ snapshot = described_class.config
1020
+ [snapshot.tries, snapshot.base_interval]
1021
+ end
1022
+ # The timeout is a deadlock backstop, not the assertion. A reader that
1023
+ # is not blocked returns immediately, so this budget is only ever
1024
+ # approached if #config starts waiting on the in-progress #configure.
1025
+ expect(reader.join(deadlock_backstop_seconds)).to be(reader),
1026
+ "reader blocked while configure was in progress"
1027
+ # Thread#value re-raises anything the reader raised, so a broken reader
1028
+ # fails loudly instead of hanging on an empty queue.
1029
+ expect(reader.value).to eq([published_tries, published_base_interval])
1030
+ ensure
1031
+ release << true
1032
+ join_without_deadlock(writer, reader)
1033
+ end
1034
+ end
761
1035
  end
762
1036
 
763
1037
  context "#retriable tries/intervals precedence" do
@@ -1110,7 +1384,7 @@ describe Retriable do
1110
1384
 
1111
1385
  2.times { ready.pop }
1112
1386
  2.times { proceed << true }
1113
- threads.each(&:join)
1387
+ join_without_deadlock(threads)
1114
1388
 
1115
1389
  expect(results).to eq(1 => 1, 2 => 2)
1116
1390
  end
@@ -1141,7 +1415,7 @@ describe Retriable do
1141
1415
  sibling_done << true
1142
1416
  end
1143
1417
 
1144
- [setter, sibling].each(&:join)
1418
+ join_without_deadlock(setter, sibling)
1145
1419
  expect(sibling_tries).to eq(3)
1146
1420
  end
1147
1421
 
@@ -1149,7 +1423,7 @@ describe Retriable do
1149
1423
  child_tries = nil
1150
1424
 
1151
1425
  described_class.with_override(tries: 1) do
1152
- Thread.new do
1426
+ child = Thread.new do
1153
1427
  tries = 0
1154
1428
  begin
1155
1429
  described_class.retriable(tries: 3) do
@@ -1159,7 +1433,8 @@ describe Retriable do
1159
1433
  rescue StandardError
1160
1434
  child_tries = tries
1161
1435
  end
1162
- end.join
1436
+ end
1437
+ join_without_deadlock(child)
1163
1438
  end
1164
1439
 
1165
1440
  expect(child_tries).to eq(3)
@@ -1168,7 +1443,7 @@ describe Retriable do
1168
1443
  it "shares the active override with fibers in the same thread" do
1169
1444
  fiber_tries = nil
1170
1445
 
1171
- Thread.new do
1446
+ worker = Thread.new do
1172
1447
  described_class.with_override(tries: 1) do
1173
1448
  Fiber.new do
1174
1449
  tries = 0
@@ -1182,7 +1457,8 @@ describe Retriable do
1182
1457
  end
1183
1458
  end.resume
1184
1459
  end
1185
- end.join
1460
+ end
1461
+ join_without_deadlock(worker)
1186
1462
 
1187
1463
  expect(fiber_tries).to eq(1)
1188
1464
  end
@@ -1191,7 +1467,7 @@ describe Retriable do
1191
1467
  other_thread_tries = nil
1192
1468
 
1193
1469
  described_class.with_override(tries: 1) do
1194
- Thread.new do
1470
+ other = Thread.new do
1195
1471
  tries = 0
1196
1472
  begin
1197
1473
  described_class.retriable(tries: 3) do
@@ -1201,7 +1477,8 @@ describe Retriable do
1201
1477
  rescue StandardError
1202
1478
  other_thread_tries = tries
1203
1479
  end
1204
- end.join
1480
+ end
1481
+ join_without_deadlock(other)
1205
1482
  end
1206
1483
 
1207
1484
  expect(other_thread_tries).to eq(3)
@@ -1248,8 +1525,15 @@ describe Retriable do
1248
1525
  expect(@tries).to eq(1)
1249
1526
  end
1250
1527
 
1251
- it "returns nil when called without a block" do
1252
- expect(described_class.with_context(:sql)).to be_nil
1528
+ it "raises an ArgumentError when called without a block" do
1529
+ expect { described_class.with_context(:sql) }
1530
+ .to raise_error(ArgumentError, /with_context requires a block/)
1531
+ expect(@tries).to eq(0)
1532
+ end
1533
+
1534
+ it "checks for a block before looking up the context" do
1535
+ expect { described_class.with_context(:missing) }
1536
+ .to raise_error(ArgumentError, /with_context requires a block/)
1253
1537
  expect(@tries).to eq(0)
1254
1538
  end
1255
1539
 
@@ -1290,6 +1574,13 @@ describe Retriable do
1290
1574
  expect(@tries).to eq(1)
1291
1575
  end
1292
1576
 
1577
+ it "surfaces an invalid context on any retriable call before that context is used" do
1578
+ described_class.configure { |c| c.contexts[:unused] = { contexts: {} } }
1579
+
1580
+ expect { described_class.retriable { :ok } }
1581
+ .to raise_error(ArgumentError, /contexts is not a valid option/)
1582
+ end
1583
+
1293
1584
  it "invokes on_give_up configured on a context" do
1294
1585
  callback_called = false
1295
1586
  described_class.configure do |c|
@@ -1301,5 +1592,37 @@ describe Retriable do
1301
1592
 
1302
1593
  expect(callback_called).to be(true)
1303
1594
  end
1595
+
1596
+ it "resolves the context against a single config snapshot" do
1597
+ with_ctx = Retriable::Config.new(sleep_disabled: true, contexts: { api: { tries: 1 } })
1598
+ without_ctx = Retriable::Config.new(sleep_disabled: true)
1599
+
1600
+ # Simulate a concurrent #configure publishing a new config between
1601
+ # with_context's existence check and its option resolution: the first
1602
+ # config read sees the context, later reads do not. with_context must
1603
+ # read config once so the context options are never silently dropped.
1604
+ allow(described_class).to receive(:config).and_return(with_ctx, without_ctx)
1605
+
1606
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }
1607
+ .to raise_error(StandardError)
1608
+
1609
+ expect(@tries).to eq(1)
1610
+ end
1611
+
1612
+ it "resolves global options against the same snapshot used for the context" do
1613
+ special_error = Class.new(StandardError)
1614
+ with_ctx = Retriable::Config.new(sleep_disabled: true, on: [special_error], contexts: { api: { tries: 2 } })
1615
+ swapped = Retriable::Config.new(sleep_disabled: true, on: [ArgumentError])
1616
+
1617
+ # A concurrent #configure swaps the global config (here, the retriable
1618
+ # `on` list) after with_context has captured its snapshot. The whole
1619
+ # context execution must use the captured snapshot, not the swapped one.
1620
+ allow(described_class).to receive(:config).and_return(with_ctx, swapped)
1621
+
1622
+ expect { described_class.with_context(:api) { increment_tries_with_exception(special_error) } }
1623
+ .to raise_error(special_error)
1624
+
1625
+ expect(@tries).to eq(2)
1626
+ end
1304
1627
  end
1305
1628
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: retriable
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.1
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu
@@ -24,14 +24,21 @@ files:
24
24
  - ".hound.yml"
25
25
  - ".rspec"
26
26
  - ".rubocop.yml"
27
+ - AGENTS.md
27
28
  - CHANGELOG.md
28
29
  - CODE_OF_CONDUCT.md
29
30
  - Gemfile
30
31
  - LICENSE
31
32
  - README.md
32
33
  - Rakefile
34
+ - benchmark/config_publication.rb
33
35
  - bin/console
34
36
  - bin/setup
37
+ - docs/adr/0001-copy-on-write-config-publication.md
38
+ - docs/agents/domain.md
39
+ - docs/agents/issue-tracker.md
40
+ - docs/agents/triage-labels.md
41
+ - docs/migration.md
35
42
  - docs/testing.md
36
43
  - lib/retriable.rb
37
44
  - lib/retriable/config.rb
@@ -65,7 +72,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
65
72
  - !ruby/object:Gem::Version
66
73
  version: '0'
67
74
  requirements: []
68
- rubygems_version: 4.0.3
75
+ rubygems_version: 4.0.16
69
76
  specification_version: 4
70
77
  summary: Retriable is a simple DSL to retry failed code blocks with randomized exponential
71
78
  backoff