retriable 4.1.1 → 4.2.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: 5961397b426eeaf2d2df8631f0a5dfe05adf52d5b9070ddbdf7b7825fd88f144
4
- data.tar.gz: c982809ef09eb85ddedfcee95be53741846f6e4438af27e403c9ad4b020214c1
3
+ metadata.gz: a551efb6db5acea9e82dde8e48afdea1869f6b93461a3c48c33f8385fbea6392
4
+ data.tar.gz: fbf6bb8c5ade48420e0f2a43532bafff0e4159eb4cf149bce559116ec1c91e50
5
5
  SHA512:
6
- metadata.gz: 30d8bfeeff407ab15fa9be7c2af2993142dce86d8406318e220afdb111c728b0c8c7cd2d6b105312ed9623074a5b20c88a9c19a6c33c3169d673d2276e0b52c4
7
- data.tar.gz: f333fbf26eff94629f85f5d58688245025be3f39830bcad59da7f42ab52ad1506b155c03e1d96020a728b489ed016cb8ee1fcd5cde0a7a0ce3cfe59ad078c4d8
6
+ metadata.gz: 19d5ac72e1614b7fdb3984697db76ad3f6ae2e9b1f60ff1eb470e210738cd67154afc2776b55329fca345fad6263035a41eb0748626f427fd2d05cc0bfea9c89
7
+ data.tar.gz: c1d46594742b7c19985a63037199125b718e6c6395dd8276013719d8c7f9cd64376637471b90fa9249869767da9a2474cafc31afd04f373f73249d8ebdfa0337
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # HEAD
2
2
 
