retriable 3.2.1 → 3.4.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: 8df6a3c9b00afd6e5f3e307fdb07d3cedae54c43b4f3537998ebae78dd17ab29
4
- data.tar.gz: 46023d8f2d1731ea9a3c66c61839b04aa49b18d3def72d1ea1fc36d9c9e7a1c3
3
+ metadata.gz: 1c6c4929adafa9b223f3fbe0ee530ef8a7d1e764cac8f5605be9ce33e3d1b0cc
4
+ data.tar.gz: 5d7013b0d5ce9ab8514a8deab7a32d77b0422cc02ead4aceb5a8c9b5ba28bff1
5
5
  SHA512:
6
- metadata.gz: cd71b6a0a42cb2c6482409bcbc42d57504c5f601aa33e80d8a032dd97e31df582234bbb8959c1cf70eb92b7cf67c650497820be5b219053322317cbb7d748efa
7
- data.tar.gz: 617c7c1785c0b7691655615484251b8b591afa43d211d0ea4ef0d94def9747e84814dbdbe9095c388c112a9957c6050d17ba4c3044bb2e2f299bf4d892f6fd04
6
+ metadata.gz: 9a83302cfe426348ae0a35e6410edce33c8985d49289ab06100be7c5d49a586f93a429632814b2129e67b20dde8963eb2f5941bcc59d12f4c1db56ce05fd6784
7
+ data.tar.gz: 792c54b8b8805eb11c93d9ac100671b357b91a465424939a5c3995a2a50a8449033a0e94138bdf2510b2a7f89b4dc35787b0c490c53d63c9a0d2b85f044c80a9
@@ -2,9 +2,9 @@ name: CI
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [master]
5
+ branches: [main]
6
6
  pull_request:
7
- branches: [master]
7
+ branches: [main]
8
8
  types: [opened, synchronize, reopened]
9
9
 
10
10
  jobs:
data/.gitignore CHANGED
@@ -4,6 +4,8 @@
4
4
  /_yardoc/
5
5
  /coverage/
6
6
  /doc/
7
+ /docs/plans/
8
+ /.worktrees/
7
9
  /pkg/
8
10
  /spec/reports/
9
11
  /tmp/
data/.rubocop.yml CHANGED
@@ -1,3 +1,7 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 2.3
4
+
1
5
  Style/StringLiterals:
2
6
  EnforcedStyle: double_quotes
3
7
 
@@ -13,12 +17,6 @@ Style/TrailingCommaInArguments:
13
17
  Lint/InheritException:
14
18
  Enabled: false
15
19
 
16
- Layout/FirstArrayElementIndentation:
17
- Enabled: false
18
-
19
- Layout/FirstHashElementIndentation:
20
- Enabled: false
21
-
22
20
  Style/NegatedIf:
23
21
  Enabled: false
24
22
 
data/CHANGELOG.md CHANGED
@@ -1,7 +1,17 @@
1
1
  # HEAD
2
2
 
3
+ ## 3.4.0
4
+
5
+ - Add `retry_if` option to support custom retry predicates, including checks against wrapped `exception.cause` values.
6
+
7
+ ## 3.3.0
8
+
9
+ - Refactor `Retriable.retriable` internals into focused private helpers to improve readability while preserving behavior.
10
+ - Modernize `.rubocop.yml` with explicit modern defaults to enable new cops while preserving existing project style policies.
11
+
3
12
  ## 3.2.1
4
- - Remove executables from gemspec as it was poluting the path for some users. Thanks @hsbt.
13
+
14
+ - Remove executables from gemspec as it was polluting the path for some users. Thanks @hsbt.
5
15
 
6
16
  ## 3.2.0
7
17
 
data/README.md CHANGED
@@ -32,7 +32,7 @@ require 'retriable'
32
32
  In your Gemfile:
33
33
 
34
34
  ```ruby
35
- gem 'retriable', '~> 3.1'
35
+ gem 'retriable', '~> 3.4'
36
36
  ```
