Skip to content

Commit

Permalink
Allow not to retry some exceptions
Browse files Browse the repository at this point in the history
  • Loading branch information
fatkodima committed Jan 11, 2020
1 parent 2425f5d commit 735d5eb
Show file tree
Hide file tree
Showing 14 changed files with 164 additions and 81 deletions.
15 changes: 13 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ Style/TrailingCommaInArguments:
Lint/InheritException:
Enabled: false

Layout/IndentArray:
Layout/IndentFirstArrayElement:
Enabled: false

Layout/IndentHash:
Layout/IndentFirstHashElement:
Enabled: false

Style/NegatedIf:
Expand All @@ -27,6 +27,8 @@ Metrics/ModuleLength:

Metrics/LineLength:
Max: 120
Exclude:
- retriable.gemspec

Metrics/MethodLength:
Enabled: false
Expand All @@ -36,3 +38,12 @@ Metrics/BlockLength:

Metrics/AbcSize:
Enabled: false

Metrics/CyclomaticComplexity:
Enabled: false

Metrics/PerceivedComplexity:
Enabled: false

Gemspec:
Enabled: false
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

source "https://rubygems.org"

gemspec
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Here are the available options, in some vague order of relevance to most common
| ------ | ------- | ---------- |
| **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). |
| **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
| **`not`** | `[]` | Type of exceptions not to retry. Takes precedence over `on`. [Read more](#configuring-which-options-not-to-retry-with-not)
| **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks). |
| **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
| **`max_elapsed_time`** | `900` (15 min) | The maximum amount of total time in seconds that code is allowed to keep being retried. |
Expand All @@ -100,6 +101,17 @@ Here are the available options, in some vague order of relevance to most common
- A single `Regexp` pattern (retries exceptions ONLY if their `message` matches the pattern)
- An array of patterns (retries exceptions ONLY if their `message` matches at least one of the patterns)

#### Configuring Which Options Not to Retry With :not
**`:not`** Can take the same form as `:on`.

Example usage:
```ruby
class MyError < StandardError; end

Retriable.retriable(on: StandardError, not: MyError) do
raise MyError, "No retries!"
end
```

### Configuration

Expand Down
129 changes: 72 additions & 57 deletions lib/retriable.rb
Original file line number Diff line number Diff line change
@@ -1,75 +1,90 @@
# frozen_string_literal: true

require "timeout"
require_relative "retriable/config"
require_relative "retriable/exponential_backoff"
require_relative "retriable/version"

module Retriable
module_function
class << self
def configure
yield(config)
end

def configure
yield(config)
end
def config
@config ||= Config.new
end

def config
@config ||= Config.new
end
def with_context(context_key, options = {}, &block)
if !config.contexts.key?(context_key)
raise ArgumentError, "#{context_key} not found in Retriable.config.contexts. "\
"Available contexts: #{config.contexts.keys}"
end

def with_context(context_key, options = {}, &block)
if !config.contexts.key?(context_key)
raise ArgumentError, "#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
retriable(config.contexts[context_key].merge(options), &block) if block
end

retriable(config.contexts[context_key].merge(options), &block) if block
end
def retriable(opts = {})
local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts))

def retriable(opts = {})
local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts))

tries = local_config.tries
base_interval = local_config.base_interval
max_interval = local_config.max_interval
rand_factor = local_config.rand_factor
multiplier = local_config.multiplier
max_elapsed_time = local_config.max_elapsed_time
intervals = local_config.intervals
timeout = local_config.timeout
on = local_config.on
on_retry = local_config.on_retry
sleep_disabled = local_config.sleep_disabled

exception_list = on.is_a?(Hash) ? on.keys : on
start_time = Time.now
elapsed_time = -> { Time.now - start_time }

if intervals
tries = intervals.size + 1
else
intervals = ExponentialBackoff.new(
tries: tries - 1,
base_interval: base_interval,
multiplier: multiplier,
max_interval: max_interval,
rand_factor: rand_factor
).intervals
end
tries = local_config.tries
base_interval = local_config.base_interval
max_interval = local_config.max_interval
rand_factor = local_config.rand_factor
multiplier = local_config.multiplier
max_elapsed_time = local_config.max_elapsed_time
intervals = local_config.intervals
timeout = local_config.timeout
on = local_config.on
not_ = local_config.not
on_retry = local_config.on_retry
sleep_disabled = local_config.sleep_disabled

on_exceptions = on.is_a?(Hash) ? on.keys : on
not_exceptions = not_.is_a?(Hash) ? not_.keys : not_
start_time = Time.now
elapsed_time = -> { Time.now - start_time }

tries.times do |index|
try = index + 1

begin
return Timeout.timeout(timeout) { return yield(try) } if timeout
return yield(try)
rescue *[*exception_list] => exception
if on.is_a?(Hash)
raise unless exception_list.any? do |e|
exception.is_a?(e) && ([*on[e]].empty? || [*on[e]].any? { |pattern| exception.message =~ pattern })
end
if intervals
tries = intervals.size + 1
else
intervals = ExponentialBackoff.new(
tries: tries - 1,
base_interval: base_interval,
multiplier: multiplier,
max_interval: max_interval,
rand_factor: rand_factor,
).intervals
end

tries.times do |index|
try = index + 1

begin
return Timeout.timeout(timeout) { return yield(try) } if timeout

return yield(try)
rescue *not_exceptions => e
raise if !not_.is_a?(Hash) || matches?(e, not_)
rescue *on_exceptions => e
raise if on.is_a?(Hash) && !matches?(e, on)

