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 91faa68
Show file tree
Hide file tree
Showing 14 changed files with 155 additions and 63 deletions.
24 changes: 22 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@ Style/Documentation:
Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: comma

Style/SafeNavigation:
Enabled: false

Style/SymbolArray:
EnforcedStyle: brackets

Style/TrailingCommaInArrayLiteral:
EnforcedStyleForMultiline: consistent_comma

Lint/InheritException:
Enabled: false

Layout/IndentArray:
Layout/IndentFirstArrayElement:
Enabled: false

Layout/IndentHash:
Layout/IndentFirstHashElement:
Enabled: false

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

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

Metrics/MethodLength:
Enabled: false
Expand All @@ -36,3 +47,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) if on_retry
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
5 changes: 5 additions & 0 deletions lib/retriable/config.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

require_relative "exponential_backoff"

module Retriable
Expand All @@ -8,6 +10,7 @@ class Config
:intervals,
:timeout,
:on,
:not,
:on_retry,
:contexts,
]).freeze
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
3 changes: 3 additions & 0 deletions lib/retriable/exponential_backoff.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

module Retriable
class ExponentialBackoff
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
2 changes: 2 additions & 0 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
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
2 changes: 2 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

require "simplecov"
SimpleCov.start

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

class NonStandardError < Exception; end
class SecondNonStandardError < NonStandardError; end
class DifferentError < Exception; end

0 comments on commit 91faa68

Please sign in to comment.