37
37
 
38
38
  ## Usage
@@ -83,6 +83,7 @@ Here are the available options, in some vague order of relevance to most common
83
83
  | ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
84
  | **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). |
85
85
  | **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
86
+ | **`retry_if`** | `nil` | Callable (for example a `Proc` or lambda) that receives the rescued exception and returns true/false to decide whether to retry. [Read more](#advanced-retry-matching-with-retry_if). |
86
87
  | **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks). |
87
88
  | **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
88
89
  | **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
@@ -104,6 +105,32 @@ Here are the available options, in some vague order of relevance to most common
104
105
  - A single `Regexp` pattern (retries exceptions ONLY if their `message` matches the pattern)
105
106
  - An array of patterns (retries exceptions ONLY if their `message` matches at least one of the patterns)
106
107
 
108
+ #### Advanced Retry Matching With :retry_if
109
+
110
+ Use **`:retry_if`** when retry logic depends on details that `:on` does not cover. The Proc receives the rescued exception and should return `true` to retry or `false` to re-raise immediately.
111
+
112
+ ```ruby
113
+ def caused_by?(error, klass)
114
+ current = error
115
+ while current
116
+ return true if current.is_a?(klass)
117
+
118
+ current = current.cause
119
+ end
120
+
121
+ false
122
+ end
123
+
124
+ Retriable.retriable(
125
+ on: [Faraday::ConnectionFailed],
126
+ retry_if: ->(exception) { caused_by?(exception, Errno::ECONNRESET) }
127
+ ) do
128
+ # code here...
129
+ end
130
+ ```
131
+
132
+ `:retry_if` runs after the exception type has matched `:on`.
133
+
107
134
  ### Configuration
108
135
 
109
136
  You can change the global defaults with a `#configure` block:
@@ -4,14 +4,15 @@ require_relative "exponential_backoff"
4
4
 
5
5
  module Retriable
6
6
  class Config
7
- ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + [
8
- :sleep_disabled,
9
- :max_elapsed_time,
10
- :intervals,
11
- :timeout,
12
- :on,
13
- :on_retry,
14
- :contexts,
7
+ ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[
8
+ sleep_disabled
9
+ max_elapsed_time
10
+ intervals
11
+ timeout
12
+ on
13
+ retry_if
14
+ on_retry
15
+ contexts
15
16
  ]).freeze
16
17
 
17
18
  attr_accessor(*ATTRIBUTES)
@@ -29,6 +30,7 @@ module Retriable
29
30
  @intervals = nil
30
31
  @timeout = nil
31
32
  @on = [StandardError]
33
+ @retry_if = nil
32
34
  @on_retry = nil
33
35
  @contexts = {}
34
36
 
@@ -2,12 +2,12 @@
2
2
 
3
3
  module Retriable
4
4
  class ExponentialBackoff
5
- ATTRIBUTES = [
6
- :tries,
7
- :base_interval,
8
- :multiplier,
9
- :max_interval,
10
- :rand_factor,
5
+ ATTRIBUTES = %i[
6
+ tries
7
+ base_interval
8
+ multiplier
9
+ max_interval
10
+ rand_factor
11
11
  ].freeze
12
12
 
13
13
  attr_accessor(*ATTRIBUTES)
@@ -21,6 +21,7 @@ module Retriable
21
21
 
22
22
  opts.each do |k, v|
23
23
  raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k)
24
+
24
25
  instance_variable_set(:"@#{k}", v)
25
26
  end
26
27
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.2.1"
4
+ VERSION = "3.4.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -18,64 +18,120 @@ module Retriable
18
18
 
19
19
  def with_context(context_key, options = {}, &block)
20
20
  if !config.contexts.key?(context_key)
21
- raise ArgumentError, "#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
21
+ raise ArgumentError,
22
+ "#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
22
23
  end
23
24
 
