pax_global_header00006660000000000000000000000064151536573430014526gustar00rootroot0000000000000052 comment=312ededb6b9be90f5cbfeb5c171ba7892b8976e2 kamui-retriable-efaaa8d/000077500000000000000000000000001515365734300154365ustar00rootroot00000000000000kamui-retriable-efaaa8d/.github/000077500000000000000000000000001515365734300167765ustar00rootroot00000000000000kamui-retriable-efaaa8d/.github/dependabot.yml000066400000000000000000000002231515365734300216230ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: bundler directory: "/" schedule: interval: daily time: "10:00" open-pull-requests-limit: 10 kamui-retriable-efaaa8d/.github/workflows/000077500000000000000000000000001515365734300210335ustar00rootroot00000000000000kamui-retriable-efaaa8d/.github/workflows/main.yml000066400000000000000000000017751515365734300225140ustar00rootroot00000000000000name: CI on: push: branches: [main] pull_request: branches: [main] types: [opened, synchronize, reopened] jobs: ci: # The type of runner that the job will run on runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-24.04] ruby: [ "2.3", "2.4", "2.5", "2.6", "2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "4.0", jruby, ] env: CC_TEST_REPORTER_ID: 20a1139ef1830b4f813a10a03d90e8aa179b5226f75e75c5a949b25756ebf558 steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v6 - name: Setup ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: ruby version run: ruby -v - name: Run rspec run: bundle exec rspec kamui-retriable-efaaa8d/.gitignore000066400000000000000000000002321515365734300174230ustar00rootroot00000000000000/.bundle/ /.yardoc /Gemfile.lock /_yardoc/ /coverage/ /doc/ /docs/plans/ /.worktrees/ /pkg/ /spec/reports/ /tmp/ *.bundle *.so *.o *.a mkmf.log .DS_Store kamui-retriable-efaaa8d/.hound.yml000066400000000000000000000000421515365734300173500ustar00rootroot00000000000000ruby: config_file: .rubocop.yml kamui-retriable-efaaa8d/.rspec000066400000000000000000000000651515365734300165540ustar00rootroot00000000000000--format documentation --color --require spec_helper kamui-retriable-efaaa8d/.rubocop.yml000066400000000000000000000011731515365734300177120ustar00rootroot00000000000000AllCops: NewCops: enable TargetRubyVersion: 2.3 Style/StringLiterals: EnforcedStyle: double_quotes Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes Style/Documentation: Enabled: false Style/TrailingCommaInArguments: EnforcedStyleForMultiline: comma Lint/InheritException: Enabled: false Style/NegatedIf: Enabled: false Metrics/ClassLength: Enabled: false Metrics/ModuleLength: Enabled: false Layout/LineLength: Max: 120 Metrics/MethodLength: Enabled: false Metrics/BlockLength: Enabled: false Metrics/AbcSize: Enabled: false Style/TrailingCommaInArrayLiteral: Enabled: false kamui-retriable-efaaa8d/CHANGELOG.md000066400000000000000000000133251515365734300172530ustar00rootroot00000000000000# HEAD ## 3.4.1 - Fix: Use `Process.clock_gettime(CLOCK_MONOTONIC)` for elapsed time tracking so retry timing is immune to wall-clock adjustments (NTP, manual changes). - Fix: Handle `max_elapsed_time: nil` gracefully instead of raising `NoMethodError`. - Remove dead `* 1.0` float coercion in `ExponentialBackoff#randomize`. ## 3.4.0 - Add `retry_if` option to support custom retry predicates, including checks against wrapped `exception.cause` values. ## 3.3.0 - Refactor `Retriable.retriable` internals into focused private helpers to improve readability while preserving behavior. - Modernize `.rubocop.yml` with explicit modern defaults to enable new cops while preserving existing project style policies. ## 3.2.1 - Remove executables from gemspec as it was polluting the path for some users. Thanks @hsbt. ## 3.2.0 - Require ruby 2.3+. - Fix: Ensure `tries` value is overridden by `intervals` parameter if both are provided and add a test for this. This is always what the README stated but the code didn't actually do it. - Fix: Some rubocop offenses. ## 3.1.2 - Replace `minitest` gem with `rspec` - Fancier README - Remove unnecessary short circuit in `randomize` method ## 3.1.1 - Fix typo in contexts exception message. - Fix updating the version in the library. ## 3.1.0 - Added [contexts feature](https://github.com/kamui/retriable#contexts). Thanks to @apurvis. ## 3.0.2 - Add configuration and options validation. ## 3.0.1 - Add `rubocop` linter to enforce coding styles for this library. Also, fix rule violations. - Removed `attr_reader :config` that caused a warning. @bruno- - Clean up Rakefile testing cruft. @bruno- - Use `.any?` in the `:on` hash processing. @apurvis ## 3.0.0 - Require ruby 2.0+. - Breaking Change: `on` with a `Hash` value now matches subclassed exceptions. Thanks @apurvis! - Remove `awesome_print` from development environment. ## 2.1.0 - Fix bug #17 due to confusing the initial try as a retry. - Switch to `Minitest` 5.6 expect syntax. ## 2.0.2 - Change required_ruby_version in gemspec to >= 1.9.3. ## 2.0.1 - Add support for ruby 1.9.3. ## 2.0.0 - Require ruby 2.0+. - Time intervals default to randomized exponential backoff instead of fixed time intervals. The delay between retries grows with every attempt and there's a randomization factor added to each attempt. - `base_interval`, `max_interval`, `rand_factor`, and `multiplier` are new arguments that are used to generate randomized exponential back off time intervals. - `interval` argument removed. - Accept `intervals` array argument to provide your own custom intervals. - Allow configurable defaults via `Retriable#configure` block. - Add ability for `:on` argument to accept a `Hash` where the keys are exception types and the values are a single or array of `Regexp` pattern(s) to match against exception messages for retrial. - Raise, not return, on max elapsed time. - Check for elapsed time after next interval is calculated and it goes over the max elapsed time. - Support early termination via `max_elapsed_time` argument. ## 2.0.0.beta5 - Change `:max_tries` back to `:tries`. ## 2.0.0.beta4 - Change #retry back to #retriable. Didn't like the idea of defining a method that is also a reserved word. - Add ability for `:on` argument to accept a `Hash` where the keys are exception types and the values are a single or array of `Regexp` pattern(s) to match against exception messages for retrial. ## 2.0.0.beta3 - Accept `intervals` array argument to provide your own custom intervals. - Refactor the exponential backoff code into it's own class. - Add specs for exponential backoff, randomization, and config. ## 2.0.0.beta2 - Raise, not return, on max elapsed time. - Check for elapsed time after next interval is calculated and it goes over the max elapsed time. - Add specs for `max_elapsed_time` and `max_interval`. ## 2.0.0.beta1 - Require ruby 2.0+. - Default to random exponential backoff, removes the `interval` option. Exponential backoff is configurable via arguments. - Allow configurable defaults via `Retriable#configure` block. - Change `Retriable.retriable` to `Retriable.retry`. - Support early termination via `max_elapsed_time` argument. ## 1.4.1 - Fixes non kernel mode bug. Remove DSL class, move `#retriable` into Retriable module. Thanks @mkrogemann. ## 1.4.0 - By default, retriable doesn't monkey patch `Kernel`. If you want this functionality, you can `require 'retriable/core_ext/kernel'. - Upgrade minitest to 5.x. - Refactor the DSL into it's own class. ## 1.3.3.1 - Allow sleep parameter to be a proc/lambda to allow for exponential backoff. ## 1.3.3 - sleep after executing the retry block, so there's no wait on the first call (molfar) ## 1.3.2 - Clean up option defaults. - By default, rescue StandardError and Timeout::Error instead of [Exception](http://www.mikeperham.com/2012/03/03/the-perils-of-rescue-exception). ## 1.3.1 - Add `rake` dependency for travis-ci. - Update gemspec summary and description. ## 1.3.0 - Rewrote a lot of the code with inspiration from [attempt](https://rubygems.org/gems/attempt). - Add timeout option to the code block. - Include in Kernel by default, but allow require 'retriable/no_kernel' to load a non kernel version. - Renamed `:times` option to `:tries`. - Renamed `:sleep` option to `:interval`. - Renamed `:then` option to `:on_retry`. - Removed other callbacks, you can wrap retriable in a begin/rescue/else/ensure block if you need that functionality. It avoids the need to define multiple Procs and makes the code more readable. - Rewrote most of the README ## 1.2.0 - Forked the retryable-rb repo. - Extend the Kernel module with the retriable method so you can use it anywhere without having to include it in every class. - Update gemspec, Gemfile, and Raketask. - Remove echoe dependency. kamui-retriable-efaaa8d/CODE_OF_CONDUCT.md000066400000000000000000000013671515365734300202440ustar00rootroot00000000000000# Code of Conduct "retriable" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.): * Participants will be tolerant of opposing views. * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks. * When interpreting the words and actions of others, participants should always assume good intentions. * Behaviour which can be reasonably considered harassment will not be tolerated. If you have any concerns about behaviour within this project, please contact us at ["jack@jackchu.com"](mailto:"jack@jackchu.com"). kamui-retriable-efaaa8d/Gemfile000066400000000000000000000004051515365734300167300ustar00rootroot00000000000000# frozen_string_literal: true source "https://rubygems.org" gemspec group :test do gem "rspec", "~> 3.0" gem "simplecov", require: false end group :development do gem "rubocop" end group :development, :test do gem "pry" gem "rake", "~> 13.0" end kamui-retriable-efaaa8d/LICENSE000066400000000000000000000020711515365734300164430ustar00rootroot00000000000000Copyright (c) 2012-2013 Jack Chu (http://www.jackchu.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.kamui-retriable-efaaa8d/README.md000066400000000000000000000547351515365734300167330ustar00rootroot00000000000000# Retriable ![Build Status](https://github.com/kamui/retriable/actions/workflows/main.yml/badge.svg) [![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) Retriable is a simple DSL to retry failed code blocks with randomized [exponential backoff](http://en.wikipedia.org/wiki/Exponential_backoff) time intervals. This is especially useful when interacting external APIs, remote services, or file system calls. ## Requirements Ruby 2.3.0+ If you need ruby 2.0.0-2.2.x support, use the [3.1 branch](https://github.com/kamui/retriable/tree/3.1.x) by specifying `~3.1` in your Gemfile. If you need ruby 1.9.3 support, use the [2.x branch](https://github.com/kamui/retriable/tree/2.x) by specifying `~2.1` in your Gemfile. If you need ruby 1.8.x to 1.9.2 support, use the [1.x branch](https://github.com/kamui/retriable/tree/1.x) by specifying `~1.4` in your Gemfile. ## Installation Via command line: ```ruby gem install retriable ``` In your ruby script: ```ruby require 'retriable' ``` In your Gemfile: ```ruby gem 'retriable', '~> 3.4' ``` ## Usage Code in a `Retriable.retriable` block will be retried if an exception is raised. ```ruby require 'retriable' class Api # Use it in methods that interact with unreliable services def get Retriable.retriable do # code here... end end end ``` ### Defaults By default, `Retriable` will: - rescue any exception inherited from `StandardError` - make 3 tries (including the initial attempt) before raising the last exception - use randomized exponential backoff to calculate each succeeding try interval. The default interval table with 10 tries looks like this (in seconds, rounded to the nearest millisecond): | Retry # | Min | Average | Max | | ------- | -------- | -------- | -------- | | 1 | `0.25` | `0.5` | `0.75` | | 2 | `0.375` | `0.75` | `1.125` | | 3 | `0.563` | `1.125` | `1.688` | | 4 | `0.844` | `1.688` | `2.531` | | 5 | `1.266` | `2.531` | `3.797` | | 6 | `1.898` | `3.797` | `5.695` | | 7 | `2.848` | `5.695` | `8.543` | | 8 | `4.271` | `8.543` | `12.814` | | 9 | `6.407` | `12.814` | `19.222` | | 10 | **stop** | **stop** | **stop** | ### Options Here are the available options, in some vague order of relevance to most common use patterns: | Option | Default | Definition | | ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`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). | | **`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). | | **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks). | | **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. | | **`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. Set to `nil` to disable the time limit and retry based solely on `tries`. | | **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. | | **`multiplier`** | `1.5` | Each successive interval grows by this factor. A multipler of 1.5 means the next interval will be 1.5x the current interval. | | **`rand_factor`** | `0.5` | The percentage to randomize the next retry interval time. The next interval calculation is `randomized_interval = retry_interval * (random value in range [1 - randomization_factor, 1 + randomization_factor])` | | **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). | | **`timeout`** | `nil` | Number of seconds to allow the code block to run before raising a `Timeout::Error` inside each try. `nil` means the code block can run forever without raising error. The implementation uses `Timeout::timeout`, which may be [unsafe](https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/) [and](http://blog.headius.com/2008/02/ruby-threadraise-threadkill-timeoutrb.html) [even](https://adamhooper.medium.com/in-ruby-dont-use-timeout-77d9d4e5a001) [dangerous](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/). Proceed with caution. | #### Configuring Which Options to Retry With :on **`:on`** Can take the form: - An `Exception` class (retry every exception of this type, including subclasses) - An `Array` of `Exception` classes (retry any exception of one of these types, including subclasses) - A `Hash` where the keys are `Exception` classes and the values are one of: - `nil` (retry every exception of the key's type, including subclasses) - 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) #### Advanced Retry Matching With :retry_if 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. ```ruby def caused_by?(error, klass) current = error while current return true if current.is_a?(klass) current = current.cause end false end Retriable.retriable( on: [Faraday::ConnectionFailed], retry_if: ->(exception) { caused_by?(exception, Errno::ECONNRESET) } ) do # code here... end ``` `:retry_if` runs after the exception type has matched `:on`. ### Configuration You can change the global defaults with a `#configure` block: ```ruby Retriable.configure do |c| c.tries = 5 c.max_elapsed_time = 3600 # 1 hour end ``` ### Example Usage This example will only retry on a `Timeout::Error`, retry 3 times and sleep for a full second before each try. ```ruby Retriable.retriable(on: Timeout::Error, tries: 3, base_interval: 1) do # code here... end ``` You can also specify multiple errors to retry on by passing an array of exceptions. ```ruby Retriable.retriable(on: [Timeout::Error, Errno::ECONNRESET]) do # code here... end ``` You can also use a hash to specify that you only want to retry exceptions with certain messages (see [the documentation above](#configuring-which-options-to-retry-with-on)). This example will retry all `ActiveRecord::RecordNotUnique` exceptions, `ActiveRecord::RecordInvalid` exceptions where the message matches either `/Parent must exist/` or `/Username has already been taken/`, or `Mysql2::Error` exceptions where the message matches `/Duplicate entry/`. ```ruby Retriable.retriable(on: { ActiveRecord::RecordNotUnique => nil, ActiveRecord::RecordInvalid => [/Parent must exist/, /Username has already been taken/], Mysql2::Error => /Duplicate entry/ }) do # code here... end ``` You can also specify a timeout if you want the code block to only try for X amount of seconds. This timeout is per try. The implementation uses `Timeout::timeout`, which may be [unsafe](https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/) [and](http://blog.headius.com/2008/02/ruby-threadraise-threadkill-timeoutrb.html) [even](https://adamhooper.medium.com/in-ruby-dont-use-timeout-77d9d4e5a001) [dangerous](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/). You can use this option, but you need to be very careful because the code in the block, including libraries or other code it calls, could be interrupted by the timeout at any line. You must ensure you have the right rescue logic and guards in place ([Thread.handle_interrupt](https://www.rubydoc.info/stdlib/core/Thread.handle_interrupt)) to handle that possible behavior. If that's not possible, the recommendation is that you're better off impelenting your own timeout methods depending on what your code is doing than use this feature. ```ruby Retriable.retriable(timeout: 60) do # code here... end ``` If you need millisecond units of time for the sleep or the timeout: ```ruby Retriable.retriable(base_interval: (200 / 1000.0), timeout: (500 / 1000.0)) do # code here... end ``` ### Custom Interval Array You can also bypass the built-in interval generation and provide your own array of intervals. Supplying your own intervals overrides the `tries`, `base_interval`, `max_interval`, `rand_factor`, and `multiplier` parameters. ```ruby Retriable.retriable(intervals: [0.5, 1.0, 2.0, 2.5]) do # code here... end ``` This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later. ### Turn off Exponential Backoff Exponential backoff is enabled by default. If you want to simply retry code every second, 5 times maximum, you can do this: ```ruby Retriable.retriable(tries: 5, base_interval: 1.0, multiplier: 1.0, rand_factor: 0.0) do # code here... end ``` This works by starting at a 1 second `base_interval`. Setting the `multipler` to 1.0 means each subsequent try will increase 1x, which is still `1.0` seconds, and then a `rand_factor` of 0.0 means that there's no randomization of that interval. (By default, it would randomize 0.5 seconds, which would mean normally the intervals would randomize between 0.5 and 1.5 seconds, but in this case `rand_factor` is basically being disabled.) Another way to accomplish this would be to create an array with a fixed interval. In this example, `Array.new(5, 1)` creates an array with 5 elements, all with the value 1. The code block will retry up to 5 times, and wait 1 second between each attempt. ```ruby # Array.new(5, 1) # => [1, 1, 1, 1, 1] Retriable.retriable(intervals: Array.new(5, 1)) do # code here... end ``` If you don't want exponential backoff but you still want some randomization between intervals, this code will run every 1 seconds with a randomization factor of 0.2, which means each interval will be a random value between 0.8 and 1.2 (1 second +/- 0.2): ```ruby Retriable.retriable(base_interval: 1.0, multiplier: 1.0, rand_factor: 0.2) do # code here... end ``` ### Callbacks `#retriable` also provides a callback called `:on_retry` that will run after an exception is rescued. This callback provides the `exception` that was raised in the current try, the `try_number`, the `elapsed_time` for all tries so far, and the time in seconds of the `next_interval`. As these are specified in a `Proc`, unnecessary variables can be left out of the parameter list. ```ruby do_this_on_each_retry = Proc.new do |exception, try, elapsed_time, next_interval| log "#{exception.class}: '#{exception.message}' - #{try} tries in #{elapsed_time} seconds and #{next_interval} seconds until the next try." end Retriable.retriable(on_retry: do_this_on_each_retry) do # code here... end ``` ### Ensure/Else What if I want to execute a code block at the end, whether or not an exception was rescued ([ensure](http://ruby-doc.org/docs/keywords/1.9/Object.html#method-i-ensure))? Or what if I want to execute a code block if no exception is raised ([else](http://ruby-doc.org/docs/keywords/1.9/Object.html#method-i-else))? Instead of providing more callbacks, I recommend you just wrap retriable in a begin/retry/else/ensure block: ```ruby begin Retriable.retriable do # some code end rescue => e # run this if retriable ends up re-raising the exception else # run this if retriable doesn't raise any exceptions ensure # run this no matter what, exception or no exception end ``` ## Contexts Contexts allow you to coordinate sets of Retriable options across an application. Each context is basically an argument hash for `Retriable.retriable` that is stored in the `Retriable.config` as a simple `Hash` and is accessible by name. For example: ```ruby Retriable.configure do |c| c.contexts[:aws] = { tries: 3, base_interval: 5, on_retry: Proc.new { puts 'Curse you, AWS!' } } c.contexts[:mysql] = { tries: 10, multiplier: 2.5, on: Mysql::DeadlockException } end ``` This will create two contexts, `aws` and `mysql`, which allow you to reuse different backoff strategies across your application without continually passing those strategy options to the `retriable` method. These are used simply by calling `Retriable.with_context`: ```ruby # Will retry all exceptions Retriable.with_context(:aws) do # aws_call end # Will retry Mysql::DeadlockException Retriable.with_context(:mysql) do # write_to_table end ``` You can even temporarily override individual options for a configured context: ```ruby Retriable.with_context(:mysql, tries: 30) do # write_to_table with :mysql context, except with 30 tries instead of 10 end ``` ## Kernel Extension If you want to call `Retriable.retriable` without the `Retriable` module prefix and you don't mind extending `Kernel`, there is a kernel extension available for this. In your ruby script: ```ruby require 'retriable/core_ext/kernel' ``` or in your Gemfile: ```ruby gem 'retriable', require: 'retriable/core_ext/kernel' ``` and then you can call `#retriable` in any context like this: ```ruby retriable do # code here... end retriable_with_context(:api) do # code here... end ``` ## Short Circuiting Retriable While Testing Your App When you are running tests for your app it often takes a long time to retry blocks that fail. This is because Retriable will default to 3 tries with exponential backoff. Ideally your tests will run as quickly as possible. You can disable retrying by setting `tries` to 1 in the test environment. If you want to test that the code is retrying an error, you want to [turn off exponential backoff](#turn-off-exponential-backoff). Under Rails, you could change your initializer to have different options in test, as follows: ```ruby # config/initializers/retriable.rb Retriable.configure do |c| # ... default configuration if Rails.env.test? c.tries = 1 end end ``` Note: In this and the following examples, `Retriable.configure` sets a default config, it doesn't override the configuration for the `retriable` method calls. Calling `Retriable.retriable` with options will override the default configuration for that call. So if you have `tries` set to 5 in `Retriable.configure`, but then you call `Retriable.retriable(tries: 3)`, that call will use 3 tries instead of 5. The configuration is basically a default set of options that can be overridden by passing options to the `retriable` method or by using contexts. Alternately, if you are using RSpec, you could override the Retriable confguration in your `spec_helper`. ```ruby # spec/spec_helper.rb Retriable.configure do |c| c.tries = 1 end ``` If you have defined contexts for your configuration, you'll need to change values for each context, because those values take precedence over the default configured value. For example assuming you have configured a `google_api` context: ```ruby # config/initializers/retriable.rb Retriable.configure do |c| c.contexts[:google_api] = { tries: 5, base_interval: 3, on: [ Net::ReadTimeout, Signet::AuthorizationError, Errno::ECONNRESET, OpenSSL::SSL::SSLError ] } end ``` Then in your test environment, you would need to set each context and the default value: ```ruby # spec/spec_helper.rb Retriable.configure do |c| c.multiplier = 1.0 c.rand_factor = 0.0 c.base_interval = 0 c.contexts.keys.each do |context| c.contexts[context][:tries] = 1 c.contexts[context][:base_interval] = 0 end end ``` ## Credits The randomized exponential backoff implementation was inspired by the one used in Google's [google-http-java-client](https://code.google.com/p/google-http-java-client/wiki/ExponentialBackoff) project. ## Development ### Running Specs ```bash bundle exec rspec ``` kamui-retriable-efaaa8d/Rakefile000066400000000000000000000003201515365734300170760ustar00rootroot00000000000000# frozen_string_literal: true require "bundler/gem_tasks" require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) require "rubocop/rake_task" RuboCop::RakeTask.new task default: %i[spec rubocop] kamui-retriable-efaaa8d/bin/000077500000000000000000000000001515365734300162065ustar00rootroot00000000000000kamui-retriable-efaaa8d/bin/console000077500000000000000000000004331515365734300175760ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true require "bundler/setup" require "retriable" # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. require "irb" IRB.start(__FILE__) kamui-retriable-efaaa8d/bin/setup000077500000000000000000000002031515365734300172670ustar00rootroot00000000000000#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' set -vx bundle install # Do any other automated setup that you need to do here kamui-retriable-efaaa8d/lib/000077500000000000000000000000001515365734300162045ustar00rootroot00000000000000kamui-retriable-efaaa8d/lib/retriable.rb000066400000000000000000000103201515365734300204760ustar00rootroot00000000000000# frozen_string_literal: true require "timeout" require_relative "retriable/config" require_relative "retriable/exponential_backoff" require_relative "retriable/version" module Retriable module_function def configure yield(config) 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 return unless block_given? retriable(config.contexts[context_key].merge(options), &block) end def retriable(opts = {}, &block) local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts)) tries = local_config.tries intervals = build_intervals(local_config, tries) timeout = local_config.timeout on = local_config.on retry_if = local_config.retry_if on_retry = local_config.on_retry sleep_disabled = local_config.sleep_disabled max_elapsed_time = local_config.max_elapsed_time exception_list = on.is_a?(Hash) ? on.keys : on exception_list = [*exception_list] start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time } tries = intervals.size + 1 execute_tries( tries: tries, intervals: intervals, timeout: timeout, exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry, elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time, sleep_disabled: sleep_disabled, &block ) end def execute_tries( # rubocop:disable Metrics/ParameterLists tries:, intervals:, timeout:, exception_list:, on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block ) tries.times do |index| try = index + 1 begin return call_with_timeout(timeout, try, &block) rescue *exception_list => e raise unless retriable_exception?(e, on, exception_list, retry_if) interval = intervals[index] call_on_retry(on_retry, e, try, elapsed_time.call, interval) raise unless can_retry?(try, tries, elapsed_time.call, interval, max_elapsed_time) sleep interval if sleep_disabled != true end end end def build_intervals(local_config, tries) return local_config.intervals if local_config.intervals ExponentialBackoff.new( tries: tries - 1, base_interval: local_config.base_interval, multiplier: local_config.multiplier, max_interval: local_config.max_interval, rand_factor: local_config.rand_factor, ).intervals end def call_with_timeout(timeout, try) return Timeout.timeout(timeout) { yield(try) } if timeout yield(try) end def call_on_retry(on_retry, exception, try, elapsed_time, interval) return unless on_retry on_retry.call(exception, try, elapsed_time, interval) end def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time) return false unless try < tries return true if max_elapsed_time.nil? (elapsed_time + interval) <= max_elapsed_time end # When `on` is a Hash, we need to verify the exception matches a pattern. # For any non-Hash `on` value (e.g., Array of classes, single Exception class, # or Module), the `rescue *exception_list` clause already guarantees the # exception is retriable with respect to `on`; `retry_if`, if provided, is an # additional gate that can still cause this method to return false. def retriable_exception?(exception, on, exception_list, retry_if) return false if on.is_a?(Hash) && !hash_exception_match?(exception, on, exception_list) return false if retry_if && !retry_if.call(exception) true end def hash_exception_match?(exception, on, exception_list) exception_list.any? do |error_class| next false unless exception.is_a?(error_class) patterns = [*on[error_class]] patterns.empty? || patterns.any? { |pattern| exception.message =~ pattern } end end private_class_method( :execute_tries, :build_intervals, :call_with_timeout, :call_on_retry, :can_retry?, :retriable_exception?, :hash_exception_match?, ) end kamui-retriable-efaaa8d/lib/retriable/000077500000000000000000000000001515365734300201555ustar00rootroot00000000000000kamui-retriable-efaaa8d/lib/retriable/config.rb000066400000000000000000000022551515365734300217530ustar00rootroot00000000000000# frozen_string_literal: true require_relative "exponential_backoff" module Retriable class Config ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[ sleep_disabled max_elapsed_time intervals timeout on retry_if on_retry contexts ]).freeze attr_accessor(*ATTRIBUTES) def initialize(opts = {}) backoff = ExponentialBackoff.new @tries = backoff.tries @base_interval = backoff.base_interval @max_interval = backoff.max_interval @rand_factor = backoff.rand_factor @multiplier = backoff.multiplier @sleep_disabled = false @max_elapsed_time = 900 # 15 min @intervals = nil @timeout = nil @on = [StandardError] @retry_if = nil @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 def to_h ATTRIBUTES.each_with_object({}) do |key, hash| hash[key] = public_send(key) end end end end kamui-retriable-efaaa8d/lib/retriable/core_ext/000077500000000000000000000000001515365734300217655ustar00rootroot00000000000000kamui-retriable-efaaa8d/lib/retriable/core_ext/kernel.rb000066400000000000000000000004361515365734300235750ustar00rootroot00000000000000# frozen_string_literal: true require_relative "../../retriable" module Kernel def retriable(opts = {}, &block) Retriable.retriable(opts, &block) end def retriable_with_context(context_key, opts = {}, &block) Retriable.with_context(context_key, opts, &block) end end kamui-retriable-efaaa8d/lib/retriable/exponential_backoff.rb000066400000000000000000000017331515365734300245070ustar00rootroot00000000000000# frozen_string_literal: true module Retriable class ExponentialBackoff ATTRIBUTES = %i[ tries base_interval multiplier max_interval rand_factor ].freeze attr_accessor(*ATTRIBUTES) def initialize(opts = {}) @tries = 3 @base_interval = 0.5 @max_interval = 60 @rand_factor = 0.5 @multiplier = 1.5 opts.each do |k, v| raise ArgumentError, "#{k} is not a valid option" if !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 end return intervals if rand_factor.zero? intervals.map { |i| randomize(i) } end private def randomize(interval) delta = rand_factor * interval.to_f min = interval - delta max = interval + delta rand(min..max) end end end kamui-retriable-efaaa8d/lib/retriable/version.rb000066400000000000000000000001101515365734300221570ustar00rootroot00000000000000# frozen_string_literal: true module Retriable VERSION = "3.4.1" end kamui-retriable-efaaa8d/retriable.gemspec000066400000000000000000000022131515365734300207520ustar00rootroot00000000000000# frozen_string_literal: true lib = File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "retriable/version" Gem::Specification.new do |spec| 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 with external " \ "APIs/services or file system calls." spec.homepage = "https://github.com/kamui/retriable" spec.license = "MIT" spec.files = `git ls-files -z`.split("\x0") spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] spec.required_ruby_version = ">= 2.3.0" spec.add_development_dependency "bundler" spec.add_development_dependency "rspec", "~> 3" spec.add_development_dependency "listen", "~> 3.1" end kamui-retriable-efaaa8d/sig/000077500000000000000000000000001515365734300162205ustar00rootroot00000000000000kamui-retriable-efaaa8d/sig/retriable.rbs000066400000000000000000000001541515365734300207010ustar00rootroot00000000000000module Retriable VERSION: String # See the writing guide of rbs: https://github.com/ruby/rbs#guides end kamui-retriable-efaaa8d/spec/000077500000000000000000000000001515365734300163705ustar00rootroot00000000000000kamui-retriable-efaaa8d/spec/config_spec.rb000066400000000000000000000027361515365734300212040ustar00rootroot00000000000000# frozen_string_literal: true describe Retriable::Config do let(:default_config) { described_class.new } context "defaults" do it "sleep defaults to enabled" do expect(default_config.sleep_disabled).to be_falsey end it "tries defaults to 3" do expect(default_config.tries).to eq(3) end it "max interval defaults to 60" do expect(default_config.max_interval).to eq(60) end 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 expect(default_config.multiplier).to eq(1.5) end it "max elapsed time defaults to 900" do expect(default_config.max_elapsed_time).to eq(900) end it "intervals defaults to nil" do expect(default_config.intervals).to be_nil end it "timeout defaults to nil" do expect(default_config.timeout).to be_nil end it "on defaults to [StandardError]" do expect(default_config.on).to eq([StandardError]) end it "retry_if defaults to nil" do expect(default_config.retry_if).to be_nil end it "on_retry handler defaults to nil" do expect(default_config.on_retry).to be_nil end it "contexts defaults to {}" do expect(default_config.contexts).to eq({}) end end 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 kamui-retriable-efaaa8d/spec/exponential_backoff_spec.rb000066400000000000000000000040421515365734300237300ustar00rootroot00000000000000# frozen_string_literal: true describe Retriable::ExponentialBackoff do context "defaults" do let(:backoff_config) { described_class.new } it "tries defaults to 3" do expect(backoff_config.tries).to eq(3) end it "max interval defaults to 60" do expect(backoff_config.max_interval).to eq(60) end 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 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([ 0.5244067512211441, 0.9113920238761231, 1.2406087918999114, 1.7632403621664823, 2.338001204738311, 4.350816718580626, 5.339852157217869, 11.889873261212443, 18.756037881636484, ]) end 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([ 1.0488135024422882, 1.8227840477522461, 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, ]) 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]) end it "generates intervals with a defined rand_factor" do expect(described_class.new(rand_factor: 0.2).intervals).to eq([ 0.5097627004884576, 0.8145568095504492, 1.1712435167599646, ]) end 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) end end kamui-retriable-efaaa8d/spec/retriable_spec.rb000066400000000000000000000332661515365734300217120ustar00rootroot00000000000000# frozen_string_literal: true describe Retriable do let(:time_table_handler) do ->(_exception, try, _elapsed_time, next_interval) { @next_interval_table[try] = next_interval } end before(:each) do described_class.configure { |c| c.sleep_disabled = true } @tries = 0 @next_interval_table = {} end def increment_tries @tries += 1 end def increment_tries_with_exception(exception_class = nil) exception_class ||= StandardError increment_tries 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) end 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 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 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 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 expect do described_class.retriable { increment_tries_with_exception(NonStandardError) } end.to raise_error(NonStandardError) expect(@tries).to eq(1) end 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) expect(@tries).to eq(3) end 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 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 expect do described_class.retriable(on_retry: time_table_handler, tries: 10) { increment_tries_with_exception } end.to raise_error(StandardError) expect(@next_interval_table).to eq( 1 => 0.5244067512211441, 2 => 0.9113920238761231, 3 => 1.2406087918999114, 4 => 1.7632403621664823, 5 => 2.338001204738311, 6 => 4.350816718580626, 7 => 5.339852157217869, 8 => 11.889873261212443, 9 => 18.756037881636484, 10 => nil, ) expect(@tries).to eq(10) end it "does not call on_retry when explicitly set to false" do callback_called = false original_on_retry = described_class.config.on_retry begin described_class.configure do |c| c.on_retry = proc { |_exception, _try, _elapsed_time, _next_interval| callback_called = true } end expect do described_class.retriable(on_retry: false, tries: 3) { increment_tries_with_exception } end.to raise_error(StandardError) expect(@tries).to eq(3) expect(callback_called).to be(false) ensure described_class.configure do |c| c.on_retry = original_on_retry end end end 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 described_class.retriable(args) do increment_tries raise StandardError if @tries < tries end expect(@tries).to eq(tries) 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 expect do described_class.retriable(args.merge(max_interval: 1.5)) { increment_tries_with_exception } end.to raise_error(StandardError) 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 interval_hash = no_rand_timetable.merge(4 => 1.5, 5 => 1.5, 6 => nil) intervals = interval_hash.values.compact.sort expect do described_class.retriable(on_retry: time_table_handler, intervals: intervals) do increment_tries_with_exception end end.to raise_error(StandardError) expect(@next_interval_table).to eq(interval_hash) expect(@tries).to eq(intervals.size + 1) end it "intervals option overrides tries, base_interval, max_interval, rand_factor, and multiplier" do # Even though we specify tries: 10, base_interval: 1.0, max_interval: 100.0, # rand_factor: 0.8, and multiplier: 2.0, the explicit intervals should take precedence custom_intervals = [0.1, 0.2, 0.3] expect do described_class.retriable( intervals: custom_intervals, tries: 10, base_interval: 1.0, max_interval: 100.0, rand_factor: 0.8, multiplier: 2.0, on_retry: time_table_handler, ) do increment_tries_with_exception end end.to raise_error(StandardError) # Should have 4 tries (3 intervals + 1), not 10 expect(@tries).to eq(4) # Should use the exact intervals provided, not generate them expect(@next_interval_table[1]).to eq(0.1) expect(@next_interval_table[2]).to eq(0.2) expect(@next_interval_table[3]).to eq(0.3) expect(@next_interval_table[4]).to be_nil 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 raise StandardError if @tries == 1 raise NonStandardError if @tries == 2 end expect(@tries).to eq(3) end end context "with a hash :on parameter" do let(:on_hash) { { NonStandardError => /NonStandardError occurred/ } } 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/) expect(@tries).to eq(3) end 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) end end.to raise_error(SecondNonStandardError, /SecondNonStandardError occurred/) expect(@tries).to eq(3) end 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" 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 exceptions = [] handler = ->(exception, try, _elapsed_time, _next_interval) { exceptions[try] = exception } on_hash = { StandardError => nil, NonStandardError => [/foo/, /bar/] } expect do described_class.retriable(tries: 4, on: on_hash, on_retry: handler) do increment_tries case @tries when 1 raise NonStandardError, "foo" when 2 raise NonStandardError, "bar" when 3 raise StandardError else 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[2]).to be_a(NonStandardError) expect(exceptions[2].message).to eq("bar") expect(exceptions[3]).to be_a(StandardError) end end context "with a :retry_if parameter" do it "retries only when retry_if returns true" do described_class.retriable(tries: 3, retry_if: ->(_exception) { @tries < 3 }) do increment_tries raise StandardError, "StandardError occurred" if @tries < 3 end expect(@tries).to eq(3) end it "does not retry when retry_if returns false" do expect do described_class.retriable(tries: 3, retry_if: ->(_exception) { false }) do increment_tries_with_exception end end.to raise_error(StandardError) expect(@tries).to eq(1) end it "can retry based on the wrapped exception cause" do root_cause_class = Class.new(StandardError) wrapper_class = Class.new(StandardError) described_class.retriable( on: [wrapper_class], tries: 3, retry_if: ->(exception) { exception.cause.is_a?(root_cause_class) }, ) do increment_tries if @tries < 3 begin raise root_cause_class, "root cause" rescue root_cause_class raise wrapper_class, "wrapped" end end end expect(@tries).to eq(3) end end 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 increment_tries_with_exception end end.to raise_error(StandardError) expect(@tries).to eq(2) end it "retries up to tries limit when max_elapsed_time is nil" do expect do described_class.retriable(tries: 4, max_elapsed_time: nil) { increment_tries_with_exception } end.to raise_error(StandardError) expect(@tries).to eq(4) end it "uses monotonic clock for elapsed time tracking" do # Stub Process.clock_gettime to return controlled values so we can # verify elapsed_time passed to on_retry is derived from the monotonic clock. clock_calls = 0 allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) do value = clock_calls.to_f clock_calls += 1 value end elapsed_times = [] on_retry = ->(_exception, _try, elapsed_time, _next_interval) { elapsed_times << elapsed_time } expect do described_class.retriable(tries: 3, on_retry: on_retry) { increment_tries_with_exception } end.to raise_error(StandardError) # start_time (call 0) + at least one elapsed_time computation per retry expect(clock_calls).to be >= 3 # elapsed_time values should be positive and non-decreasing expect(elapsed_times).to all(be > 0) expect(elapsed_times).to eq(elapsed_times.sort) end 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 "exposes only the intended public API" do public_api_methods = %i[ retriable with_context configure config ] expect(described_class.singleton_methods(false)).to match_array(public_api_methods) end 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 let(:api_tries) { 4 } before do described_class.configure do |c| c.contexts[:sql] = { tries: 1 } c.contexts[:api] = { tries: api_tries } end end 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 "returns nil when called without a block" do expect(described_class.with_context(:sql)).to be_nil expect(@tries).to eq(0) end it "passes try count through to the context block" do seen_tries = [] described_class.with_context(:api) do |try| seen_tries << try raise StandardError if try < 3 end expect(seen_tries).to eq([1, 2, 3]) end 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 expect do described_class.with_context(:sql, tries: 5) { increment_tries_with_exception } end.to raise_error(StandardError) expect(@tries).to eq(5) end it "raises an ArgumentError when the context isn't found" do expect { described_class.with_context(:wtf) { increment_tries } }.to raise_error(ArgumentError, /wtf not found/) end end end kamui-retriable-efaaa8d/spec/spec_helper.rb000066400000000000000000000003521515365734300212060ustar00rootroot00000000000000# frozen_string_literal: true require "simplecov" SimpleCov.start require "pry" require_relative "../lib/retriable" require_relative "support/exceptions" RSpec.configure do |config| config.before(:each) do srand(0) end end kamui-retriable-efaaa8d/spec/support/000077500000000000000000000000001515365734300201045ustar00rootroot00000000000000kamui-retriable-efaaa8d/spec/support/exceptions.rb000066400000000000000000000002421515365734300226100ustar00rootroot00000000000000# frozen_string_literal: true class NonStandardError < Exception; end class SecondNonStandardError < NonStandardError; end class DifferentError < Exception; end