interval = intervals[index]
on_retry&.call(e, try, elapsed_time.call, interval)
raise if try >= tries || (elapsed_time.call + interval) > max_elapsed_time

sleep interval if sleep_disabled != true
end
end
end

private

interval = intervals[index]
on_retry.call(exception, try, elapsed_time.call, interval) if on_retry
raise if try >= tries || (elapsed_time.call + interval) > max_elapsed_time
sleep interval if sleep_disabled != true
def matches?(exception, exceptions)
exception_list = exceptions.is_a?(Hash) ? exceptions.keys : exceptions
exception_list.any? do |e|
exception.is_a?(e) &&
([*exceptions[e]].empty? || [*exceptions[e]].any? { |pattern| exception.message =~ pattern })
end
end
end
Expand Down
21 changes: 13 additions & 8 deletions lib/retriable/config.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# frozen_string_literal: true

require_relative "exponential_backoff"

module Retriable
class Config
ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + [
:sleep_disabled,
:max_elapsed_time,
:intervals,
:timeout,
:on,
:on_retry,
:contexts,
ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[
sleep_disabled
max_elapsed_time
intervals
timeout
on
not
on_retry
contexts
]).freeze

attr_accessor(*ATTRIBUTES)
Expand All @@ -27,11 +30,13 @@ def initialize(opts = {})
@intervals = nil
@timeout = nil
@on = [StandardError]
@not = []
@on_retry = nil
@contexts = {}

opts.each do |k, v|
raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k)

instance_variable_set(:"@#{k}", v)
end
end
Expand Down
2 changes: 2 additions & 0 deletions lib/retriable/core_ext/kernel.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

require_relative "../../retriable"

module Kernel
Expand Down
15 changes: 9 additions & 6 deletions lib/retriable/exponential_backoff.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# frozen_string_literal: true

module Retriable
class ExponentialBackoff
ATTRIBUTES = [
:tries,
:base_interval,
:multiplier,
:max_interval,
:rand_factor,
ATTRIBUTES = %i[
tries
base_interval
multiplier
max_interval
rand_factor
].freeze

attr_accessor(*ATTRIBUTES)
Expand All @@ -19,6 +21,7 @@ def initialize(opts = {})

opts.each do |k, v|
raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k)

instance_variable_set(:"@#{k}", v)
end
end
Expand Down
4 changes: 3 additions & 1 deletion lib/retriable/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

module Retriable
VERSION = "3.1.2".freeze
VERSION = "3.1.2"
end
7 changes: 4 additions & 3 deletions retriable.gemspec
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# coding: utf-8
lib = File.expand_path("../lib", __FILE__)
# frozen_string_literal: true

lib = File.expand_path("lib", __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require "retriable/version"

Expand All @@ -24,8 +25,8 @@ Gem::Specification.new do |spec|
spec.add_development_dependency "rspec", "~> 3"

if RUBY_VERSION < "2.3"
spec.add_development_dependency "ruby_dep", "~> 1.3.1"
spec.add_development_dependency "listen", "~> 3.0.8"
spec.add_development_dependency "ruby_dep", "~> 1.3.1"
else
spec.add_development_dependency "listen", "~> 3.1"
end
Expand Down
2 changes: 2 additions & 0 deletions spec/config_spec.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

describe Retriable::Config do
let(:default_config) { described_class.new }

Expand Down
10 changes: 6 additions & 4 deletions spec/exponential_backoff_spec.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

describe Retriable::ExponentialBackoff do
context "defaults" do
let(:backoff_config) { described_class.new }
Expand Down Expand Up @@ -29,7 +31,7 @@
4.350816718580626,
5.339852157217869,
11.889873261212443,
18.756037881636484,
18.756037881636484
])
end

Expand All @@ -41,15 +43,15 @@
expect(described_class.new(base_interval: 1).intervals).to eq([
1.0488135024422882,
1.8227840477522461,
2.4812175837998227,
2.4812175837998227
])
end

it "generates intervals with a defined multiplier" do
expect(described_class.new(multiplier: 1).intervals).to eq([
0.5244067512211441,
0.607594682584082,
0.5513816852888495,
0.5513816852888495
])
end

Expand All @@ -61,7 +63,7 @@
expect(described_class.new(rand_factor: 0.2).intervals).to eq([
0.5097627004884576,
0.8145568095504492,
1.1712435167599646,
1.1712435167599646
])
end

Expand Down
22 changes: 22 additions & 0 deletions spec/retriable_spec.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

describe Retriable do
let(:time_table_handler) do
->(_exception, try, _elapsed_time, next_interval) { @next_interval_table[try] = next_interval }
Expand Down Expand Up @@ -207,6 +209,26 @@ def increment_tries_with_exception(exception_class = nil)
end
end

context "with :not parameter" do
it "does not retry on :not exception" do
expect do
described_class.retriable(not: StandardError) { increment_tries_with_exception }
end.to raise_error(StandardError)

expect(@tries).to eq(1)
end

it "gives precedence to :not over :on" do
expect do
described_class.retriable(on: StandardError, not: IndexError) do
increment_tries_with_exception(@tries >= 1 ? IndexError : StandardError)
end
end.to raise_error(IndexError)

expect(@tries).to eq(2)
end
end

it "runs for a max elapsed time of 2 seconds" do
described_class.configure { |c| c.sleep_disabled = false }

Expand Down
Loading

0 comments on commit 735d5eb

Please sign in to comment.