24
- retriable(config.contexts[context_key].merge(options), &block) if block
25
+ return unless block_given?
26
+
27
+ retriable(config.contexts[context_key].merge(options), &block)
25
28
  end
26
29
 
27
- def retriable(opts = {})
30
+ def retriable(opts = {}, &block)
28
31
  local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts))
29
32
 
30
- tries = local_config.tries
31
- base_interval = local_config.base_interval
32
- max_interval = local_config.max_interval
33
- rand_factor = local_config.rand_factor
34
- multiplier = local_config.multiplier
35
- max_elapsed_time = local_config.max_elapsed_time
36
- intervals = local_config.intervals
37
- timeout = local_config.timeout
38
- on = local_config.on
39
- on_retry = local_config.on_retry
40
- sleep_disabled = local_config.sleep_disabled
33
+ tries = local_config.tries
34
+ intervals = build_intervals(local_config, tries)
35
+ timeout = local_config.timeout
36
+ on = local_config.on
37
+ retry_if = local_config.retry_if
38
+ on_retry = local_config.on_retry
39
+ sleep_disabled = local_config.sleep_disabled
40
+ max_elapsed_time = local_config.max_elapsed_time
41
41
 
42
42
  exception_list = on.is_a?(Hash) ? on.keys : on
43
+ exception_list = [*exception_list]
43
44
  start_time = Time.now
44
45
  elapsed_time = -> { Time.now - start_time }
45
46
 
46
- if !intervals
47
- intervals = ExponentialBackoff.new(
48
- tries: tries - 1,
49
- base_interval: base_interval,
50
- multiplier: multiplier,
51
- max_interval: max_interval,
52
- rand_factor: rand_factor
53
- ).intervals
54
- end
55
-
56
47
  tries = intervals.size + 1
57
48
 
49
+ execute_tries(
50
+ tries: tries, intervals: intervals, timeout: timeout,
51
+ exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
52
+ elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
53
+ sleep_disabled: sleep_disabled, &block
54
+ )
55
+ end
56
+
57
+ def execute_tries( # rubocop:disable Metrics/ParameterLists
58
+ tries:, intervals:, timeout:, exception_list:,
59
+ on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
60
+ )
58
61
  tries.times do |index|
59
62
  try = index + 1
60
63
 
61
64
  begin
62
- return Timeout.timeout(timeout) { return yield(try) } if timeout
63
-
64
- return yield(try)
65
- rescue *[*exception_list] => exception
66
- if on.is_a?(Hash)
67
- raise unless exception_list.any? do |e|
68
- exception.is_a?(e) && ([*on[e]].empty? || [*on[e]].any? { |pattern| exception.message =~ pattern })
69
- end
70
- end
65
+ return call_with_timeout(timeout, try, &block)
66
+ rescue *exception_list => e
67
+ raise unless retriable_exception?(e, on, exception_list, retry_if)
71
68
 
72
69
  interval = intervals[index]
73
- on_retry.call(exception, try, elapsed_time.call, interval) if on_retry
70
+ call_on_retry(on_retry, e, try, elapsed_time.call, interval)
74
71
 
75
- raise if try >= tries || (elapsed_time.call + interval) > max_elapsed_time
72
+ raise unless can_retry?(try, tries, elapsed_time.call, interval, max_elapsed_time)
76
73
 
77
74
  sleep interval if sleep_disabled != true
78
75
  end
79
76
  end
80
77
  end
