diff --git a/.gitignore b/.gitignore index 8d324ef..1095617 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ /.bundle/ /.yardoc -/Gemfile.lock /_yardoc/ /coverage/ /doc/ diff --git a/.hound.yml b/.hound.yml index 5d0ff60..ab75abd 100644 --- a/.hound.yml +++ b/.hound.yml @@ -1,2 +1,3 @@ ruby: - config_file: .rubocop.yml +# config_file: .rubocop.yml + enabled: false \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index 4b695e3..85439ee 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,25 +1,31 @@ -Style/StringLiterals: - EnforcedStyle: double_quotes +AllCops: + TargetRubyVersion: 2.0 -Style/Documentation: - Enabled: false +Layout/IndentArray: + EnforcedStyle: consistent + +Layout/SpaceAroundOperators: + AllowForAlignment: true -Style/TrailingCommaInArguments: - EnforcedStyleForMultiline: comma Lint/InheritException: + Exclude: + - 'spec/support/exceptions.rb' + + +Metrics/AbcSize: Enabled: false -Layout/IndentArray: +Metrics/BlockLength: Enabled: false -Layout/IndentHash: +Metrics/ClassLength: Enabled: false -Style/NegatedIf: +Metrics/CyclomaticComplexity: Enabled: false -Metrics/ClassLength: +Metrics/MethodLength: Enabled: false Metrics/ModuleLength: @@ -28,11 +34,31 @@ Metrics/ModuleLength: Metrics/LineLength: Max: 120 -Metrics/MethodLength: +Metrics/PerceivedComplexity: Enabled: false -Metrics/BlockLength: + +RSpec/ExampleLength: Enabled: false -Metrics/AbcSize: +RSpec/FilePath: + # rspec thinks these should be in spec/retriable/ + # There is a SpecSuffixOnly option that should fix this + # but it's being silently ignored + Exclude: + - 'spec/config_spec.rb' + - 'spec/exponential_backoff_spec.rb' + +RSpec/InstanceVariable: + Exclude: + - 'spec/retriable_spec.rb' + + +Style/Documentation: + Enabled: false + +Style/NegatedIf: Enabled: false + +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma diff --git a/.travis.yml b/.travis.yml index 860d452..167fde9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ # Send builds to container-based infrastructure # http://docs.travis-ci.com/user/workers/container-based-infrastructure/ -dist: trusty +dist: xenial language: ruby rvm: - 2.0.0 @@ -10,6 +10,8 @@ rvm: - 2.4.6 - 2.5.5 - 2.6.2 + - 2.7 + - 3.0 - ruby-head - jruby-9.2.9.0 - jruby-head @@ -24,8 +26,8 @@ env: - CC_TEST_REPORTER_ID=20a1139ef1830b4f813a10a03d90e8aa179b5226f75e75c5a949b25756ebf558 before_install: - - gem install rubygems-update -v '<3' --no-document && update_rubygems - gem install bundler -v 1.17.3 + - bundle install before_script: - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter @@ -33,7 +35,7 @@ before_script: - ./cc-test-reporter before-build script: - - bundle exec rspec + - bundle exec rake test:ci after_script: - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT diff --git a/Gemfile b/Gemfile index e4d505f..efbce00 100644 --- a/Gemfile +++ b/Gemfile @@ -1,16 +1,25 @@ -source "https://rubygems.org" +# frozen_string_literal: true + +source 'https://rubygems.org' gemspec group :test do - gem "rspec" - gem "simplecov", require: false + gem 'rspec' + gem 'simplecov', require: false end -group :development do - gem "rubocop" +group :development, :test do + gem 'rubocop', '>= 0.50', '< 0.51', require: false + gem 'rubocop-rspec', require: false end group :development, :test do - gem "pry" + gem 'jazz_fingers' + gem 'rake' + # byebug constraint due to lack of support for binding.local_variables + # in ruby 2.0 + gem 'byebug', '< 9.0.0' + # pry constraints fix https://github.com/pry/pry/issues/2121 + gem 'pry', '!= 0.13.0', '!= 0.13.1' end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..bfc444a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,98 @@ +PATH + remote: . + specs: + retriable (3.1.2) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + awesome_print (1.8.0) + byebug (8.2.5) + coderay (1.1.3) + coolline (0.5.0) + unicode_utils (~> 1.4) + diff-lcs (1.4.4) + docile (1.3.5) + hirb (0.7.3) + jazz_fingers (3.0.1) + awesome_print (~> 1.6) + hirb (~> 0.7) + pry (~> 0.10) + pry-byebug (~> 3.1) + pry-coolline (~> 0.2) + pry-doc (~> 0.6) + pry-remote (>= 0.1.7) + json (2.5.1) + method_source (0.9.2) + parallel (1.13.0) + parser (2.7.2.0) + ast (~> 2.4.1) + powerpack (0.1.3) + pry (0.12.2) + coderay (~> 1.1.0) + method_source (~> 0.9.0) + pry-byebug (3.3.0) + byebug (~> 8.0) + pry (~> 0.10) + pry-coolline (0.2.5) + coolline (~> 0.5) + pry-doc (0.13.5) + pry (~> 0.11) + yard (~> 0.9.11) + pry-remote (0.1.8) + pry (~> 0.9) + slop (~> 3.0) + rainbow (2.2.2) + rake + rake (12.3.3) + rspec (3.10.0) + rspec-core (~> 3.10.0) + rspec-expectations (~> 3.10.0) + rspec-mocks (~> 3.10.0) + rspec-core (3.10.1) + rspec-support (~> 3.10.0) + rspec-expectations (3.10.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-mocks (3.10.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-support (3.10.1) + rubocop (0.50.0) + parallel (~> 1.10) + parser (>= 2.3.3.1, < 3.0) + powerpack (~> 0.1) + rainbow (>= 2.2.2, < 3.0) + ruby-progressbar (~> 1.7) + unicode-display_width (~> 1.0, >= 1.0.1) + rubocop-rspec (1.5.1) + rubocop (>= 0.41.2) + ruby-progressbar (1.11.0) + simplecov (0.17.1) + docile (~> 1.1) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.2) + slop (3.6.0) + unicode-display_width (1.7.0) + unicode_utils (1.4.0) + yard (0.9.26) + +PLATFORMS + ruby + +DEPENDENCIES + bundler + byebug (< 9.0.0) + jazz_fingers + pry (!= 0.13.1, != 0.13.0) + rake + retriable! + rspec + rubocop (>= 0.50, < 0.51) + rubocop-rspec + simplecov + +BUNDLED WITH + 1.17.3 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..377b18a --- /dev/null +++ b/Rakefile @@ -0,0 +1,38 @@ +require 'bundler/setup' +Bundler.require(:test, :development) + +begin + require 'rspec/core/rake_task' + require 'rubocop/rake_task' +rescue LoadError => e + require 'shellwords' + raise "Required gem #{e.path.inspect} not found. " \ + 'Be sure you ran `bundle install` and are launching rake via ' \ + "`bundle exec rake #{ARGV.shelljoin}`." +end + +namespace :test do + RSpec::Core::RakeTask.new(:rspec) do |t| + t.rspec_opts = '-w --backtrace --no-fail-fast' + end + + RuboCop::RakeTask.new('rubocop') do |t, _task_args| + t.options << '--require' << 'rubocop-rspec' + t.options << '--fail-level' << 'convention' + t.options << '--display-cop-names' + t.options << '--extra-details' + t.options << '--display-style-guide' + # Can't enable parallel because it will break rubocop:autocorrect + # t.options << '--parallel' + end + + desc 'Run all tests' + task all: %w[test:rubocop test:rspec] + + task ci: %w[test:all] +end + +desc 'Alias for test:all' +task test: %w[test:all] + +task default: %w[test] diff --git a/lib/retriable.rb b/lib/retriable.rb index c944093..69716fe 100644 --- a/lib/retriable.rb +++ b/lib/retriable.rb @@ -1,7 +1,7 @@ -require "timeout" -require_relative "retriable/config" -require_relative "retriable/exponential_backoff" -require_relative "retriable/version" +require 'timeout' +require_relative 'retriable/config' +require_relative 'retriable/exponential_backoff' +require_relative 'retriable/version' module Retriable module_function @@ -15,8 +15,9 @@ def config 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}" + unless config.contexts.key?(context_key) + raise ArgumentError, "#{context_key} not found in Retriable.config.contexts. "\ + "Available contexts: #{config.contexts.keys}" end retriable(config.contexts[context_key].merge(options), &block) if block @@ -42,35 +43,53 @@ def retriable(opts = {}) elapsed_time = -> { Time.now - start_time } if intervals - tries = intervals.size + 1 + tries = intervals.size + 1 if intervals.size else - intervals = ExponentialBackoff.new( - tries: tries - 1, + backoff = ExponentialBackoff.new( + tries: tries ? tries - 1 : nil, base_interval: base_interval, multiplier: multiplier, max_interval: max_interval, - rand_factor: rand_factor - ).intervals + rand_factor: rand_factor, + ) + intervals = backoff.intervals end - tries.times do |index| - try = index + 1 - + # TODO: Ideally this would be it's own function, but would probably require + # a separate class to efficiently pass on the processed config + run_try = proc do |interval, try| begin return Timeout.timeout(timeout) { return yield(try) } if timeout return yield(try) - rescue *[*exception_list] => exception + 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 end - - 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 + + # Note: Tries can't always be calculated if a custom Enumerator is given + # for the intervals argument. (Enumerator#size will return nil, per docs) + # So we'll just let the enumerator run out, and then invoke run_try once more. + # With a nil timeout. + raise unless interval + raise if max_elapsed_time && elapsed_time.call + interval > max_elapsed_time + sleep interval unless sleep_disabled + throw :failed + end + end + + try = 0 + intervals.each do |interval| + try += 1 + # Use throw/catch to distinguish between success and caught exception + catch :failed do + result = run_try.call(interval, try) + return result end + break if tries && (try + 1 >= tries) end + run_try.call(nil, try + 1) end end diff --git a/lib/retriable/config.rb b/lib/retriable/config.rb index 38368be..b224366 100644 --- a/lib/retriable/config.rb +++ b/lib/retriable/config.rb @@ -1,15 +1,15 @@ -require_relative "exponential_backoff" +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 + on_retry + contexts ]).freeze attr_accessor(*ATTRIBUTES) @@ -31,7 +31,7 @@ def initialize(opts = {}) @contexts = {} opts.each do |k, v| - raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k) + raise ArgumentError, "#{k} is not a valid option" unless 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..d80d6de 100644 --- a/lib/retriable/core_ext/kernel.rb +++ b/lib/retriable/core_ext/kernel.rb @@ -1,4 +1,4 @@ -require_relative "../../retriable" +require_relative '../../retriable' module Kernel def retriable(opts = {}, &block) diff --git a/lib/retriable/exponential_backoff.rb b/lib/retriable/exponential_backoff.rb index a85af41..21b7846 100644 --- a/lib/retriable/exponential_backoff.rb +++ b/lib/retriable/exponential_backoff.rb @@ -1,11 +1,11 @@ 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) @@ -18,24 +18,28 @@ def initialize(opts = {}) @multiplier = 1.5 opts.each do |k, v| - raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k) + raise ArgumentError, "#{k} is not a valid option" unless ATTRIBUTES.include?(k) instance_variable_set(:"@#{k}", v) end end def intervals - intervals = Array.new(tries) do |iteration| - [base_interval * multiplier**iteration, max_interval].min + Enumerator.new(tries) do |result| + try = 0 + loop do + interval = [base_interval * multiplier**try, max_interval].min + result << randomize(interval) + try += 1 + raise StopIteration if tries && try >= tries + end end - - return intervals if rand_factor.zero? - - intervals.map { |i| randomize(i) } end private def randomize(interval) + return interval if rand_factor.zero? + delta = rand_factor * interval * 1.0 min = interval - delta max = interval + delta diff --git a/lib/retriable/version.rb b/lib/retriable/version.rb index 62479cb..3d9b14b 100644 --- a/lib/retriable/version.rb +++ b/lib/retriable/version.rb @@ -1,3 +1,3 @@ module Retriable - VERSION = "3.1.2".freeze + VERSION = '3.1.2'.freeze end diff --git a/retriable.gemspec b/retriable.gemspec index d9cf266..f8ff005 100644 --- a/retriable.gemspec +++ b/retriable.gemspec @@ -1,32 +1,26 @@ -# coding: utf-8 -lib = File.expand_path("../lib", __FILE__) + +lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require "retriable/version" +require 'retriable/version' Gem::Specification.new do |spec| - spec.name = "retriable" + spec.name = 'retriable' spec.version = Retriable::VERSION - spec.authors = ["Jack Chu"] - spec.email = ["jack@jackchu.com"] - spec.summary = "Retriable is a simple DSL to retry failed code blocks with randomized exponential backoff" - 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." - spec.homepage = "https://github.com/kamui/retriable" - spec.license = "MIT" + spec.authors = ['Jack Chu'] + spec.email = ['jack@jackchu.com'] + spec.summary = 'Retriable is a simple DSL to retry failed code blocks with randomized exponential backoff' + 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.' + spec.homepage = 'https://github.com/kamui/retriable' + spec.license = 'MIT' spec.files = `git ls-files -z`.split("\x0") spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) - spec.require_paths = ["lib"] - - spec.required_ruby_version = ">= 2.0.0" + spec.require_paths = ['lib'] - spec.add_development_dependency "bundler" - spec.add_development_dependency "rspec", "~> 3" + spec.required_ruby_version = '>= 2.0.0' - if RUBY_VERSION < "2.3" - spec.add_development_dependency "ruby_dep", "~> 1.3.1" - spec.add_development_dependency "listen", "~> 3.0.8" - else - spec.add_development_dependency "listen", "~> 3.1" - end + spec.add_development_dependency 'bundler' + spec.add_development_dependency 'rspec', '~> 3' end diff --git a/spec/config_spec.rb b/spec/config_spec.rb index 53fcb48..4cb14df 100644 --- a/spec/config_spec.rb +++ b/spec/config_spec.rb @@ -1,53 +1,53 @@ describe Retriable::Config do let(:default_config) { described_class.new } - context "defaults" do - it "sleep defaults to enabled" do + context 'defaults' do + it 'sleep defaults to enabled' do expect(default_config.sleep_disabled).to be_falsey end - it "tries defaults to 3" do + it 'tries defaults to 3' do expect(default_config.tries).to eq(3) end - it "max interval defaults to 60" do + it 'max interval defaults to 60' do expect(default_config.max_interval).to eq(60) end - it "randomization factor defaults to 0.5" do + it 'randomization factor defaults to 0.5' do expect(default_config.base_interval).to eq(0.5) end - it "multiplier defaults to 1.5" do + it 'multiplier defaults to 1.5' do expect(default_config.multiplier).to eq(1.5) end - it "max elapsed time defaults to 900" do + it 'max elapsed time defaults to 900' do expect(default_config.max_elapsed_time).to eq(900) end - it "intervals defaults to nil" do + it 'intervals defaults to nil' do expect(default_config.intervals).to be_nil end - it "timeout defaults to nil" do + it 'timeout defaults to nil' do expect(default_config.timeout).to be_nil end - it "on defaults to [StandardError]" do + it 'on defaults to [StandardError]' do expect(default_config.on).to eq([StandardError]) end - it "on_retry handler defaults to nil" do + it 'on_retry handler defaults to nil' do expect(default_config.on_retry).to be_nil end - it "contexts defaults to {}" do + it 'contexts defaults to {}' do expect(default_config.contexts).to eq({}) end end - it "raises errors on invalid configuration" do + it 'raises errors on invalid configuration' do expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/) end end diff --git a/spec/exponential_backoff_spec.rb b/spec/exponential_backoff_spec.rb index a0c0d8b..d211981 100644 --- a/spec/exponential_backoff_spec.rb +++ b/spec/exponential_backoff_spec.rb @@ -1,26 +1,26 @@ describe Retriable::ExponentialBackoff do - context "defaults" do + context 'defaults' do let(:backoff_config) { described_class.new } - it "tries defaults to 3" do + it 'tries defaults to 3' do expect(backoff_config.tries).to eq(3) end - it "max interval defaults to 60" do + it 'max interval defaults to 60' do expect(backoff_config.max_interval).to eq(60) end - it "randomization factor defaults to 0.5" do + it 'randomization factor defaults to 0.5' do expect(backoff_config.base_interval).to eq(0.5) end - it "multiplier defaults to 1.5" do + it 'multiplier defaults to 1.5' do expect(backoff_config.multiplier).to eq(1.5) end end - it "generates 10 randomized intervals" do - expect(described_class.new(tries: 9).intervals).to eq([ + it 'generates 10 randomized intervals' do + expect(described_class.new(tries: 9).intervals.to_a).to eq([ 0.5244067512211441, 0.9113920238761231, 1.2406087918999114, @@ -29,44 +29,44 @@ 4.350816718580626, 5.339852157217869, 11.889873261212443, - 18.756037881636484, + 18.756037881636484 ]) end - it "generates defined number of intervals" do + it 'generates defined number of intervals' do expect(described_class.new(tries: 5).intervals.size).to eq(5) end - it "generates intervals with a defined base interval" do - expect(described_class.new(base_interval: 1).intervals).to eq([ + it 'generates intervals with a defined base interval' do + expect(described_class.new(base_interval: 1).intervals.to_a).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([ + it 'generates intervals with a defined multiplier' do + expect(described_class.new(multiplier: 1).intervals.to_a).to eq([ 0.5244067512211441, 0.607594682584082, - 0.5513816852888495, + 0.5513816852888495 ]) end - it "generates intervals with a defined max interval" do - expect(described_class.new(max_interval: 1.0, rand_factor: 0.0).intervals).to eq([0.5, 0.75, 1.0]) + it 'generates intervals with a defined max interval' do + expect(described_class.new(max_interval: 1.0, rand_factor: 0.0).intervals.to_a).to eq([0.5, 0.75, 1.0]) end - it "generates intervals with a defined rand_factor" do - expect(described_class.new(rand_factor: 0.2).intervals).to eq([ + it 'generates intervals with a defined rand_factor' do + expect(described_class.new(rand_factor: 0.2).intervals.to_a).to eq([ 0.5097627004884576, 0.8145568095504492, - 1.1712435167599646, + 1.1712435167599646 ]) end - it "generates 10 non-randomized intervals" do + it 'generates 10 non-randomized intervals' do non_random_intervals = 9.times.inject([0.5]) { |memo, _i| memo + [memo.last * 1.5] } - expect(described_class.new(tries: 10, rand_factor: 0.0).intervals).to eq(non_random_intervals) + expect(described_class.new(tries: 10, rand_factor: 0.0).intervals.to_a).to eq(non_random_intervals) end end diff --git a/spec/retriable_spec.rb b/spec/retriable_spec.rb index 5084e2f..4317892 100644 --- a/spec/retriable_spec.rb +++ b/spec/retriable_spec.rb @@ -19,36 +19,36 @@ def increment_tries_with_exception(exception_class = nil) raise exception_class, "#{exception_class} occurred" end - context "global scope extension" do - it "cannot be called in the global scope without requiring the core_ext/kernel" do - expect { retriable { puts "should raise NoMethodError" } }.to raise_error(NoMethodError) + context 'global scope extension' do + it 'cannot be called in the global scope without requiring the core_ext/kernel' do + expect { retriable { puts 'should raise NoMethodError' } }.to raise_error(NoMethodError) end - it "can be called once the kernel extension is required" do - require_relative "../lib/retriable/core_ext/kernel" + it 'can be called once the kernel extension is required' do + require_relative '../lib/retriable/core_ext/kernel' expect { retriable { increment_tries_with_exception } }.to raise_error(StandardError) expect(@tries).to eq(3) end end - context "#retriable" do - it "raises a LocalJumpError if not given a block" do + context '#retriable' do + it 'raises a LocalJumpError if not given a block' do expect { described_class.retriable }.to raise_error(LocalJumpError) expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError) end - it "stops at first try if the block does not raise an exception" do + it 'stops at first try if the block does not raise an exception' do described_class.retriable { increment_tries } expect(@tries).to eq(1) end - it "makes 3 tries when retrying block of code raising StandardError with no arguments" do + it 'makes 3 tries when retrying block of code raising StandardError with no arguments' do expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError) expect(@tries).to eq(3) end - it "makes only 1 try when exception raised is not descendent of StandardError" do + it 'makes only 1 try when exception raised is not descendent of StandardError' do expect do described_class.retriable { increment_tries_with_exception(NonStandardError) } end.to raise_error(NonStandardError) @@ -56,7 +56,7 @@ def increment_tries_with_exception(exception_class = nil) expect(@tries).to eq(1) end - it "with custom exception tries 3 times and re-raises the exception" do + it 'with custom exception tries 3 times and re-raises the exception' do expect do described_class.retriable(on: NonStandardError) { increment_tries_with_exception(NonStandardError) } end.to raise_error(NonStandardError) @@ -64,16 +64,16 @@ def increment_tries_with_exception(exception_class = nil) expect(@tries).to eq(3) end - it "tries 10 times when specified" do + it 'tries 10 times when specified' do expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError) expect(@tries).to eq(10) end - it "will timeout after 1 second" do + it 'will timeout after 1 second' do expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error) end - it "applies a randomized exponential backoff to each try" do + it 'applies a randomized exponential backoff to each try' do expect do described_class.retriable(on_retry: time_table_handler, tries: 10) { increment_tries_with_exception } end.to raise_error(StandardError) @@ -94,12 +94,12 @@ def increment_tries_with_exception(exception_class = nil) expect(@tries).to eq(10) end - context "with rand_factor 0.0 and an on_retry handler" do + context 'with rand_factor 0.0 and an on_retry handler' do let(:tries) { 6 } let(:no_rand_timetable) { { 1 => 0.5, 2 => 0.75, 3 => 1.125 } } let(:args) { { on_retry: time_table_handler, rand_factor: 0.0, tries: tries } } - it "applies a non-randomized exponential backoff to each try" do + it 'applies a non-randomized exponential backoff to each try' do described_class.retriable(args) do increment_tries raise StandardError if @tries < tries @@ -109,7 +109,7 @@ def increment_tries_with_exception(exception_class = nil) expect(@next_interval_table).to eq(no_rand_timetable.merge(4 => 1.6875, 5 => 2.53125)) end - it "obeys a max interval of 1.5 seconds" do + it 'obeys a max interval of 1.5 seconds' do expect do described_class.retriable(args.merge(max_interval: 1.5)) { increment_tries_with_exception } end.to raise_error(StandardError) @@ -117,7 +117,7 @@ def increment_tries_with_exception(exception_class = nil) expect(@next_interval_table).to eq(no_rand_timetable.merge(4 => 1.5, 5 => 1.5, 6 => nil)) end - it "obeys custom defined intervals" do + it 'obeys custom defined intervals' do interval_hash = no_rand_timetable.merge(4 => 1.5, 5 => 1.5, 6 => nil) intervals = interval_hash.values.compact.sort @@ -132,8 +132,73 @@ def increment_tries_with_exception(exception_class = nil) end end - context "with an array :on parameter" do - it "handles both kinds of exceptions" do + context 'with interval enumerator' do + let(:intervals_with_size) do + Enumerator.new(4) { |result| loop { result << 0.00001 } } + end + let(:intervals_with_one_result) do + Enumerator.new(4) do |result| + loop do + result << 0.00001 + raise StopIteration + end + end + end + + it 'uses size of Enumerator if it can be determined without iterating' do + expect do + described_class.retriable(on: StandardError, tries: 6, + intervals: intervals_with_size, max_elapsed_time: 1.2) do + increment_tries_with_exception + end + end.to raise_error(StandardError) + expect(@tries).to eq(5) + end + + it 'recognizes if Enumerator stops iterating' do + expect do + described_class.retriable(on: StandardError, tries: 6, + intervals: intervals_with_one_result, max_elapsed_time: 1.2) do + increment_tries_with_exception + end + end.to raise_error(StandardError) + expect(@tries).to eq(2) + end + + it 'lazily iterates through the enumerator' do + start_time = Time.now + intervals = Enumerator.new do |result| + loop do + result << 0.00001 + sleep 0.2 + end + end + expect do + described_class.retriable(on: StandardError, tries: 6, intervals: intervals, max_elapsed_time: 1.2) do + increment_tries_with_exception + end + end.to raise_error(StandardError) + expect(@tries).to eq(6) + expect(Time.now - start_time).to be < 2 + end + end + + context 'with unlimited tries' do + let(:args) { { on: StandardError, tries: nil, rand_factor: 0.0, multiplier: 1, base_interval: 0.00001 } } + + it 'keeps going indefinitely' do + start_time = Time.now.to_i + expect do + described_class.retriable(args) do + increment_tries_with_exception(NonStandardError) if start_time + 3 < Time.now.to_i + increment_tries_with_exception + end + end.to raise_error(NonStandardError) + expect(@tries).to be > 100 + end + end + context 'with an array :on parameter' do + it 'handles both kinds of exceptions' do described_class.retriable(on: [StandardError, NonStandardError]) do increment_tries @@ -145,10 +210,10 @@ def increment_tries_with_exception(exception_class = nil) end end - context "with a hash :on parameter" do + context 'with a hash :on parameter' do let(:on_hash) { { NonStandardError => /NonStandardError occurred/ } } - it "where the value is an exception message pattern" do + it 'where the value is an exception message pattern' do expect do described_class.retriable(on: on_hash) { increment_tries_with_exception(NonStandardError) } end.to raise_error(NonStandardError, /NonStandardError occurred/) @@ -156,7 +221,7 @@ def increment_tries_with_exception(exception_class = nil) expect(@tries).to eq(3) end - it "matches exception subclasses when message matches pattern" do + it 'matches exception subclasses when message matches pattern' do expect do described_class.retriable(on: on_hash.merge(DifferentError => [/shouldn't happen/, /also not/])) do increment_tries_with_exception(SecondNonStandardError) @@ -166,18 +231,18 @@ def increment_tries_with_exception(exception_class = nil) expect(@tries).to eq(3) end - it "does not retry matching exception subclass but not message" do + it 'does not retry matching exception subclass but not message' do expect do described_class.retriable(on: on_hash) do increment_tries - raise SecondNonStandardError, "not a match" + raise SecondNonStandardError, 'not a match' end end.to raise_error(SecondNonStandardError, /not a match/) expect(@tries).to eq(1) end - it "successfully retries when the values are arrays of exception message patterns" do + it 'successfully retries when the values are arrays of exception message patterns' do exceptions = [] handler = ->(exception, try, _elapsed_time, _next_interval) { exceptions[try] = exception } on_hash = { StandardError => nil, NonStandardError => [/foo/, /bar/] } @@ -188,30 +253,30 @@ def increment_tries_with_exception(exception_class = nil) case @tries when 1 - raise NonStandardError, "foo" + raise NonStandardError, 'foo' when 2 - raise NonStandardError, "bar" + raise NonStandardError, 'bar' when 3 raise StandardError else - raise NonStandardError, "crash" + raise NonStandardError, 'crash' end end end.to raise_error(NonStandardError, /crash/) expect(exceptions[1]).to be_a(NonStandardError) - expect(exceptions[1].message).to eq("foo") + expect(exceptions[1].message).to eq('foo') expect(exceptions[2]).to be_a(NonStandardError) - expect(exceptions[2].message).to eq("bar") + expect(exceptions[2].message).to eq('bar') expect(exceptions[3]).to be_a(StandardError) end end - it "runs for a max elapsed time of 2 seconds" do + it 'runs for a max elapsed time of 2 seconds' do described_class.configure { |c| c.sleep_disabled = false } expect do - described_class.retriable(base_interval: 1.0, multiplier: 1.0, rand_factor: 0.0, max_elapsed_time: 2.0) do + described_class.retriable(base_interval: 1.0, multiplier: 1.0, rand_factor: 0.0, max_elapsed_time: 2) do increment_tries_with_exception end end.to raise_error(StandardError) @@ -219,18 +284,18 @@ def increment_tries_with_exception(exception_class = nil) expect(@tries).to eq(2) end - it "raises ArgumentError on invalid options" do + it 'raises ArgumentError on invalid options' do expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError) end end - context "#configure" do - it "raises NoMethodError on invalid configuration" do + context '#configure' do + it 'raises NoMethodError on invalid configuration' do expect { described_class.configure { |c| c.does_not_exist = 123 } }.to raise_error(NoMethodError) end end - context "#with_context" do + context '#with_context' do let(:api_tries) { 4 } before do @@ -240,17 +305,17 @@ def increment_tries_with_exception(exception_class = nil) end end - it "stops at first try if the block does not raise an exception" do + it 'stops at first try if the block does not raise an exception' do described_class.with_context(:sql) { increment_tries } expect(@tries).to eq(1) end - it "respects the context options" do + it 'respects the context options' do expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError) expect(@tries).to eq(api_tries) end - it "allows override options" do + it 'allows override options' do expect do described_class.with_context(:sql, tries: 5) { increment_tries_with_exception } end.to raise_error(StandardError) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 335d435..1dfbe7a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,9 +1,9 @@ -require "simplecov" +require 'simplecov' SimpleCov.start -require "pry" -require_relative "../lib/retriable" -require_relative "support/exceptions.rb" +require 'pry' +require_relative '../lib/retriable' +require_relative 'support/exceptions.rb' RSpec.configure do |config| config.before(:each) do