pax_global_header00006660000000000000000000000064152133332240014510gustar00rootroot0000000000000052 comment=5be1372e31868ec06ed0ee82be49d4117b43cfb8 kamui-retriable-f1b3618/000077500000000000000000000000001521333322400151215ustar00rootroot00000000000000kamui-retriable-f1b3618/.github/000077500000000000000000000000001521333322400164615ustar00rootroot00000000000000kamui-retriable-f1b3618/.github/dependabot.yml000066400000000000000000000002231521333322400213060ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: bundler directory: "/" schedule: interval: daily time: "10:00" open-pull-requests-limit: 10 kamui-retriable-f1b3618/.github/workflows/000077500000000000000000000000001521333322400205165ustar00rootroot00000000000000kamui-retriable-f1b3618/.github/workflows/main.yml000066400000000000000000000037411521333322400221720ustar00rootroot00000000000000name: CI on: push: branches: [main] pull_request: branches: [main] types: [opened, synchronize, reopened] permissions: contents: read jobs: ci: # The type of runner that the job will run on runs-on: ${{ matrix.os }} # Ruby 4.0 is still in preview. Treat its results as informational so a # preview-only regression doesn't block merges. Drop this gate (or update # the version literal) once Ruby 4.0 is released and we treat it as # required. continue-on-error: ${{ matrix.ruby == '4.0' }} strategy: matrix: os: [ubuntu-24.04] ruby: [ "3.2", "3.3", "3.4", "4.0", jruby, ] steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup ruby uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: ruby version run: ruby -v - name: Run rspec run: bundle exec rspec lint: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup ruby uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: ruby-version: "3.3" bundler-cache: true - name: Run rubocop run: bundle exec rubocop - name: Validate RBS run: bundle exec rbs -I sig validate audit: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup ruby uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: ruby-version: "3.3" bundler-cache: true - name: Run bundler-audit run: bundle exec bundle-audit check --update kamui-retriable-f1b3618/.gitignore000066400000000000000000000002321521333322400171060ustar00rootroot00000000000000/.bundle/ /.yardoc /Gemfile.lock /_yardoc/ /coverage/ /doc/ /docs/plans/ /.worktrees/ /pkg/ /spec/reports/ /tmp/ *.bundle *.so *.o *.a mkmf.log .DS_Store kamui-retriable-f1b3618/.hound.yml000066400000000000000000000000271521333322400170360ustar00rootroot00000000000000ruby: enabled: false kamui-retriable-f1b3618/.rspec000066400000000000000000000000651521333322400162370ustar00rootroot00000000000000--format documentation --color --require spec_helper kamui-retriable-f1b3618/.rubocop.yml000066400000000000000000000012531521333322400173740ustar00rootroot00000000000000AllCops: NewCops: enable TargetRubyVersion: 3.2 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 Naming/MethodParameterName: MinNameLength: 2 kamui-retriable-f1b3618/CHANGELOG.md000066400000000000000000000347551521333322400167500ustar00rootroot00000000000000# HEAD ## 4.2.0 ### Bug fixes - The `Kernel` extension methods (`require "retriable/core_ext/kernel"`) are now private, matching idiomatic `Kernel` helpers like `puts` and `rand`. Previously `retriable` and `retriable_with_context` were public instance methods, so they leaked onto every object's public API and could be invoked with an explicit receiver (e.g. `"foo".retriable { ... }`). They remain callable in the documented receiver-less form. ([#146](https://github.com/kamui/retriable/pull/146)) - `Retriable.with_context` (and `Kernel#retriable_with_context`) now raises `ArgumentError` when called without a block, matching `with_override`. Previously a missing block was silently ignored: the call returned `nil` and the intended block never ran, hiding a caller bug. Behavior change: code that relied on the silent no-op will now raise. - `Config#validate!` now validates the structure of each entry in `contexts`, so configured contexts are checked on every `Retriable.retriable`/ `with_context` call rather than only when a given context is first used. A context whose options contain an unknown key (including a nested `contexts` key) now raises `ArgumentError, " is not a valid option"`, matching the `with_override` path. Non-Hash `contexts` and non-Hash per-context values remain leniently treated as empty options (no behavior change). Option _values_ are still validated lazily at retry time, unchanged. ### Docs - Document that `on_retry` receives `next_interval: nil` on the final rescued attempt, when Retriable is about to give up because `tries` are exhausted. `on_retry` still fires before `on_give_up` (unchanged behavior); the `nil` contract is now called out in the `on_retry` documentation so handlers guard arithmetic or logging on `next_interval`. ### Performance - `Config#initialize` no longer allocates a throwaway `ExponentialBackoff` (and runs its redundant `validate!`) just to read default values. Defaults now live in a frozen `ExponentialBackoff::DEFAULTS` constant, removing an allocation and redundant validation from the `retriable` hot path. ([#149](https://github.com/kamui/retriable/pull/149)) ## 4.1.1 ### Bug fixes - `retry_if`, `on_retry`, and `on_give_up` are now validated to be callable (respond to `#call`) or falsy. A non-callable truthy value raises `ArgumentError` at configuration time instead of a later `NoMethodError` on a retry path. ([#140](https://github.com/kamui/retriable/pull/140)) ### Internal - Add RBS type signatures for the public API (`Retriable.configure`, `config`, `retriable`, `with_override`, `with_context`, and `Retriable::Config`) and validate them in CI with `rbs validate`. ([#142](https://github.com/kamui/retriable/pull/142)) - Enforce a minimum test coverage floor and add a `bundler-audit` dependency audit job to CI. ([#143](https://github.com/kamui/retriable/pull/143)) - Remove an unused `CC_TEST_REPORTER_ID` from the CI workflow. ([#141](https://github.com/kamui/retriable/pull/141)) ## 4.1.0 ### Bug fixes - A per-call or `with_context` `tries:` now clears an inherited `intervals:` from global config or a context, matching the documented precedence. Previously `Retriable.retriable(tries: 1)` was silently ignored when `intervals` was configured, running `intervals.size + 1` times. Passing both `intervals:` and `tries:` in the same call still lets `intervals:` win. ## 4.0.0 **This is a major release with breaking changes. Please read carefully before upgrading.** ### Breaking changes - Removed `timeout:` option. The `timeout:` option has been removed from `Retriable.retriable`, `Retriable.configure`, and `Retriable.with_override`. It was a thin wrapper around Ruby's `Timeout.timeout`, which has well-documented safety issues: it interrupts execution at arbitrary lines and can corrupt internal state in libraries that are not interrupt-safe (mutexes, file handles, network sockets, allocator state). This was first raised against this gem in [#96](https://github.com/kamui/retriable/issues/96) in 2021; Retriable 3.8.0 deprecated the option, and 4.0 removes the footgun entirely. As a side effect, the historical bug where Retriable's own internal `Timeout::Error` was silently retried by default is no longer reachable, since Retriable no longer raises a timeout itself. User-raised `Timeout::Error` (for example, from a `Timeout.timeout` block you write inside the retried block) is still matched by the default `on: [StandardError]` because `Timeout::Error < RuntimeError < StandardError`. Passing `timeout:` to `Retriable.retriable` or `Retriable.with_override` now raises `ArgumentError`; setting `config.timeout` in `Retriable.configure` now raises `NoMethodError` because the configuration attribute has been removed. See the [4.0 migration section in the README](README.md#migration-from-3x-to-40) for replacement patterns. - Minimum Ruby version is now 3.2. Support for Ruby 2.x, 3.0, and 3.1 has been dropped in Retriable 4.0. If you need Retriable on Ruby 2.3.0-3.1.x, the 3.8.x line (`~> 3.8`) remains available. ### Features - Add [`on_give_up`](README.md#callbacks) callback that runs when Retriable stops retrying after a rescued retriable exception. Receives `(exception, try, elapsed_time, next_interval, reason)`, where `reason` is `:tries_exhausted` or `:max_elapsed_time`. Does not fire for non-retriable exceptions or `retry_if` rejections. Pass `on_give_up: false` to suppress a configured handler for a single call. - Accept a [`Set` of `Exception` classes](README.md#configuring-which-options-to-retry-with-on) as the `on:` option, in addition to a single class, an `Array`, or a `Hash`. ### Internal - Switched `Retriable.retriable`, `Retriable.with_context`, and the `Kernel` extension methods to Ruby 3.1+ anonymous block forwarding. No user-visible behavior change. ## 3.8.0 ### Deprecations - Deprecated the `timeout:` option ahead of its removal in Retriable 4.0. Non-nil timeout values supplied through `Retriable.configure`, `Retriable.retriable(...)`, or `Retriable.with_override(...)` now emit a deprecation warning while keeping the existing runtime behavior unchanged. On Ruby 2.7+ the warning is emitted via `Kernel.warn(..., category: :deprecated)`, so callers can silence it through the standard Ruby controls (`Warning[:deprecated] = false`, `ruby -W:no-deprecated`, or a custom `Warning.warn`). To keep the notice from drowning busy applications, it is emitted at most once per process; suppression via `Warning[:deprecated]` leaves the warner armed for the next call that re-enables the category. Prefer library-native timeout settings, or wrap the retried block in `Timeout.timeout(...)` directly if you still need that behavior. See the README migration guidance for details. ## 3.7.0 - Feature: Opt-in unbounded retries via `tries: Float::INFINITY`. Requires a finite `max_elapsed_time` as a safety bound and is incompatible with custom `intervals:`. Both invalid configurations raise `ArgumentError` from `Config#validate!`. ## 3.6.1 - Fix: Validate the `on:` option before retrying. Previously, passing a non-`Exception` value such as `Object`, `Kernel`, or a plain `Module` (which appear in every `Exception`'s ancestor chain) would silently retry process-critical exceptions like `SystemExit` and `Interrupt`. The `on:` option now requires an `Exception` subclass, an array of them, or a hash whose keys are such classes and whose values are `nil`, a `Regexp`, or an array of `Regexp`s. Invalid shapes raise `ArgumentError` before the block runs. - Fix: Validate `with_override(contexts:)` shape before applying overrides. `contexts` may be `nil` or a hash, and each per-context override must be a hash. - Docs: Document that `on_retry: false` disables a callback set in `Retriable.configure` for a single call. ## 3.6.0 - Breaking: `Retriable.override` and `Retriable.reset_override` are removed and replaced by block-scoped `Retriable.with_override(opts) { ... }`. The new API requires a block, restores the previous override (or absence of override) when the block exits via `ensure`, and is thread-local — overrides set in one thread do not affect other threads, and child threads do not inherit them. Fibers within a thread still share the thread's active override. Nested `with_override` calls correctly restore the outer override on inner exit. See the README and `docs/testing.md` for migration and testing patterns. This replaces the override API introduced in 3.5.0. ## 3.5.1 - Fix: Validate retry timing and count options before use to reject invalid retry configurations. `tries` must now be a positive integer unless a custom `intervals` array is provided. ## 3.5.0 - Fix: Do not count skipped sleep intervals against `max_elapsed_time` when `sleep_disabled` is true. - Add `override` and `reset_override` APIs to force retry settings over local call options when needed (for example, test short-circuiting). ## 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-f1b3618/CODE_OF_CONDUCT.md000066400000000000000000000013671521333322400177270ustar00rootroot00000000000000# 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-f1b3618/Gemfile000066400000000000000000000005611521333322400164160ustar00rootroot00000000000000# 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 "bundler-audit", "~> 0.9" gem "listen", "~> 3.1" gem "rbs", "~> 4.0", platforms: :ruby gem "rubocop", "~> 1.86" end group :development, :test do gem "pry" gem "rake", "~> 13.0" end kamui-retriable-f1b3618/LICENSE000066400000000000000000000020711521333322400161260ustar00rootroot00000000000000Copyright (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-f1b3618/README.md000066400000000000000000000624331521333322400164100ustar00rootroot00000000000000# Retriable ![Build Status](https://github.com/kamui/retriable/actions/workflows/main.yml/badge.svg) 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. ## Table of Contents - [Requirements](#requirements) - [Migration from 3.x to 4.0](#migration-from-3x-to-40) - [Installation](#installation) - [Usage](#usage) - [Defaults](#defaults) - [Options](#options) - [Configuring Which Options to Retry With :on](#configuring-which-options-to-retry-with-on) - [Advanced Retry Matching With :retry_if](#advanced-retry-matching-with-retry_if) - [Configuration](#configuration) - [Override](#override) - [Example Usage](#example-usage) - [Custom Interval Array](#custom-interval-array) - [Unbounded Retries (Opt-in)](#unbounded-retries-opt-in) - [Turn off Exponential Backoff](#turn-off-exponential-backoff) - [Callbacks](#callbacks) - [Disabling a Configured Callback Per Call](#disabling-a-configured-callback-per-call) - [Ensure/Else](#ensureelse) - [Contexts](#contexts) - [Kernel Extension](#kernel-extension) - [Testing](#testing) - [Credits](#credits) - [Development](#development) - [Running Specs](#running-specs) ## Requirements Ruby 3.2+ If you need Ruby 2.3.0-3.1.x support, use the [3.8.x branch](https://github.com/kamui/retriable/tree/3.8.x) by specifying `~> 3.8` in your Gemfile. 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. ## Migration from 3.x to 4.0 ### Ruby version Retriable 4.0 requires Ruby 3.2 or later. If you run Ruby 2.3.0-3.1.x, or want to stay on the 3.x gem line, use Retriable 3.8.x by specifying `~> 3.8` in your Gemfile. ### `timeout:` option removed The `timeout:` option was deprecated in Retriable 3.8.0 and has been removed in Retriable 4.0. It was a thin wrapper around `Timeout.timeout`, which has well-documented safety issues: it interrupts execution at arbitrary lines and can corrupt internal state in libraries that are not interrupt-safe. See [issue #96](https://github.com/kamui/retriable/issues/96) for the original report of this problem. If you previously used `Retriable.retriable(timeout: 5) { ... }`, you have two recommended alternatives: 1. **Use your library's native timeout** (preferred). For example, configure `Net::HTTP#read_timeout`, Faraday's `request.timeout`, or your database client's statement timeout. Library-native timeouts do not have the safety issues of `Timeout.timeout`. 2. **Manage the timeout yourself inside the block** if no native option exists: ```ruby require "timeout" Retriable.retriable do Timeout.timeout(5) do # code here... end end ``` **Note:** This still uses `Timeout.timeout`, which has the same safety issues that motivated removing the option — interruption can happen at any line, including inside non-interrupt-safe library code (mutexes, file handles, network sockets, allocator state). Prefer option 1 wherever possible. For background, see [why Ruby's `Timeout` is dangerous](https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/), [Headius on Thread#raise and Timeout](http://blog.headius.com/2008/02/ruby-threadraise-threadkill-timeoutrb.html), [In Ruby, don't use `Timeout`](https://adamhooper.medium.com/in-ruby-dont-use-timeout-77d9d4e5a001), and [Timeout: Ruby's most dangerous API](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/). Like the removed `timeout:` option, `Timeout.timeout(5)` inside the block is per-try — each retry gets a fresh 5-second budget. For an overall cap across all retries, use `max_elapsed_time:` instead. Passing `timeout:` to `Retriable.retriable` or `Retriable.with_override` now raises `ArgumentError`. The `timeout` configuration attribute has also been removed, so `Retriable.configure { |c| c.timeout = 5 }` now raises `NoMethodError`. ## Installation Via command line: ```ruby gem install retriable ``` In your ruby script: ```ruby require 'retriable' ``` In your Gemfile: ```ruby gem 'retriable', '~> 4.0' ``` ## 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). Pass `Float::INFINITY` to keep retrying until success or until `max_elapsed_time` is reached. | | **`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. Pass `false` to disable a callback set in `#configure` for a single call. [Read more](#callbacks). | | **`on_give_up`** | `nil` | `Proc` to call when Retriable stops retrying after a rescued retriable exception. [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). | Timing options are validated before retrying. `tries` must be a positive integer when Retriable generates intervals, or `Float::INFINITY` for unbounded retries. `base_interval`, `max_interval`, `multiplier`, and `max_elapsed_time` must be non-negative numbers, with `max_elapsed_time` also accepting `nil`. `rand_factor` must be a number from `0` through `1`. If provided, `intervals` must be an array of non-negative numbers; because it replaces generated intervals, it also overrides `tries`, `base_interval`, `max_interval`, `rand_factor`, and `multiplier` validation. `intervals` cannot be combined with `tries: Float::INFINITY`. #### 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` or `Set` 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 ``` `#configure` sets defaults only. Per-call options passed to `Retriable.retriable` and `Retriable.with_context` still take precedence. When a higher-precedence layer sets `tries:` without `intervals:`, it clears any `intervals:` inherited from a lower layer (so `retriable(tries: 1)` runs once even if `intervals` was configured). Within a single call, passing `intervals:` still overrides `tries:`. ### Override `#with_override` is a block-scoped API for forcing retry options that should take precedence over both `#configure` defaults and per-call options. It is primarily intended for tests — it lets a test force values like `tries: 1` or `base_interval: 0` so the suite runs quickly and predictably, regardless of the application's `#configure` defaults. In application code, prefer `#configure` for app-level defaults and per-call options for caller-specific values. ```ruby Retriable.with_override(tries: 1, base_interval: 0) do Retriable.retriable do # code here... end end ``` Precedence inside the block: ``` with_override > local options > configure defaults ``` `#with_override` requires a block and raises `ArgumentError` if called without one. The override is active only while the block is executing, and is automatically restored to its previous value when the block returns or raises. Nested `#with_override` calls work as expected: the inner block temporarily replaces the active override and the outer override is restored when the inner block exits. `#with_override` is scoped to the **current thread**. The active override does not affect any other thread, and child threads spawned inside the block do not inherit it. This makes `#with_override` safe to use in parallel test runners. Fibers running inside the same thread share the thread's active override. `#with_override` stores the provided options hash **by reference** and reads from it on every attempt while the block runs. Treat the hash and all of its nested values as immutable for the duration of the block: do not mutate them from inside the block, and do not mutate them from another thread or fiber that shares this thread's active override. Mutating the options mid-block results in undefined retry behavior. If options must be computed, build the hash before calling `#with_override` and do not retain a reference you will later mutate. For test-integration patterns (RSpec `around`, helper methods, Minitest, etc.), see [docs/testing.md](docs/testing.md). ### Example Usage This example will only retry on a `Timeout::Error`, retry 3 times and sleep for a full second before each try. ```ruby require "timeout" 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 require "timeout" 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 ``` If you need millisecond units of time for the sleep interval: ```ruby Retriable.retriable(base_interval: (200 / 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. ### Unbounded Retries (Opt-in) You can opt in to unbounded retries with `tries: Float::INFINITY`. This is useful for long-running worker processes where retrying should continue indefinitely, but it must be used with care. ```ruby Retriable.retriable(tries: Float::INFINITY, max_elapsed_time: 300) do # code here... end ``` When `tries: Float::INFINITY` is set: - `max_elapsed_time` must be a finite number. Retriable raises `ArgumentError` if it is `nil` or `Float::INFINITY`. This is a safety bound that prevents accidentally unbounded loops. - Custom `intervals:` cannot be combined with `Float::INFINITY` and raises `ArgumentError`. Use the exponential backoff settings (`base_interval`, `multiplier`, `max_interval`, `rand_factor`) instead. ### 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 ``` > **Note:** On the final rescued attempt — when Retriable is about to give up because `tries` are exhausted — `on_retry` still fires (before `on_give_up`; see below), but `next_interval` is **`nil`** because there is no next retry. Guard any handler that does arithmetic or formatting on `next_interval` (for example `next_interval&.*(1000)`, or `if next_interval`), and avoid unconditionally logging messages like `"retrying in #{next_interval}s"` since no retry is coming. This mirrors the `nil` contract documented for [`on_give_up`](#callbacks) below. #### Disabling a Configured Callback Per Call If `on_retry` is set in `Retriable.configure`, every call uses it by default. To opt a specific call out — for example, a critical call site that should not log on retry — pass `on_retry: false` or `on_retry: nil`. ```ruby Retriable.configure do |c| c.on_retry = ->(exception, try, elapsed_time, next_interval) { log(...) } end # Most calls use the configured callback. Retriable.retriable do # ... end # This specific call opts out of the configured callback. Retriable.retriable(on_retry: false) do # ... end ``` You can also use `:on_give_up` to run a callback when Retriable stops retrying after a rescued retriable exception. This callback receives the `exception`, the `try_number`, the `elapsed_time` for all tries so far, the `next_interval`, and the `reason` Retriable is giving up. The `reason` is either `:tries_exhausted` or `:max_elapsed_time`. ```ruby do_this_when_retries_stop = Proc.new do |exception, try, elapsed_time, next_interval, reason| log "#{exception.class}: '#{exception.message}' - gave up after #{try} tries because #{reason}." end Retriable.retriable(on_give_up: do_this_when_retries_stop) do # code here... end ``` When the reason is `:tries_exhausted`, `next_interval` is `nil` because there is no next retry. When the reason is `:max_elapsed_time`, `next_interval` is the interval that would have been slept before the next try. This reason means the next retry would exceed `max_elapsed_time`, not necessarily that the elapsed time has already exceeded it. If both `:on_retry` and `:on_give_up` are configured, `:on_retry` still runs first for the final rescued retriable exception. This preserves the existing behavior that `:on_retry` runs whenever Retriable rescues an exception that matches its retry rules. If you configure a default `:on_give_up` callback but want to suppress it for a specific call, pass `on_give_up: false` (or `nil`). Both are treated as "no callback". `:on_give_up` is invoked only when Retriable rescued an exception that matched the retry rules and then decided to stop. It does **not** fire when the block raises an exception that is not in `:on`, nor when `:retry_if` returns false. Both of those cases are immediate re-raises, not retry exhaustion, and should be handled with normal Ruby `rescue` blocks around the `Retriable.retriable` call. If `:on_give_up` itself raises, that exception propagates to the caller and replaces the original retried exception. Keep the handler defensive (rescue inside it) if you need the original exception to surface. ### 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!' }, on_give_up: Proc.new { |_e, _try, _elapsed, _interval, reason| puts "Gave up on AWS: #{reason}" } } 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 ``` `#with_context` requires a block and raises `ArgumentError` if called without one. ## 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 ``` ## Testing `Retriable.with_override` is designed to short-circuit retries in your test suite so failing blocks do not slow tests down. The simplest pattern is an RSpec `around(:each)` hook (or your test framework's equivalent) that wraps every example in `with_override(tries: 1, base_interval: 0)`. For Rails integration, opting out of the override for specific tests, and overriding configured contexts in tests, see [docs/testing.md](docs/testing.md). ## 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-f1b3618/Rakefile000066400000000000000000000003201521333322400165610ustar00rootroot00000000000000# 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-f1b3618/bin/000077500000000000000000000000001521333322400156715ustar00rootroot00000000000000kamui-retriable-f1b3618/bin/console000077500000000000000000000004331521333322400172610ustar00rootroot00000000000000#!/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-f1b3618/bin/setup000077500000000000000000000002031521333322400167520ustar00rootroot00000000000000#!/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-f1b3618/docs/000077500000000000000000000000001521333322400160515ustar00rootroot00000000000000kamui-retriable-f1b3618/docs/testing.md000066400000000000000000000124101521333322400200460ustar00rootroot00000000000000# Testing with Retriable `Retriable.with_override` exists primarily for tests. It lets a test force retry options like `tries: 1` or `base_interval: 0` so the suite runs quickly and predictably, regardless of what the application's `Retriable.configure` defaults are. `with_override` is block-scoped: the override is active inside the block and restored to its previous value (which is usually "no override") when the block exits, even if the block raises. It is also thread-local — overrides set in one thread do not affect other threads — so it is safe for parallel test runners. See the README for the full API contract. ## RSpec ### Apply an override to every test Use `around(:each)` in `RSpec.configure` so every test in the suite runs inside the override. This is the most common pattern: ```ruby RSpec.configure do |config| config.around(:each) do |example| Retriable.with_override(tries: 1, base_interval: 0) do example.run end end end ``` ### Apply an override to a specific context ```ruby describe MyClient do context "when external calls should not retry" do around(:each) do |example| Retriable.with_override(tries: 1) { example.run } end it "fails fast" do # `with_override(tries: 1)` is active here end end end ``` ### Apply an override to a single test Wrap the test body directly: ```ruby it "does the thing without waiting" do Retriable.with_override(tries: 1, base_interval: 0) do # test body end end ``` ### Reusable helper Wrap a common configuration in a helper to keep tests readable: ```ruby module RetriableHelpers def with_fast_retries(&block) Retriable.with_override(tries: 1, base_interval: 0, &block) end end RSpec.configure do |config| config.include RetriableHelpers end # In a spec: it "does the thing" do with_fast_retries do # test body end end ``` ## Minitest ```ruby class MyClientTest < Minitest::Test def around Retriable.with_override(tries: 1, base_interval: 0) { yield } end def test_fails_fast # `with_override(tries: 1)` is active here end end ``` Older Minitest versions without `around` can wrap the test body directly: ```ruby def test_fails_fast Retriable.with_override(tries: 1) do # test body end end ``` ## Short-Circuiting Retriable in Your Test Suite When you are running tests for your app, the default retry behavior (3 tries with exponential backoff) makes failing blocks take a long time. To short-circuit retries — including calls that pass local options — set `tries: 1` and disable backoff using `with_override`. ### Under Rails Keep shared defaults in `Retriable.configure` and apply test-only overrides via RSpec's `around` hook (or your test framework's equivalent): ```ruby # config/initializers/retriable.rb Retriable.configure do |c| c.tries = 3 c.base_interval = 0.5 c.rand_factor = 0.5 end # spec/spec_helper.rb (or equivalent) RSpec.configure do |config| config.around(:each) do |example| Retriable.with_override(tries: 1, base_interval: 0, rand_factor: 0) do example.run end end end ``` If a specific test needs normal retry behavior, opt out by running outside the `around` hook. The cleanest way is to tag the example and skip the hook for tagged examples: ```ruby config.around(:each, retriable: :real) { |example| example.run } config.around(:each) do |example| next example.run if example.metadata[:retriable] == :real Retriable.with_override(tries: 1, base_interval: 0, rand_factor: 0) do example.run end end it "exercises the real retry behavior", retriable: :real do # `with_override` is not applied here end ``` ### Overriding Configured Contexts in Tests If you have configured contexts, top-level override values (such as `tries: 1`) already take precedence over context-specific values. To override context-specific options as well (for example, clearing a context's `:intervals` array or shrinking its `:on` exception list), pass `:contexts` to `with_override`. Given a configured `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 ``` You can override both top-level defaults and per-context options in your test setup: ```ruby RSpec.configure do |config| config.around(:each) do |example| context_overrides = Retriable.config.contexts.each_key.with_object({}) do |key, h| h[key] = { tries: 1, base_interval: 0 } end Retriable.with_override( multiplier: 1.0, rand_factor: 0.0, base_interval: 0, contexts: context_overrides, ) do example.run end end end ``` ## Notes - The override is automatically cleared when the block exits, including when the block raises. You do not need to clean up after the block. - `with_override` calls nest: an inner block temporarily replaces the active override, and the outer override is restored when the inner block exits. - Overrides are thread-local. Child threads spawned inside the block do not inherit it. If a test spawns background threads that themselves call `Retriable.retriable`, wrap each background thread's body in its own `with_override` call. kamui-retriable-f1b3618/lib/000077500000000000000000000000001521333322400156675ustar00rootroot00000000000000kamui-retriable-f1b3618/lib/retriable.rb000066400000000000000000000231031521333322400201640ustar00rootroot00000000000000# frozen_string_literal: true require_relative "retriable/config" require_relative "retriable/exponential_backoff" require_relative "retriable/version" module Retriable # Thread-local storage key for the active #with_override block. # We deliberately use Thread#thread_variable_set/get (true thread-local) # rather than Thread.current[] (fiber-local) so that fibers within a thread # share the same override. Changing this to Thread.current[] would silently # break callers that use fiber-based concurrency. OVERRIDE_THREAD_KEY = :retriable_override RetryPlan = Struct.new(:max_tries, :interval_for) private_constant :RetryPlan module_function def configure yield(config) end def config @config ||= Config.new end def with_override(opts = {}) raise ArgumentError, "empty override options are not allowed" if opts.empty? raise ArgumentError, "with_override requires a block" unless block_given? validate_override_options(opts) previous = Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY) Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, opts) begin yield ensure Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, previous) end end def with_context(context_key, options = {}, &) raise ArgumentError, "with_context requires a block" unless block_given? contexts = available_contexts if !contexts.key?(context_key) raise ArgumentError, "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}" end retriable(context_options_for(context_key, options), &) end def retriable(opts = {}, &) override_config = current_override local_config = if opts.empty? && !override_config config else Config.new(apply_override_options(merge_layer(config.to_h, opts), override_config)) end # Config is mutable through `configure`, so validate again immediately before use. local_config.validate! plan = retry_plan(local_config) on = local_config.on retry_if = local_config.retry_if on_retry = local_config.on_retry on_give_up = local_config.on_give_up 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 } execute_tries( max_tries: plan.max_tries, interval_for: plan.interval_for, exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry, on_give_up: on_give_up, elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time, sleep_disabled: sleep_disabled, & ) end def execute_tries( # rubocop:disable Metrics/ParameterLists max_tries:, interval_for:, exception_list:, on:, retry_if:, on_retry:, on_give_up:, elapsed_time:, max_elapsed_time:, sleep_disabled: ) try = 0 loop do try += 1 begin return yield(try) rescue *exception_list => e raise unless retriable_exception?(e, on, exception_list, retry_if) # On the final attempt `interval_for` returns nil (no next retry), and # `on_retry` intentionally fires before the give-up check below, so it # receives `interval: nil`. See the on_retry/on_give_up README contract. interval = interval_for.call(try - 1) call_on_retry(on_retry, e, try, elapsed_time.call, interval) elapsed_interval = sleep_disabled == true ? 0 : interval # Snapshot elapsed_time once so the stop check and on_give_up see the same value. current_elapsed_time = elapsed_time.call stop_reason = retry_stop_reason(try, max_tries, current_elapsed_time, elapsed_interval, max_elapsed_time) if stop_reason call_on_give_up(on_give_up, e, try, current_elapsed_time, interval, stop_reason) raise end sleep interval if sleep_disabled != true end end end def retry_plan(local_config) return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries) if local_config.intervals intervals = local_config.intervals return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] }) end max_tries = local_config.tries provider = interval_provider(local_config) RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil }) end def interval_provider(local_config) ExponentialBackoff.new( base_interval: local_config.base_interval, multiplier: local_config.multiplier, max_interval: local_config.max_interval, rand_factor: local_config.rand_factor, ).interval_provider 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 call_on_give_up( # rubocop:disable Metrics/ParameterLists on_give_up, exception, try, elapsed_time, interval, reason ) return unless on_give_up on_give_up.call(exception, try, elapsed_time, interval, reason) end # `:tries_exhausted` is checked first, but the two conditions can't both hold # on the same try in practice: `retry_plan` returns a nil interval whenever # `try >= max_tries`, so `(elapsed_time + interval) > max_elapsed_time` is not # evaluable on the exhausted-tries try. The early return guards against that # nil and also pins precedence in case the plan ever changes. def retry_stop_reason(try, max_tries, elapsed_time, interval, max_elapsed_time) return :tries_exhausted if max_tries && try >= max_tries return nil if max_elapsed_time.nil? :max_elapsed_time if (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 def validate_override_options(opts) opts.each_key do |k| raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k) end return unless opts.key?(:contexts) contexts = opts[:contexts] return if contexts.nil? raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash) contexts.each do |context_key, context_options| validate_context_override_options(context_key, context_options) end end def validate_context_override_options(context_key, context_options) unless context_options.is_a?(Hash) raise ArgumentError, "contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}" end context_attributes = Config::ATTRIBUTES - [:contexts] context_options.each_key do |k| raise ArgumentError, "#{k} is not a valid option" unless context_attributes.include?(k) end end def apply_override_options(options, overrides) return options unless overrides merge_layer(options, overrides) end # Merge a higher-precedence option layer onto a base layer. A higher layer # that sets `tries` without `intervals` clears the base layer's inherited # `intervals`, so a caller's `tries:` is never silently ignored. When the # higher layer supplies its own `intervals`, those win (same-call override). def merge_layer(base, higher) merged = base.merge(higher) merged[:intervals] = nil if higher.key?(:tries) && !higher.key?(:intervals) merged end def available_contexts config_contexts.merge(override_contexts) end def context_options_for(context_key, options) context_options = config_contexts.fetch(context_key, {}) context_options = {} unless context_options.is_a?(Hash) context_options = merge_layer(context_options, options) override_context_options = override_contexts[context_key] return context_options unless override_context_options.is_a?(Hash) apply_override_options(context_options, override_context_options) end def config_contexts config.contexts.is_a?(Hash) ? config.contexts : {} end def override_contexts override_config = current_override contexts = override_config && override_config[:contexts] contexts.is_a?(Hash) ? contexts : {} end def current_override Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY) end private_class_method( :validate_override_options, :validate_context_override_options, :execute_tries, :retry_plan, :interval_provider, :call_on_retry, :call_on_give_up, :retry_stop_reason, :retriable_exception?, :hash_exception_match?, :apply_override_options, :merge_layer, :available_contexts, :context_options_for, :config_contexts, :override_contexts, :current_override, ) end kamui-retriable-f1b3618/lib/retriable/000077500000000000000000000000001521333322400176405ustar00rootroot00000000000000kamui-retriable-f1b3618/lib/retriable/config.rb000066400000000000000000000062341521333322400214370ustar00rootroot00000000000000# frozen_string_literal: true require_relative "exponential_backoff" require_relative "validation" module Retriable class Config include Validation ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[ sleep_disabled max_elapsed_time intervals on retry_if on_retry on_give_up contexts ]).freeze CONTEXT_ATTRIBUTES = (ATTRIBUTES - %i[contexts]).freeze private_constant :CONTEXT_ATTRIBUTES attr_accessor(*ATTRIBUTES) def initialize(opts = {}) defaults = ExponentialBackoff::DEFAULTS @tries = defaults[:tries] @base_interval = defaults[:base_interval] @max_interval = defaults[:max_interval] @rand_factor = defaults[:rand_factor] @multiplier = defaults[:multiplier] @sleep_disabled = false @max_elapsed_time = 900 # 15 min @intervals = nil @on = [StandardError] @retry_if = nil @on_retry = nil @on_give_up = 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 validate! end def to_h ATTRIBUTES.to_h { |key| [key, public_send(key)] } end def validate! validate_contexts validate_callable(:retry_if, retry_if) validate_callable(:on_retry, on_retry) validate_callable(:on_give_up, on_give_up) validate_on(on) validate_intervals if unbounded_tries?(tries) validate_unbounded_tries else validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time) return if intervals validate_positive_integer(:tries, tries) end validate_backoff_options end private def validate_contexts return unless contexts.is_a?(Hash) return if contexts.empty? contexts.each_value do |options| next unless options.is_a?(Hash) options.each_key do |k| next if CONTEXT_ATTRIBUTES.include?(k) raise ArgumentError, "#{k} is not a valid option" end end end def validate_backoff_options validate_non_negative_number(:base_interval, base_interval) validate_non_negative_number(:multiplier, multiplier) validate_non_negative_number(:max_interval, max_interval) validate_rand_factor end def validate_unbounded_tries if intervals raise ArgumentError, "intervals cannot be used with tries: Float::INFINITY" end unless finite_number?(max_elapsed_time) raise ArgumentError, "max_elapsed_time must be a finite number when tries is Float::INFINITY" end validate_non_negative_number(:max_elapsed_time, max_elapsed_time) end def validate_intervals return if intervals.nil? raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array) return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 } raise ArgumentError, "intervals must contain only non-negative numbers" end end end kamui-retriable-f1b3618/lib/retriable/core_ext/000077500000000000000000000000001521333322400214505ustar00rootroot00000000000000kamui-retriable-f1b3618/lib/retriable/core_ext/kernel.rb000066400000000000000000000004711521333322400232570ustar00rootroot00000000000000# frozen_string_literal: true require_relative "../../retriable" module Kernel def retriable(opts = {}, &) Retriable.retriable(opts, &) end def retriable_with_context(context_key, opts = {}, &) Retriable.with_context(context_key, opts, &) end private :retriable, :retriable_with_context end kamui-retriable-f1b3618/lib/retriable/exponential_backoff.rb000066400000000000000000000035671521333322400242010ustar00rootroot00000000000000# frozen_string_literal: true require_relative "validation" module Retriable class ExponentialBackoff include Validation ATTRIBUTES = %i[ tries base_interval multiplier max_interval rand_factor ].freeze DEFAULTS = { tries: 3, base_interval: 0.5, max_interval: 60, rand_factor: 0.5, multiplier: 1.5 }.freeze attr_accessor(*ATTRIBUTES) def initialize(opts = {}) @tries = DEFAULTS[:tries] @base_interval = DEFAULTS[:base_interval] @max_interval = DEFAULTS[:max_interval] @rand_factor = DEFAULTS[:rand_factor] @multiplier = DEFAULTS[:multiplier] opts.each do |k, v| raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k) instance_variable_set(:"@#{k}", v) end validate! end def intervals provider = interval_provider Array.new(tries) { |iteration| provider.call(iteration) } end def interval_provider raw_interval = base_interval lambda do |_iteration| interval = [raw_interval, max_interval].min raw_interval = next_raw_interval(raw_interval) rand_factor.zero? ? interval : randomize(interval) end end private def validate! validate_non_negative_integer(:tries, tries) validate_non_negative_number(:base_interval, base_interval) validate_non_negative_number(:multiplier, multiplier) validate_non_negative_number(:max_interval, max_interval) validate_rand_factor end def next_raw_interval(raw_interval) return max_interval if multiplier >= 1 && raw_interval >= max_interval raw_interval * multiplier end def randomize(interval) delta = rand_factor * interval.to_f min = interval - delta max = interval + delta rand(min..max) end end end kamui-retriable-f1b3618/lib/retriable/validation.rb000066400000000000000000000056501521333322400223250ustar00rootroot00000000000000# frozen_string_literal: true module Retriable module Validation private def validate_positive_integer(name, value) return if value.is_a?(Integer) && value.positive? raise ArgumentError, "#{name} must be a positive integer" end def validate_non_negative_integer(name, value) return if value.is_a?(Integer) && value >= 0 raise ArgumentError, "#{name} must be a non-negative integer" end def validate_non_negative_number(name, value) return if finite_number?(value) && value >= 0 raise ArgumentError, "#{name} must be a non-negative number" end def validate_optional_non_negative_number(name, value) return if value.nil? validate_non_negative_number(name, value) end def validate_callable(name, value) return unless value # nil/false disable the callback return if value.respond_to?(:call) raise ArgumentError, "#{name} must respond to #call or be nil" end def validate_rand_factor return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1 raise ArgumentError, "rand_factor must be between 0 and 1" end def finite_number?(value) value.is_a?(Numeric) && value.to_f.finite? end def unbounded_tries?(value) value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1 end module_function :unbounded_tries? # Validates an `on:` value. Acceptable shapes: # - a Class that descends from Exception # - an Array or Set whose elements are Classes that descend from Exception # - a Hash whose keys are such Classes and whose values are nil, # a Regexp, or an Array of Regexps # # Without this validation, callers can pass values like `Object` or # `Kernel` and silently retry process-critical exceptions such as # SystemExit and Interrupt, because every Exception's ancestor chain # includes both. Hash values that are not Regexps (e.g. plain Strings) # also silently fail to match in #hash_exception_match?, so we require # Regexp values explicitly. def validate_on(value) case value in Hash value.each do |klass, pattern| validate_on_class(klass) validate_on_hash_value(klass, pattern) end in Array | Set value.each { |klass| validate_on_class(klass) } else validate_on_class(value) end end def validate_on_class(klass) return if klass.is_a?(Class) && klass <= Exception raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}" end def validate_on_hash_value(klass, pattern) return if pattern.nil? return if pattern.is_a?(Regexp) return if pattern.is_a?(Array) && pattern.all?(Regexp) raise ArgumentError, "on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}" end end end kamui-retriable-f1b3618/lib/retriable/version.rb000066400000000000000000000001101521333322400216420ustar00rootroot00000000000000# frozen_string_literal: true module Retriable VERSION = "4.2.0" end kamui-retriable-f1b3618/retriable.gemspec000066400000000000000000000017431521333322400204440ustar00rootroot00000000000000# 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.metadata["rubygems_mfa_required"] = "true" spec.files = `git ls-files -z`.split("\x0") spec.require_paths = ["lib"] spec.required_ruby_version = ">= 3.2" end kamui-retriable-f1b3618/sig/000077500000000000000000000000001521333322400157035ustar00rootroot00000000000000kamui-retriable-f1b3618/sig/retriable.rbs000066400000000000000000000021661521333322400203710ustar00rootroot00000000000000module Retriable VERSION: String OVERRIDE_THREAD_KEY: Symbol def self.configure: () { (Config) -> void } -> void def self.config: () -> Config def self.with_override: (Hash[Symbol, untyped] options) { () -> untyped } -> untyped def self.with_context: (Symbol context_key, ?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped def self.retriable: (?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped class Config ATTRIBUTES: Array[Symbol] attr_accessor tries: Numeric attr_accessor base_interval: Numeric attr_accessor max_interval: Numeric attr_accessor rand_factor: Numeric attr_accessor multiplier: Numeric attr_accessor sleep_disabled: bool attr_accessor max_elapsed_time: Numeric? attr_accessor intervals: Array[Numeric]? attr_accessor on: untyped attr_accessor retry_if: untyped attr_accessor on_retry: untyped attr_accessor on_give_up: untyped attr_accessor contexts: Hash[Symbol, untyped] def initialize: (?Hash[Symbol, untyped] opts) -> void def to_h: () -> Hash[Symbol, untyped] def validate!: () -> void end end kamui-retriable-f1b3618/spec/000077500000000000000000000000001521333322400160535ustar00rootroot00000000000000kamui-retriable-f1b3618/spec/config_spec.rb000066400000000000000000000164051521333322400206650ustar00rootroot00000000000000# 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 "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 "on_give_up handler defaults to nil" do expect(default_config.on_give_up).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 it "rejects timeout as an unknown option" do expect { described_class.new(timeout: 5) }.to raise_error(ArgumentError, /not a valid option/) end it "raises errors on invalid timing configuration" do expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/) end it "raises errors when intervals is not an array" do expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/) end it "requires a finite max_elapsed_time when tries is Float::INFINITY" do expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) } .to raise_error(ArgumentError, /max_elapsed_time must be a finite number/) end it "rejects intervals combined with tries: Float::INFINITY" do expect do described_class.new( tries: Float::INFINITY, max_elapsed_time: 60, intervals: [0.1, 0.2], ) end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/) end it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) } .not_to raise_error end context "on: option validation" do it "accepts a single Exception subclass" do expect { described_class.new(on: StandardError) }.not_to raise_error end it "accepts Exception itself" do expect { described_class.new(on: Exception) }.not_to raise_error end it "accepts an array of Exception subclasses" do expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error end it "accepts a Set of Exception subclasses" do expect { described_class.new(on: Set[StandardError, RuntimeError]) }.not_to raise_error end it "rejects a Set containing a non-Exception class" do expect { described_class.new(on: Set[StandardError, Kernel]) } .to raise_error(ArgumentError, /on must be an Exception class/) end it "accepts a hash with nil pattern values" do expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error end it "accepts a hash with Regexp pattern values" do expect { described_class.new(on: { StandardError => /boom/ }) }.not_to raise_error end it "accepts a hash with Array-of-Regexp pattern values" do expect { described_class.new(on: { StandardError => [/a/, /b/] }) }.not_to raise_error end it "rejects Object as on:" do expect { described_class.new(on: Object) } .to raise_error(ArgumentError, /on must be an Exception class/) end it "rejects Kernel as on:" do expect { described_class.new(on: Kernel) } .to raise_error(ArgumentError, /on must be an Exception class/) end it "rejects an array containing a non-Exception class" do expect { described_class.new(on: [StandardError, Kernel]) } .to raise_error(ArgumentError, /on must be an Exception class/) end it "rejects a hash key that is not an Exception class" do expect { described_class.new(on: { Kernel => nil }) } .to raise_error(ArgumentError, /on must be an Exception class/) end it "rejects a hash value that is a String" do expect { described_class.new(on: { StandardError => "boom" }) } .to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/) end it "rejects a hash value that is an Array containing a non-Regexp" do expect { described_class.new(on: { StandardError => [/a/, "b"] }) } .to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/) end it "rejects a string passed as on:" do expect { described_class.new(on: "StandardError") } .to raise_error(ArgumentError, /on must be an Exception class/) end it "validates on: even when intervals is provided" do expect { described_class.new(intervals: [0.1], on: Object) } .to raise_error(ArgumentError, /on must be an Exception class/) end end context "callable option validation" do %i[retry_if on_retry on_give_up].each do |opt| it "accepts a callable for #{opt}" do expect { described_class.new(opt => ->(*) {}) }.not_to raise_error end it "accepts nil and false for #{opt}" do expect { described_class.new(opt => nil) }.not_to raise_error expect { described_class.new(opt => false) }.not_to raise_error end it "rejects a non-callable truthy value for #{opt}" do expect { described_class.new(opt => 5) }.to raise_error(ArgumentError, /#{opt}.*#call/) end end end context "context structure validation" do it "rejects a context whose options contain a nested :contexts key" do expect { described_class.new(contexts: { api: { contexts: {} } }) } .to raise_error(ArgumentError, /contexts is not a valid option/) end it "rejects a context with an unknown option key" do expect { described_class.new(contexts: { api: { does_not_exist: 1 } }) } .to raise_error(ArgumentError, /does_not_exist is not a valid option/) end it "validates context structure even when intervals is provided" do expect { described_class.new(intervals: [0.1], contexts: { api: { contexts: {} } }) } .to raise_error(ArgumentError, /contexts is not a valid option/) end it "accepts a non-Hash context value (treated as empty options)" do expect { described_class.new(contexts: { broken: nil }) }.not_to raise_error end it "accepts nil contexts" do expect { described_class.new(contexts: nil) }.not_to raise_error end it "accepts a valid context" do expect { described_class.new(contexts: { api: { tries: 3, base_interval: 1.0 } }) }.not_to raise_error end end end kamui-retriable-f1b3618/spec/exponential_backoff_spec.rb000066400000000000000000000046661521333322400234270ustar00rootroot00000000000000# 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 it "provides capped intervals lazily" do interval_for = described_class.new( base_interval: 1.0, multiplier: 2.0, max_interval: 4.0, rand_factor: 0.0, ).interval_provider expect(Array.new(5) { |index| interval_for.call(index) }).to eq([1.0, 2.0, 4.0, 4.0, 4.0]) end end kamui-retriable-f1b3618/spec/retriable_spec.rb000066400000000000000000001267071521333322400214000ustar00rootroot00000000000000# frozen_string_literal: true require "rbconfig" 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.instance_variable_set(:@config, nil) Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil) 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 script = "require 'retriable'; begin; retriable {}; rescue NoMethodError; exit 0; end; exit 1" expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true) 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 it "passes on_give_up through the kernel extension" do require_relative "../lib/retriable/core_ext/kernel" received_reason = nil handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason } expect { retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception } } .to raise_error(StandardError) expect(received_reason).to eq(:tries_exhausted) end # These two specs lock in the anonymous block forwarding (`&`) semantics # across both delegation layers: Kernel#retriable_with_context -> # Retriable.with_context. If the `&` is dropped at either layer, the block # is not forwarded and the `block_given?` guard in with_context raises # ArgumentError instead of running the block. it "forwards a block through Kernel#retriable_with_context" do require_relative "../lib/retriable/core_ext/kernel" Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } } retriable_with_context(:sql) { increment_tries } expect(@tries).to eq(1) end it "raises an ArgumentError when Kernel#retriable_with_context is called without a block" do require_relative "../lib/retriable/core_ext/kernel" Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } } expect { retriable_with_context(:sql) } .to raise_error(ArgumentError, /with_context requires a block/) expect(@tries).to eq(0) end it "is not callable with an explicit receiver" do require_relative "../lib/retriable/core_ext/kernel" expect { "foo".retriable { increment_tries } } .to raise_error(NoMethodError, /private method/) expect { "foo".retriable_with_context(:sql) { increment_tries } } .to raise_error(NoMethodError, /private method/) end end context "#retriable" do it "reuses the singleton config when no local options or overrides are provided" do expect(described_class::Config).not_to receive(:new) described_class.retriable { increment_tries } expect(@tries).to eq(1) end it "raises a LocalJumpError if not given a block" do expect { described_class.retriable }.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 "does not prebuild generated intervals before the first successful try" do interval_for = ->(_index) { raise "interval should not be used" } backoff = instance_double(Retriable::ExponentialBackoff, interval_provider: interval_for) allow(Retriable::ExponentialBackoff).to receive(:new).and_call_original allow(Retriable::ExponentialBackoff).to receive(:new).with( hash_including(:base_interval, :multiplier, :max_interval, :rand_factor), ).and_return(backoff) described_class.retriable(tries: 1_000_000) { increment_tries } expect(@tries).to eq(1) expect(backoff).to have_received(:interval_provider) end it "supports unbounded retries until the block succeeds" do described_class.retriable(tries: Float::INFINITY, max_elapsed_time: 60) do increment_tries raise StandardError if @tries < 5 end expect(@tries).to eq(5) end it "stops unbounded retries at max_elapsed_time" do start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) timeline = [ start_time, start_time, start_time, start_time + 0.01, start_time + 0.01, start_time + 0.02, start_time + 0.02, ] allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last } expect do described_class.retriable( tries: Float::INFINITY, base_interval: 0.01, multiplier: 1.0, rand_factor: 0.0, sleep_disabled: true, max_elapsed_time: 0.015, ) do increment_tries_with_exception end end.to raise_error(StandardError) expect(@tries).to eq(3) end it "raises ArgumentError when tries is Float::INFINITY without a finite max_elapsed_time" do expect do described_class.retriable(tries: Float::INFINITY, max_elapsed_time: nil) { increment_tries } end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/) end it "raises ArgumentError when tries is Float::INFINITY with infinite max_elapsed_time" do expect do described_class.retriable(tries: Float::INFINITY, max_elapsed_time: Float::INFINITY) { increment_tries } end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/) end it "raises ArgumentError when tries is Float::INFINITY with custom intervals" do expect do described_class.retriable(tries: Float::INFINITY, intervals: [0.1, 0.2], max_elapsed_time: 60) do increment_tries_with_exception end end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/) end it "raises ArgumentError when tries is Float::NAN" do expect do described_class.retriable(tries: Float::NAN) { increment_tries } end.to raise_error(ArgumentError, /tries/) end it "raises ArgumentError when tries is negative infinity" do expect do described_class.retriable(tries: -Float::INFINITY) { increment_tries } end.to raise_error(ArgumentError, /tries/) end it "rejects timeout as an unknown option" do expect { described_class.retriable(timeout: 1) { :noop } }.to raise_error(ArgumentError, /not a valid option/) 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 it "does not call on_retry when explicitly set to nil" 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: nil, 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 it "calls on_give_up with max elapsed time details before re-raising" do described_class.configure { |c| c.sleep_disabled = false } give_up_calls = [] on_give_up = proc do |exception, try, elapsed_time, next_interval, reason| give_up_calls << [exception, try, elapsed_time, next_interval, reason] end expect do described_class.retriable( intervals: [1.0, 1.0], max_elapsed_time: 0.5, on_give_up: on_give_up, ) do increment_tries_with_exception end end.to raise_error(StandardError) exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0) expect(give_up_calls.size).to eq(1) expect(exception).to be_a(StandardError) expect(exception.message).to eq("StandardError occurred") expect(try).to eq(1) expect(elapsed_time).to be >= 0 expect(next_interval).to eq(1.0) expect(reason).to eq(:max_elapsed_time) expect(@tries).to eq(1) end it "calls on_give_up with tries exhausted details before re-raising" do give_up_calls = [] on_give_up = proc do |exception, try, elapsed_time, next_interval, reason| give_up_calls << [exception, try, elapsed_time, next_interval, reason] end expect do described_class.retriable(tries: 2, on_give_up: on_give_up) { increment_tries_with_exception } end.to raise_error(StandardError) exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0) expect(give_up_calls.size).to eq(1) expect(exception).to be_a(StandardError) expect(exception.message).to eq("StandardError occurred") expect(try).to eq(2) expect(elapsed_time).to be >= 0 expect(next_interval).to be_nil expect(reason).to eq(:tries_exhausted) expect(@tries).to eq(2) end it "does not call on_give_up when the block eventually succeeds" do callback_called = false described_class.retriable(tries: 3, on_give_up: proc { callback_called = true }) do increment_tries raise StandardError if @tries < 2 end expect(callback_called).to be(false) expect(@tries).to eq(2) end it "does not call on_give_up for non-retriable exception types" do callback_called = false expect do described_class.retriable(on_give_up: proc { callback_called = true }) do increment_tries_with_exception(NonStandardError) end end.to raise_error(NonStandardError) expect(callback_called).to be(false) expect(@tries).to eq(1) end it "does not call on_give_up when retry_if rejects the exception" do callback_called = false expect do described_class.retriable( tries: 3, retry_if: ->(_exception) { false }, on_give_up: proc { callback_called = true }, ) do increment_tries_with_exception end end.to raise_error(StandardError) expect(callback_called).to be(false) expect(@tries).to eq(1) end it "does not call on_give_up when explicitly set to false" do callback_called = false original_on_give_up = described_class.config.on_give_up begin described_class.configure do |c| c.on_give_up = proc { callback_called = true } end expect do described_class.retriable(on_give_up: false, tries: 1) { increment_tries_with_exception } end.to raise_error(StandardError) expect(callback_called).to be(false) ensure described_class.configure do |c| c.on_give_up = original_on_give_up end end end it "does not call on_give_up when explicitly set to nil" do callback_called = false original_on_give_up = described_class.config.on_give_up begin described_class.configure do |c| c.on_give_up = proc { callback_called = true } end expect do described_class.retriable(on_give_up: nil, tries: 1) { increment_tries_with_exception } end.to raise_error(StandardError) expect(callback_called).to be(false) ensure described_class.configure do |c| c.on_give_up = original_on_give_up end end end it "calls on_retry before on_give_up when giving up" do events = [] expect do described_class.retriable( tries: 1, on_retry: proc { events << :on_retry }, on_give_up: proc { events << :on_give_up }, ) do increment_tries_with_exception end end.to raise_error(StandardError) expect(events).to eq(%i[on_retry on_give_up]) end it "propagates exceptions raised inside on_give_up, replacing the original exception" do handler = proc { raise "handler exploded" } expect do described_class.retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception } end.to raise_error(RuntimeError, "handler exploded") expect(@tries).to eq(1) 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 Set :on parameter" do it "retries each exception class in the Set" do described_class.retriable(on: Set[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 "does not call on_give_up when exception class matches but message does not" do callback_called = false expect do described_class.retriable(on: on_hash, on_give_up: proc { callback_called = true }) do increment_tries raise SecondNonStandardError, "not a match" end end.to raise_error(SecondNonStandardError, /not a match/) expect(callback_called).to be(false) 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 "does not count skipped sleep intervals against max elapsed time" do allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(0.0) expect do described_class.retriable(tries: 3, base_interval: 1.0, rand_factor: 0.0, max_elapsed_time: 0.1) do increment_tries_with_exception end end.to raise_error(StandardError) expect(@tries).to eq(3) 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 it "raises ArgumentError when tries is not a positive integer" do expect { described_class.retriable(tries: 1.5) { increment_tries } } .to raise_error(ArgumentError, /tries/) end it "raises ArgumentError when an interval is negative" do expect { described_class.retriable(intervals: [-1]) { increment_tries } } .to raise_error(ArgumentError, /intervals/) end it "raises ArgumentError when configured timing options become invalid" do described_class.configure { |config| config.tries = 0 } expect { described_class.retriable { increment_tries } } .to raise_error(ArgumentError, /tries/) end it "does not validate generated backoff options when intervals are provided" do described_class.retriable(intervals: [0], tries: 0, rand_factor: 1.1) { increment_tries } expect(@tries).to eq(1) end it "allows an empty interval array as one attempt" do expect do described_class.retriable(intervals: []) { increment_tries_with_exception } end.to raise_error(StandardError) expect(@tries).to eq(1) end it "rejects on: Object before invoking the block" do block_invoked = false expect do described_class.retriable(on: Object) { block_invoked = true } end.to raise_error(ArgumentError, /on must be an Exception class/) expect(block_invoked).to be(false) end end context "#configure" do it "exposes only the intended public API" do public_api_methods = %i[ retriable with_context configure config with_override ] 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 "#retriable tries/intervals precedence" do it "lets a per-call tries clear globally configured intervals" do described_class.configure { |c| c.intervals = [0.5, 1.0] } expect do described_class.retriable(tries: 1) { increment_tries_with_exception } end.to raise_error(StandardError) expect(@tries).to eq(1) end it "still lets per-call intervals win when both intervals and tries are given" do expect do described_class.retriable(intervals: [0.5, 1.0], tries: 1) { increment_tries_with_exception } end.to raise_error(StandardError) expect(@tries).to eq(3) # intervals.size + 1 end it "lets a with_context tries clear context intervals" do described_class.configure do |c| c.contexts[:api] = { intervals: [0.5, 1.0] } end expect do described_class.with_context(:api, tries: 1) { increment_tries_with_exception } end.to raise_error(StandardError) expect(@tries).to eq(1) end end context "#with_override" do it "takes precedence over both global config and local options" do described_class.configure { |c| c.tries = 2 } described_class.with_override(tries: 1) do expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError) end expect(@tries).to eq(1) end it "lets override tries take precedence over local intervals" do described_class.with_override(tries: 1) do expect do described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception } end.to raise_error(StandardError) end expect(@tries).to eq(1) end it "lets override tries take precedence over context intervals" do described_class.configure do |c| c.contexts[:api] = { intervals: [0.5, 1.0] } end described_class.with_override(tries: 1) do expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError) end expect(@tries).to eq(1) end it "lets override context tries take precedence over context intervals" do described_class.configure do |c| c.contexts[:api] = { intervals: [0.5, 1.0] } end described_class.with_override(contexts: { api: { tries: 1 } }) do expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError) end expect(@tries).to eq(1) end it "replaces hash-valued options instead of deep-merging them" do described_class.with_override(on: { NonStandardError => nil }) do expect do described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception } end.to raise_error(StandardError) end expect(@tries).to eq(1) end it "can override local intervals with nil to use configured backoff" do described_class.configure { |c| c.tries = 3 } described_class.with_override(intervals: nil) do expect do described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do increment_tries_with_exception end end.to raise_error(StandardError) end expect(@tries).to eq(3) expect(@next_interval_table[1]).to be_between(0.0, 1.0) end it "applies override context values after with_context local options" do described_class.configure do |c| c.contexts[:api] = { tries: 3, base_interval: 1.0 } end described_class.with_override(contexts: { api: { tries: 1 } }) do described_class.with_context(:api, tries: 10) { increment_tries } end expect(@tries).to eq(1) end it "can define a context only in override config" do described_class.with_override(contexts: { test_only: { tries: 1 } }) do described_class.with_context(:test_only) { increment_tries } end expect(@tries).to eq(1) end it "does not apply context-only overrides to plain retriable calls" do described_class.with_override(contexts: { api: { tries: 1 } }) do expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError) end expect(@tries).to eq(3) end it "keeps configured context matchers when top-level override values apply" do described_class.configure do |c| c.contexts[:api] = { tries: 3, on: NonStandardError } end described_class.with_override(tries: 1) do expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } } .to raise_error(NonStandardError) end expect(@tries).to eq(1) end it "combines local options with override-only contexts" do described_class.with_override(contexts: { api: { tries: 1 } }) do expect do described_class.with_context(:api, on: NonStandardError) do increment_tries_with_exception(NonStandardError) end end.to raise_error(NonStandardError) end expect(@tries).to eq(1) end it "reuses configured contexts when override does not include contexts" do described_class.configure do |c| c.contexts[:api] = { tries: 1 } end described_class.with_override(tries: 1) do described_class.with_context(:api) { increment_tries } end expect(@tries).to eq(1) end it "treats non-hash configured contexts as empty when override contexts are hash" do described_class.configure { |c| c.contexts = nil } described_class.with_override(contexts: { api: { tries: 1 } }) do described_class.with_context(:api) { increment_tries } end expect(@tries).to eq(1) ensure described_class.configure { |c| c.contexts = {} } end it "ignores nil override contexts values in with_context" do described_class.configure do |c| c.contexts[:api] = { tries: 1 } end described_class.with_override(contexts: nil) do described_class.with_context(:api) { increment_tries } end expect(@tries).to eq(1) end it "raises ArgumentError on non-hash override contexts values" do block_called = false expect { described_class.with_override(contexts: 123) { block_called = true } } .to raise_error(ArgumentError, /contexts must be a Hash or nil/) expect(block_called).to be(false) end it "raises ArgumentError on non-hash per-context override values" do block_called = false expect { described_class.with_override(contexts: { api: 123 }) { block_called = true } } .to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/) expect(block_called).to be(false) end it "preserves outer override after rejected nested override contexts values" do described_class.with_override(tries: 2) do expect { described_class.with_override(tries: 1, contexts: 123) { :noop } } .to raise_error(ArgumentError, /contexts must be a Hash or nil/) expect { described_class.retriable(tries: 10) { increment_tries_with_exception } } .to raise_error(StandardError) end expect(@tries).to eq(2) end it "preserves outer context override after rejected nested per-context values" do described_class.configure do |c| c.contexts[:api] = { tries: 10 } end described_class.with_override(contexts: { api: { tries: 2 } }) do expect { described_class.with_override(contexts: { api: 123 }) { :noop } } .to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/) expect { described_class.with_context(:api) { increment_tries_with_exception } } .to raise_error(StandardError) end expect(@tries).to eq(2) end it "shows merged context keys in with_context missing-context errors" do described_class.configure do |c| c.contexts[:configured] = { tries: 2 } end described_class.with_override(contexts: { override_only: { tries: 1 } }) do expect { described_class.with_context(:missing) { increment_tries } } .to raise_error(ArgumentError, /override_only/) end end it "does not snapshot configured contexts when adding override-only contexts" do described_class.configure do |c| c.contexts[:api] = { tries: 2 } end described_class.with_override(contexts: { test_only: { tries: 1 } }) do described_class.configure do |c| c.contexts[:api] = { tries: 5 } end expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError) end expect(@tries).to eq(5) end it "raises ArgumentError on invalid override options" do expect { described_class.with_override(does_not_exist: 123) { :noop } }.to raise_error(ArgumentError) end it "raises ArgumentError on empty override options" do expect { described_class.with_override({}) { :noop } }.to raise_error(ArgumentError, /empty override/) end it "raises ArgumentError when called without a block" do expect { described_class.with_override(tries: 1) }.to raise_error(ArgumentError, /requires a block/) end it "raises ArgumentError on invalid context override options" do expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } } .to raise_error(ArgumentError, /does_not_exist is not a valid option/) end it "clears the override after the block returns" do described_class.with_override(tries: 1) do # active here end expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError) expect(@tries).to eq(3) end it "clears the override when the block raises" do expect do described_class.with_override(tries: 1) { raise "boom" } end.to raise_error(RuntimeError, "boom") expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError) expect(@tries).to eq(3) end it "returns the block's return value" do result = described_class.with_override(tries: 1) { :return_value } expect(result).to eq(:return_value) end it "restores the outer override when nested blocks exit" do tries_seen = [] handler = ->(_exception, try, _elapsed, _next) { tries_seen << [Thread.current.object_id, try] } described_class.with_override(tries: 2, on_retry: handler) do described_class.with_override(tries: 4, on_retry: handler) do expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError) end # After the inner block exits, the outer tries: 2 override is restored. @tries = 0 expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError) expect(@tries).to eq(2) end end end context "#with_override thread safety" do # Coordinate threads with queues rather than sleep so tests are deterministic. # sleep_disabled is already set to true in the top-level before(:each), so # retriable calls do not actually sleep between attempts. it "isolates overrides between threads" do ready = Queue.new proceed = Queue.new results = {} mutex = Mutex.new threads = [1, 2].map do |id| Thread.new do described_class.with_override(tries: id) do ready << true proceed.pop tries = 0 begin described_class.retriable do tries += 1 raise StandardError end rescue StandardError mutex.synchronize { results[id] = tries } end end end end 2.times { ready.pop } 2.times { proceed << true } threads.each(&:join) expect(results).to eq(1 => 1, 2 => 2) end it "does not leak an active override into a sibling thread" do override_active = Queue.new sibling_done = Queue.new sibling_tries = nil setter = Thread.new do described_class.with_override(tries: 1) do override_active << true sibling_done.pop end end sibling = Thread.new do override_active.pop tries = 0 begin described_class.retriable(tries: 3) do tries += 1 raise StandardError end rescue StandardError sibling_tries = tries end sibling_done << true end [setter, sibling].each(&:join) expect(sibling_tries).to eq(3) end it "does not propagate an active override to a child thread" do child_tries = nil described_class.with_override(tries: 1) do Thread.new do tries = 0 begin described_class.retriable(tries: 3) do tries += 1 raise StandardError end rescue StandardError child_tries = tries end end.join end expect(child_tries).to eq(3) end it "shares the active override with fibers in the same thread" do fiber_tries = nil Thread.new do described_class.with_override(tries: 1) do Fiber.new do tries = 0 begin described_class.retriable(tries: 10) do tries += 1 raise StandardError end rescue StandardError fiber_tries = tries end end.resume end end.join expect(fiber_tries).to eq(1) end it "does not treat a main-thread override as a global default for other threads" do other_thread_tries = nil described_class.with_override(tries: 1) do Thread.new do tries = 0 begin described_class.retriable(tries: 3) do tries += 1 raise StandardError end rescue StandardError other_thread_tries = tries end end.join end expect(other_thread_tries).to eq(3) end it "applies overridden on_give_up handlers" do callback_called = false expect do described_class.with_override(on_give_up: proc { callback_called = true }) do described_class.retriable(tries: 1) { increment_tries_with_exception } end end.to raise_error(StandardError) expect(callback_called).to be(true) end it "applies on_give_up handlers configured via per-context overrides" do received_reason = nil handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason } expect do described_class.with_override(contexts: { api: { tries: 1, on_give_up: handler } }) do described_class.with_context(:api) { increment_tries_with_exception } end end.to raise_error(StandardError) expect(received_reason).to eq(:tries_exhausted) 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 "raises an ArgumentError when called without a block" do expect { described_class.with_context(:sql) } .to raise_error(ArgumentError, /with_context requires a block/) expect(@tries).to eq(0) end it "checks for a block before looking up the context" do expect { described_class.with_context(:missing) } .to raise_error(ArgumentError, /with_context requires a block/) 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 it "treats non-Hash context values as empty options" do described_class.configure do |c| c.contexts[:broken] = nil end described_class.with_context(:broken) { increment_tries } expect(@tries).to eq(1) end it "surfaces an invalid context on any retriable call before that context is used" do described_class.configure { |c| c.contexts[:unused] = { contexts: {} } } expect { described_class.retriable { :ok } } .to raise_error(ArgumentError, /contexts is not a valid option/) end it "invokes on_give_up configured on a context" do callback_called = false described_class.configure do |c| c.contexts[:flaky] = { tries: 1, on_give_up: proc { callback_called = true } } end expect { described_class.with_context(:flaky) { increment_tries_with_exception } } .to raise_error(StandardError) expect(callback_called).to be(true) end end end kamui-retriable-f1b3618/spec/spec_helper.rb000066400000000000000000000004071521333322400206720ustar00rootroot00000000000000# frozen_string_literal: true require "simplecov" SimpleCov.start do minimum_coverage 95 end require "pry" require_relative "../lib/retriable" require_relative "support/exceptions" RSpec.configure do |config| config.before(:each) do srand(0) end end kamui-retriable-f1b3618/spec/support/000077500000000000000000000000001521333322400175675ustar00rootroot00000000000000kamui-retriable-f1b3618/spec/support/exceptions.rb000066400000000000000000000002421521333322400222730ustar00rootroot00000000000000# frozen_string_literal: true class NonStandardError < Exception; end class SecondNonStandardError < NonStandardError; end class DifferentError < Exception; end