78
+
79
+ def build_intervals(local_config, tries)
80
+ return local_config.intervals if local_config.intervals
81
+
82
+ ExponentialBackoff.new(
83
+ tries: tries - 1,
84
+ base_interval: local_config.base_interval,
85
+ multiplier: local_config.multiplier,
86
+ max_interval: local_config.max_interval,
87
+ rand_factor: local_config.rand_factor,
88
+ ).intervals
89
+ end
90
+
91
+ def call_with_timeout(timeout, try)
92
+ return Timeout.timeout(timeout) { yield(try) } if timeout
93
+
94
+ yield(try)
95
+ end
96
+
97
+ def call_on_retry(on_retry, exception, try, elapsed_time, interval)
98
+ return unless on_retry
99
+
100
+ on_retry.call(exception, try, elapsed_time, interval)
101
+ end
102
+
103
+ def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time)
104
+ try < tries && (elapsed_time + interval) <= max_elapsed_time
105
+ end
106
+
107
+ # When `on` is a Hash, we need to verify the exception matches a pattern.
108
+ # For any non-Hash `on` value (e.g., Array of classes, single Exception class,
109
+ # or Module), the `rescue *exception_list` clause already guarantees the
110
+ # exception is retriable with respect to `on`; `retry_if`, if provided, is an
111
+ # additional gate that can still cause this method to return false.
112
+ def retriable_exception?(exception, on, exception_list, retry_if)
113
+ return false if on.is_a?(Hash) && !hash_exception_match?(exception, on, exception_list)
114
+ return false if retry_if && !retry_if.call(exception)
115
+
116
+ true
117
+ end
118
+
119
+ def hash_exception_match?(exception, on, exception_list)
120
+ exception_list.any? do |error_class|
121
+ next false unless exception.is_a?(error_class)
122
+
123
+ patterns = [*on[error_class]]
124
+ patterns.empty? || patterns.any? { |pattern| exception.message =~ pattern }
125
+ end
126
+ end
127
+
128
+ private_class_method(
129
+ :execute_tries,
130
+ :build_intervals,
131
+ :call_with_timeout,
132
+ :call_on_retry,
133
+ :can_retry?,
134
+ :retriable_exception?,
135
+ :hash_exception_match?,
136
+ )
81
137
  end
