Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Here are the available options, in some vague order of relevance to most common
| ------ | ------- | ---------- |
| **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). |
| **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
| **`ignore`** | `[NonStandardError]` | Type of exceptions to ignore from retrying. [Read more](#configuring-which-options-to-retry-with-ignore). |
| **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks). |
| **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
| **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
Expand All @@ -100,6 +101,16 @@ Here are the available options, in some vague order of relevance to most common
- A single `Regexp` pattern (retries exceptions ONLY if their `message` matches the pattern)
- An array of patterns (retries exceptions ONLY if their `message` matches at least one of the patterns)

#### Configuring Which Options to Retry With :ignore
**`:ignore`** Can take the form:

- An `Exception` class (ignore every exception of this type, including subclasses)
- An `Array` of `Exception` classes (ignore any exception of one of these types, including subclasses)
- A `Hash` where the keys are `Exception` classes and the values are one of:
- `nil` (ignore every exception of the key's type, including subclasses)
- A single `Regexp` pattern (ignores exceptions ONLY if their `message` matches the pattern)
- An array of patterns (ignores exceptions ONLY if their `message` matches at least one of the patterns)


### Configuration

Expand Down Expand Up @@ -142,6 +153,16 @@ Retriable.retriable(on: {
end
```

Conversely you can also specify the errors to ignore from retries (see [the documentation above](#configuring-which-options-to-retry-with-ignore)). This example will ignore all `ActiveRecord::RecordNotUnique` exceptions where the message matches either `/Parent must exist/` or `/Username has already been taken/`.

```ruby
Retriable.retriable(ignore: {
ActiveRecord::RecordNotUnique => [/Parent must exist/, /Username has already been taken/]
}) do
# code here...
end
```

You can also specify a timeout if you want the code block to only try for X amount of seconds. This timeout is per try.

The implementation uses `Timeout::timeout`, which may be [unsafe](https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/) [and](http://blog.headius.com/2008/02/ruby-threadraise-threadkill-timeoutrb.html) [even](https://adamhooper.medium.com/in-ruby-dont-use-timeout-77d9d4e5a001) [dangerous](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/). You can use this option, but you need to be very careful because the code in the block, including libraries or other code it calls, could be interrupted by the timeout at any line. You must ensure you have the right rescue logic and guards in place ([Thread.handle_interrupt](https://www.rubydoc.info/stdlib/core/Thread.handle_interrupt)) to handle that possible behavior. If that's not possible, the recommendation is that you're better off impelenting your own timeout methods depending on what your code is doing than use this feature.
Expand Down
17 changes: 15 additions & 2 deletions lib/retriable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ def retriable(opts = {})
on = local_config.on
on_retry = local_config.on_retry
sleep_disabled = local_config.sleep_disabled
ignore = local_config.ignore

exception_list = on.is_a?(Hash) ? on.keys : on
exception_list = on.is_a?(Hash) ? on.keys : [on].flatten
ignore_list = ignore.is_a?(Hash) ? ignore.keys : [ignore].flatten
rescue_list = exception_list + ignore_list
start_time = Time.now
elapsed_time = -> { Time.now - start_time }

Expand All @@ -59,7 +62,17 @@ def retriable(opts = {})
begin
return Timeout.timeout(timeout) { return yield(try) } if timeout
return yield(try)
rescue *[*exception_list] => exception
rescue *rescue_list => exception
if ignore.is_a?(Hash)
raise if ignore_list.any? do |e|
exception.is_a?(e) && ([*ignore[e]].empty? || [*ignore[e]].any? { |pattern| exception.message =~ pattern })
end
elsif ignore.is_a?(Array)
raise if ignore_list.any? do |e|
exception.is_a?(e)
end
end

if on.is_a?(Hash)
raise unless exception_list.any? do |e|
exception.is_a?(e) && ([*on[e]].empty? || [*on[e]].any? { |pattern| exception.message =~ pattern })
Expand Down
2 changes: 2 additions & 0 deletions lib/retriable/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Config
:intervals,
:timeout,
:on,
:ignore,
:on_retry,
:contexts,
]).freeze
Expand All @@ -27,6 +28,7 @@ def initialize(opts = {})
@intervals = nil
@timeout = nil
@on = [StandardError]
@ignore = []
@on_retry = nil
@contexts = {}

Expand Down
72 changes: 72 additions & 0 deletions spec/retriable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,78 @@ def increment_tries_with_exception(exception_class = nil)
end
end

context "with an array :ignore parameter" do
it "does not retry :ignore exception" do
expect do
described_class.retriable(ignore: [NonStandardError]) do
increment_tries

raise StandardError if @tries == 1
raise NonStandardError if @tries == 2
end
end.to raise_error(NonStandardError)

expect(@tries).to eq(2)
end

it "overrides :on parameter" do
expect do
described_class.retriable(ignore: [StandardError]) do
increment_tries

raise StandardError if @tries == 1
end
end.to raise_error(StandardError)
end
end

context "with a hash :ignore parameter" do
let(:ignore_hash) { { NonStandardError => /NonStandardError occurred/ } }

it "where the value is an exception message pattern" do
expect do
described_class.retriable(ignore: ignore_hash) { increment_tries_with_exception(NonStandardError) }
end.to raise_error(NonStandardError, /NonStandardError occurred/)

expect(@tries).to eq(1)
end

it "matches exception subclasses when message matches pattern" do
expect do
described_class.retriable(ignore: ignore_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(1)
end

it "does not ignore matching exception subclass but not message" do
expect do
described_class.retriable(ignore: ignore_hash) do
increment_tries
raise SecondNonStandardError, "not a match"
end
end.to raise_error(SecondNonStandardError, /not a match/)

expect(@tries).to eq(3)
end

it "successfully ignores when the values are arrays of exception message patterns" do
ignore_hash = { StandardError => nil, NonStandardError => [/foo/, /bar/] }

expect do
described_class.retriable(ignore: ignore_hash) do
increment_tries

raise NonStandardError, "foo" if @tries == 1
end
end.to raise_error(NonStandardError, /foo/)

expect(@tries).to eq(1)
end
end

context "with a hash :on parameter" do
let(:on_hash) { { NonStandardError => /NonStandardError occurred/ } }

Expand Down