3
+ ## 4.2.0
4
+
5
+ ### Bug fixes
6
+
7
+ - The `Kernel` extension methods (`require "retriable/core_ext/kernel"`) are now
8
+ private, matching idiomatic `Kernel` helpers like `puts` and `rand`.
9
+ Previously `retriable` and `retriable_with_context` were public instance
10
+ methods, so they leaked onto every object's public API and could be invoked
11
+ with an explicit receiver (e.g. `"foo".retriable { ... }`). They remain
12
+ callable in the documented receiver-less form.
13
+ ([#146](https://github.com/kamui/retriable/pull/146))
14
+ - `Retriable.with_context` (and `Kernel#retriable_with_context`) now raises
15
+ `ArgumentError` when called without a block, matching `with_override`.
16
+ Previously a missing block was silently ignored: the call returned `nil` and
17
+ the intended block never ran, hiding a caller bug. Behavior change: code that
18
+ relied on the silent no-op will now raise.
19
+ - `Config#validate!` now validates the structure of each entry in `contexts`,
20
+ so configured contexts are checked on every `Retriable.retriable`/
21
+ `with_context` call rather than only when a given context is first used. A
22
+ context whose options contain an unknown key (including a nested `contexts`
23
+ key) now raises `ArgumentError, "<key> is not a valid option"`, matching the
24
+ `with_override` path. Non-Hash `contexts` and non-Hash per-context values
25
+ remain leniently treated as empty options (no behavior change). Option
26
+ _values_ are still validated lazily at retry time, unchanged.
27
+
28
+ ### Docs
29
+
30
+ - Document that `on_retry` receives `next_interval: nil` on the final rescued
31
+ attempt, when Retriable is about to give up because `tries` are exhausted.
32
+ `on_retry` still fires before `on_give_up` (unchanged behavior); the `nil`
33
+ contract is now called out in the `on_retry` documentation so handlers guard
34
+ arithmetic or logging on `next_interval`.
35
+
36
+ ### Performance
37
+
38
+ - `Config#initialize` no longer allocates a throwaway `ExponentialBackoff` (and
39
+ runs its redundant `validate!`) just to read default values. Defaults now live
40
+ in a frozen `ExponentialBackoff::DEFAULTS` constant, removing an allocation and
41
+ redundant validation from the `retriable` hot path.
42
+ ([#149](https://github.com/kamui/retriable/pull/149))
43
+
3
44
  ## 4.1.1
4
45
 
5
46
  ### Bug fixes
data/Gemfile CHANGED
@@ -12,7 +12,7 @@ end
12
12
  group :development do
13
13
  gem "bundler-audit", "~> 0.9"
14
14
  gem "listen", "~> 3.1"
15
- gem "rbs", "~> 3.0", platforms: :ruby
15
+ gem "rbs", "~> 4.0", platforms: :ruby
16
16
  gem "rubocop", "~> 1.86"
17
17
  end
18
18
 
data/README.md CHANGED
@@ -248,8 +248,13 @@ do not inherit it. This makes `#with_override` safe to use in parallel test
248
248
  runners. Fibers running inside the same thread share the thread's active
249
249
  override.
250
250
 
251
- `#with_override` stores the provided options directly. Do not mutate the
252
- options hash or nested values for the duration of the block.
251
+ `#with_override` stores the provided options hash **by reference** and reads
252
+ from it on every attempt while the block runs. Treat the hash and all of its
253
+ nested values as immutable for the duration of the block: do not mutate them
254
+ from inside the block, and do not mutate them from another thread or fiber that
255
+ shares this thread's active override. Mutating the options mid-block results in
256
+ undefined retry behavior. If options must be computed, build the hash before
257
+ calling `#with_override` and do not retain a reference you will later mutate.
253
258
 
254
259
  For test-integration patterns (RSpec `around`, helper methods, Minitest, etc.),
255
260
  see [docs/testing.md](docs/testing.md).
@@ -367,6 +372,8 @@ Retriable.retriable(on_retry: do_this_on_each_retry) do
367
372
  end
368
373
  ```
369
374
 
375
+ > **Note:** On the final rescued attempt — when Retriable is about to give up because `tries` are exhausted — `on_retry` still fires (before `on_give_up`; see below), but `next_interval` is **`nil`** because there is no next retry. Guard any handler that does arithmetic or formatting on `next_interval` (for example `next_interval&.*(1000)`, or `if next_interval`), and avoid unconditionally logging messages like `"retrying in #{next_interval}s"` since no retry is coming. This mirrors the `nil` contract documented for [`on_give_up`](#callbacks) below.
376
+
370
377
  #### Disabling a Configured Callback Per Call
371
378
 
372
379
  If `on_retry` is set in `Retriable.configure`, every call uses it by default. To opt a specific call out — for example, a critical call site that should not log on retry — pass `on_retry: false` or `on_retry: nil`.
@@ -471,6 +478,9 @@ Retriable.with_context(:mysql, tries: 30) do
471
478
  end
472
479
  ```
473
480
 
481
+ `#with_context` requires a block and raises `ArgumentError` if called without
482
+ one.
483
+
474
484
  ## Kernel Extension
475
485
 
476
486
  If you want to call `Retriable.retriable` without the `Retriable` module prefix and you don't mind extending `Kernel`,
@@ -18,16 +18,19 @@ module Retriable
18
18
  contexts
19
19
  ]).freeze
20
20
 
21
+ CONTEXT_ATTRIBUTES = (ATTRIBUTES - %i[contexts]).freeze
22
+ private_constant :CONTEXT_ATTRIBUTES
23
+
21
24
  attr_accessor(*ATTRIBUTES)
22
25
 
23
26
  def initialize(opts = {})
24
- backoff = ExponentialBackoff.new
27
+ defaults = ExponentialBackoff::DEFAULTS
25
28
 
26
- @tries = backoff.tries
27
- @base_interval = backoff.base_interval
28
- @max_interval = backoff.max_interval
29
- @rand_factor = backoff.rand_factor
30
- @multiplier = backoff.multiplier
29
+ @tries = defaults[:tries]
30
+ @base_interval = defaults[:base_interval]
31
+ @max_interval = defaults[:max_interval]
32
+ @rand_factor = defaults[:rand_factor]
33
+ @multiplier = defaults[:multiplier]
31
34
  @sleep_disabled = false
32
35
  @max_elapsed_time = 900 # 15 min
33
36
  @intervals = nil
@@ -51,6 +54,7 @@ module Retriable
51
54
  end
52
55
 
53
56
  def validate!
57
+ validate_contexts
54
58
  validate_callable(:retry_if, retry_if)
55
59
  validate_callable(:on_retry, on_retry)
56
60
  validate_callable(:on_give_up, on_give_up)
@@ -70,6 +74,21 @@ module Retriable
70
74
 
71
75
  private
72
76
 
77
+ def validate_contexts
78
+ return unless contexts.is_a?(Hash)
79
+ return if contexts.empty?
80
+
81
+ contexts.each_value do |options|
82
+ next unless options.is_a?(Hash)
83
+
84
+ options.each_key do |k|
85
+ next if CONTEXT_ATTRIBUTES.include?(k)
86
+
87
+ raise ArgumentError, "#{k} is not a valid option"
88
+ end
89
+ end
90
+ end
91
+
73
92
  def validate_backoff_options
74
93
  validate_non_negative_number(:base_interval, base_interval)
75
94
  validate_non_negative_number(:multiplier, multiplier)
@@ -10,4 +10,6 @@ module Kernel
10
10
  def retriable_with_context(context_key, opts = {}, &)
11
11
  Retriable.with_context(context_key, opts, &)
12
12
  end
13
+
14
+ private :retriable, :retriable_with_context
13
15
  end
@@ -14,14 +14,22 @@ module Retriable
14
14
  rand_factor
15
15
  ].freeze
16
16
 
17
+ DEFAULTS = {
18
+ tries: 3,
19
+ base_interval: 0.5,
20
+ max_interval: 60,
21
+ rand_factor: 0.5,
22
+ multiplier: 1.5
23
+ }.freeze
24
+
17
25
  attr_accessor(*ATTRIBUTES)
18
26
 
19
27
  def initialize(opts = {})
20
- @tries = 3
21
- @base_interval = 0.5
22
- @max_interval = 60
23
- @rand_factor = 0.5
24
- @multiplier = 1.5
28
+ @tries = DEFAULTS[:tries]
29
+ @base_interval = DEFAULTS[:base_interval]
30
+ @max_interval = DEFAULTS[:max_interval]
31
+ @rand_factor = DEFAULTS[:rand_factor]
32
+ @multiplier = DEFAULTS[:multiplier]
25
33
 
26
34
  opts.each do |k, v|
27
35
  raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "4.1.1"
4
+ VERSION = "4.2.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -41,6 +41,8 @@ module Retriable
41
41
  end
42
42
 
43
43
  def with_context(context_key, options = {}, &)
44
+ raise ArgumentError, "with_context requires a block" unless block_given?
45
+
44
46
  contexts = available_contexts
45
47
 
46
48
  if !contexts.key?(context_key)
@@ -48,8 +50,6 @@ module Retriable
48
50
  "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
49
51
  end
50
52
 
51
- return unless block_given?
52
-
53
53
  retriable(context_options_for(context_key, options), &)
54
54
  end
55
55
 
@@ -97,6 +97,9 @@ module Retriable
97
97
  rescue *exception_list => e
98
98
  raise unless retriable_exception?(e, on, exception_list, retry_if)
99
99
 
100
+ # On the final attempt `interval_for` returns nil (no next retry), and
101
+ # `on_retry` intentionally fires before the give-up check below, so it
102
+ # receives `interval: nil`. See the on_retry/on_give_up README contract.
100
103
  interval = interval_for.call(try - 1)
101
104
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
102
105
 
data/sig/retriable.rbs CHANGED
@@ -5,7 +5,7 @@ module Retriable
5
5
  def self.configure: () { (Config) -> void } -> void
6
6
  def self.config: () -> Config
7
7
  def self.with_override: (Hash[Symbol, untyped] options) { () -> untyped } -> untyped
8
- def self.with_context: (Symbol context_key, ?Hash[Symbol, untyped] options) ?{ (Integer) -> untyped } -> untyped
8
+ def self.with_context: (Symbol context_key, ?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
9
9
  def self.retriable: (?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
10
10
 
11
11
  class Config
data/spec/config_spec.rb CHANGED
@@ -180,4 +180,33 @@ 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
183
212
  end
@@ -52,9 +52,9 @@ describe Retriable do
52
52
 
53
53
  # These two specs lock in the anonymous block forwarding (`&`) semantics
54
54
  # 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.
55
+ # Retriable.with_context. If the `&` is dropped at either layer, the block
56
+ # is not forwarded and the `block_given?` guard in with_context raises
57
+ # ArgumentError instead of running the block.
58
58
  it "forwards a block through Kernel#retriable_with_context" do
59
59
  require_relative "../lib/retriable/core_ext/kernel"
60
60
  Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
@@ -64,13 +64,23 @@ describe Retriable do
64
64
  expect(@tries).to eq(1)
65
65
  end
66
66
 
67
- it "returns nil when Kernel#retriable_with_context is called without a block" do
67
+ it "raises an ArgumentError when Kernel#retriable_with_context is called without a block" do
68
68
  require_relative "../lib/retriable/core_ext/kernel"
69
69
  Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
70
70
 
71
- expect(retriable_with_context(:sql)).to be_nil
71
+ expect { retriable_with_context(:sql) }
72
+ .to raise_error(ArgumentError, /with_context requires a block/)
72
73
  expect(@tries).to eq(0)
73
74
  end
75
+
76
+ it "is not callable with an explicit receiver" do
77
+ require_relative "../lib/retriable/core_ext/kernel"
78
+
79
+ expect { "foo".retriable { increment_tries } }
80
+ .to raise_error(NoMethodError, /private method/)
81
+ expect { "foo".retriable_with_context(:sql) { increment_tries } }
82
+ .to raise_error(NoMethodError, /private method/)
83
+ end
74
84
  end
75
85
 
76
86
  context "#retriable" do
@@ -1248,8 +1258,15 @@ describe Retriable do
1248
1258
  expect(@tries).to eq(1)
1249
1259
  end
1250
1260
 
1251
- it "returns nil when called without a block" do
1252
- expect(described_class.with_context(:sql)).to be_nil
1261
+ it "raises an ArgumentError when called without a block" do
1262
+ expect { described_class.with_context(:sql) }
1263
+ .to raise_error(ArgumentError, /with_context requires a block/)
1264
+ expect(@tries).to eq(0)
1265
+ end
1266
+
1267
+ it "checks for a block before looking up the context" do
1268
+ expect { described_class.with_context(:missing) }
1269
+ .to raise_error(ArgumentError, /with_context requires a block/)
1253
1270
  expect(@tries).to eq(0)
1254
1271
  end
1255
1272
 
@@ -1290,6 +1307,13 @@ describe Retriable do
1290
1307
  expect(@tries).to eq(1)
1291
1308
  end
1292
1309
 
1310
+ it "surfaces an invalid context on any retriable call before that context is used" do
1311
+ described_class.configure { |c| c.contexts[:unused] = { contexts: {} } }
1312
+
1313
+ expect { described_class.retriable { :ok } }
1314
+ .to raise_error(ArgumentError, /contexts is not a valid option/)
1315
+ end
1316
+
1293
1317
  it "invokes on_give_up configured on a context" do
1294
1318
  callback_called = false
1295
1319
  described_class.configure do |c|
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: 4.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu
@@ -65,7 +65,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
65
65
  - !ruby/object:Gem::Version
66
66
  version: '0'
67
67
  requirements: []
68
- rubygems_version: 4.0.3
68
+ rubygems_version: 3.6.9
69
69
  specification_version: 4
70
70
  summary: Retriable is a simple DSL to retry failed code blocks with randomized exponential
71
71
  backoff