data/retriable.gemspec CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
- lib = File.expand_path("../lib", __FILE__)
2
+
3
+ lib = File.expand_path("lib", __dir__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
5
  require "retriable/version"
5
6
 
@@ -9,7 +10,9 @@ Gem::Specification.new do |spec|
9
10
  spec.authors = ["Jack Chu"]
10
11
  spec.email = ["jack@jackchu.com"]
11
12
  spec.summary = "Retriable is a simple DSL to retry failed code blocks with randomized exponential backoff"
12
- spec.description = "Retriable is a simple DSL to retry failed code blocks with randomized exponential backoff. This is especially useful when interacting external api/services or file system calls."
13
+ spec.description = "Retriable is a simple DSL to retry failed code blocks with randomized " \
14
+ "exponential backoff. This is especially useful when interacting with external " \
15
+ "APIs/services or file system calls."
13
16
  spec.homepage = "https://github.com/kamui/retriable"
14
17
  spec.license = "MIT"
15
18
 
data/spec/config_spec.rb CHANGED
@@ -40,6 +40,10 @@ describe Retriable::Config do
40
40
  expect(default_config.on).to eq([StandardError])
41
41
  end
42
42
 
43
+ it "retry_if defaults to nil" do
44
+ expect(default_config.retry_if).to be_nil
45
+ end
46
+
43
47
  it "on_retry handler defaults to nil" do
44
48
  expect(default_config.on_retry).to be_nil
45
49
  end
@@ -96,6 +96,28 @@ describe Retriable do
96
96
  expect(@tries).to eq(10)
97
97
  end
98
98
 
99
+ it "does not call on_retry when explicitly set to false" do
100
+ callback_called = false
101
+ original_on_retry = described_class.config.on_retry
102
+
103
+ begin
104
+ described_class.configure do |c|
105
+ c.on_retry = proc { |_exception, _try, _elapsed_time, _next_interval| callback_called = true }
106
+ end
107
+
108
+ expect do
109
+ described_class.retriable(on_retry: false, tries: 3) { increment_tries_with_exception }
110
+ end.to raise_error(StandardError)
111
+
112
+ expect(@tries).to eq(3)
113
+ expect(callback_called).to be(false)
114
+ ensure
115
+ described_class.configure do |c|
116
+ c.on_retry = original_on_retry
117
+ end
118
+ end
119
+ end
120
+
99
121
  context "with rand_factor 0.0 and an on_retry handler" do
100
122
  let(:tries) { 6 }
101
123
  let(:no_rand_timetable) { { 1 => 0.5, 2 => 0.75, 3 => 1.125 } }
@@ -237,6 +259,50 @@ describe Retriable do
237
259
  end
238
260
  end
239
261
 
262
+ context "with a :retry_if parameter" do
263
+ it "retries only when retry_if returns true" do
264
+ described_class.retriable(tries: 3, retry_if: ->(_exception) { @tries < 3 }) do
265
+ increment_tries
266
+ raise StandardError, "StandardError occurred" if @tries < 3
267
+ end
268
+
269
+ expect(@tries).to eq(3)
270
+ end
271
+
272
+ it "does not retry when retry_if returns false" do
273
+ expect do
274
+ described_class.retriable(tries: 3, retry_if: ->(_exception) { false }) do
275
+ increment_tries_with_exception
276
+ end
277
+ end.to raise_error(StandardError)
278
+
279
+ expect(@tries).to eq(1)
280
+ end
281
+
282
+ it "can retry based on the wrapped exception cause" do
283
+ root_cause_class = Class.new(StandardError)
284
+ wrapper_class = Class.new(StandardError)
285
+
286
+ described_class.retriable(
287
+ on: [wrapper_class],
288
+ tries: 3,
289
+ retry_if: ->(exception) { exception.cause.is_a?(root_cause_class) },
290
+ ) do
291
+ increment_tries
292
+
293
+ if @tries < 3
294
+ begin
295
+ raise root_cause_class, "root cause"
296
+ rescue root_cause_class
297
+ raise wrapper_class, "wrapped"
298
+ end
299
+ end
300
+ end
301
+
302
+ expect(@tries).to eq(3)
303
+ end
304
+ end
305
+
240
306
  it "runs for a max elapsed time of 2 seconds" do
241
307
  described_class.configure { |c| c.sleep_disabled = false }
242
308
 
@@ -255,6 +321,17 @@ describe Retriable do
255
321
  end
256
322
 
257
323
  context "#configure" do
324
+ it "exposes only the intended public API" do
325
+ public_api_methods = %i[
326
+ retriable
327
+ with_context
328
+ configure
329
+ config
330
+ ]
331
+
332
+ expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
333
+ end
334
+
258
335
  it "raises NoMethodError on invalid configuration" do
259
336
  expect { described_class.configure { |c| c.does_not_exist = 123 } }.to raise_error(NoMethodError)
260
337
  end
@@ -275,6 +352,22 @@ describe Retriable do
275
352
  expect(@tries).to eq(1)
276
353
  end
277
354
 
355
+ it "returns nil when called without a block" do
356
+ expect(described_class.with_context(:sql)).to be_nil
357
+ expect(@tries).to eq(0)
358
+ end
359
+
360
+ it "passes try count through to the context block" do
361
+ seen_tries = []
362
+
363
+ described_class.with_context(:api) do |try|
364
+ seen_tries << try
365
+ raise StandardError if try < 3
366
+ end
367
+
368
+ expect(seen_tries).to eq([1, 2, 3])
369
+ end
370
+
278
371
  it "respects the context options" do
279
372
  expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
280
373
  expect(@tries).to eq(api_tries)
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: 3.2.1
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu
@@ -52,7 +52,7 @@ dependencies:
52
52
  - !ruby/object:Gem::Version
53
53
  version: '3.1'
54
54
  description: Retriable is a simple DSL to retry failed code blocks with randomized
55
- exponential backoff. This is especially useful when interacting external api/services
55
+ exponential backoff. This is especially useful when interacting with external APIs/services
56
56
  or file system calls.
57
57
  email:
58
58
  - jack@jackchu.com