diff --git a/.rubocop.yml b/.rubocop.yml index 4b695e3..744fd88 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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: @@ -27,6 +36,8 @@ Metrics/ModuleLength: Metrics/LineLength: Max: 120 + Exclude: + - retriable.gemspec Metrics/MethodLength: Enabled: false @@ -36,3 +47,12 @@ Metrics/BlockLength: Metrics/AbcSize: Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Gemspec: + Enabled: false diff --git a/Gemfile b/Gemfile index e4d505f..e45ef18 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source "https://rubygems.org" gemspec diff --git a/README.md b/README.md index 551b14c..f4eec34 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 diff --git a/lib/retriable.rb b/lib/retriable.rb index c944093..3085bbf 100644 --- a/lib/retriable.rb +++ b/lib/retriable.rb @@ -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 diff --git a/lib/retriable/config.rb b/lib/retriable/config.rb index 38368be..5fe1c88 100644 --- a/lib/retriable/config.rb +++ b/lib/retriable/config.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative "exponential_backoff" module Retriable @@ -8,6 +10,7 @@ class Config :intervals, :timeout, :on, + :not, :on_retry, :contexts, ]).freeze @@ -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 diff --git a/lib/retriable/core_ext/kernel.rb b/lib/retriable/core_ext/kernel.rb index a95c9f2..78e8e1b 100644 --- a/lib/retriable/core_ext/kernel.rb +++ b/lib/retriable/core_ext/kernel.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative "../../retriable" module Kernel diff --git a/lib/retriable/exponential_backoff.rb b/lib/retriable/exponential_backoff.rb index a85af41..a31b45f 100644 --- a/lib/retriable/exponential_backoff.rb +++ b/lib/retriable/exponential_backoff.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Retriable class ExponentialBackoff ATTRIBUTES = [ @@ -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 diff --git a/lib/retriable/version.rb b/lib/retriable/version.rb index 62479cb..9d33773 100644 --- a/lib/retriable/version.rb +++ b/lib/retriable/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Retriable - VERSION = "3.1.2".freeze + VERSION = "3.1.2" end diff --git a/retriable.gemspec b/retriable.gemspec index d9cf266..d996c0c 100644 --- a/retriable.gemspec +++ b/retriable.gemspec @@ -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" @@ -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 diff --git a/spec/config_spec.rb b/spec/config_spec.rb index 53fcb48..42caf82 100644 --- a/spec/config_spec.rb +++ b/spec/config_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + describe Retriable::Config do let(:default_config) { described_class.new } diff --git a/spec/exponential_backoff_spec.rb b/spec/exponential_backoff_spec.rb index a0c0d8b..6bf7a41 100644 --- a/spec/exponential_backoff_spec.rb +++ b/spec/exponential_backoff_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + describe Retriable::ExponentialBackoff do context "defaults" do let(:backoff_config) { described_class.new } diff --git a/spec/retriable_spec.rb b/spec/retriable_spec.rb index 5084e2f..ca8111f 100644 --- a/spec/retriable_spec.rb +++ b/spec/retriable_spec.rb @@ -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 } @@ -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 } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 335d435..cb5e472 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "simplecov" SimpleCov.start diff --git a/spec/support/exceptions.rb b/spec/support/exceptions.rb index 17e896c..744660b 100644 --- a/spec/support/exceptions.rb +++ b/spec/support/exceptions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class NonStandardError < Exception; end class SecondNonStandardError < NonStandardError; end class DifferentError < Exception; end