Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# HEAD

- 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).
Expand Down
75 changes: 52 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,34 @@ Retriable.configure do |c|
end
```

`#configure` sets defaults only. Per-call options passed to `Retriable.retriable` and
`Retriable.with_context` still take precedence.

### Override

If you need to force values globally (including over per-call options), use
`#override`:

```ruby
Retriable.override(tries: 1, base_interval: 0)
```

`#override` precedence:

```
override > local options > configure defaults
```

`#override` uses process-global state. Once set, it affects every caller and
thread until `#reset_override` runs. Prefer setting it once at boot (or in test
helpers), and avoid toggling it per request in multi-threaded runtimes.

To clear an override:

```ruby
Retriable.reset_override
```
Comment thread
kamui marked this conversation as resolved.

### Example Usage

This example will only retry on a `Timeout::Error`, retry 3 times and sleep for a full second before each try.
Expand Down Expand Up @@ -340,33 +368,33 @@ end

When you are running tests for your app it often takes a long time to retry blocks that fail. This is because Retriable will default to 3 tries with exponential backoff. Ideally your tests will run as quickly as possible.

You can disable retrying by setting `tries` to 1 in the test environment. If you want to test that the code is retrying an error, you want to [turn off exponential backoff](#turn-off-exponential-backoff).
If you want to short-circuit retries in tests, including calls that pass local options, use `Retriable.override` and set `tries` to `1`.

Under Rails, you could change your initializer to have different options in test, as follows:
Under Rails, keep shared defaults in `Retriable.configure` and apply test-only overrides conditionally:

```ruby
# config/initializers/retriable.rb
Retriable.configure do |c|
# ... default configuration
c.tries = 3
c.base_interval = 0.5
c.rand_factor = 0.5
end

if Rails.env.test?
c.tries = 1
end
if Rails.env.test?
Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
end
```

Note: In this and the following examples, `Retriable.configure` sets a default config, it doesn't override the configuration for the `retriable` method calls. Calling `Retriable.retriable` with options will override the default configuration for that call. So if you have `tries` set to 5 in `Retriable.configure`, but then you call `Retriable.retriable(tries: 3)`, that call will use 3 tries instead of 5. The configuration is basically a default set of options that can be overridden by passing options to the `retriable` method or by using contexts.
If you need to run a specific test with normal retry behavior, call `Retriable.reset_override` for that example and then reapply your test override afterward.

Alternately, if you are using RSpec, you could override the Retriable confguration in your `spec_helper`.
Alternately, if you are using RSpec, you could override the Retriable configuration in your `spec_helper`.

```ruby
# spec/spec_helper.rb
Retriable.configure do |c|
c.tries = 1
end
Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
```

If you have defined contexts for your configuration, you'll need to change values for each context, because those values take precedence over the default configured value.
If you have defined contexts for your configuration, top-level override values (such as `tries: 1`) already take precedence over context-specific values. However, if you need to override context-specific options (for example, clearing a context's `:intervals` array or changing its `:on` exception list), pass `:contexts` to `Retriable.override`:

For example assuming you have configured a `google_api` context:

Expand All @@ -386,20 +414,21 @@ Retriable.configure do |c|
end
```

Then in your test environment, you would need to set each context and the default value:
Then in your test environment, you can override both top-level defaults and per-context options:

```ruby
# spec/spec_helper.rb
Retriable.configure do |c|
c.multiplier = 1.0
c.rand_factor = 0.0
c.base_interval = 0

c.contexts.keys.each do |context|
c.contexts[context][:tries] = 1
c.contexts[context][:base_interval] = 0
end
# Build context overrides from existing configured context keys
context_overrides = {}
Retriable.config.contexts.each_key do |key|
context_overrides[key] = { tries: 1, base_interval: 0 }
end

Retriable.override(
multiplier: 1.0,
rand_factor: 0.0,
base_interval: 0,
contexts: context_overrides,
)
```

## Credits
Expand Down
89 changes: 85 additions & 4 deletions lib/retriable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@
module Retriable
module_function

def deep_merge(base, overrides)
base.merge(overrides) do |_key, base_value, override_value|
if base_value.is_a?(Hash) && override_value.is_a?(Hash)
deep_merge(base_value, override_value)
else
override_value
end
end
end

def deep_dup(obj)
case obj
when Hash
obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
when Array
obj.map { |v| deep_dup(v) }
else
obj
end
end

def configure
yield(config)
end
Expand All @@ -16,19 +37,40 @@ def config
@config ||= Config.new
end

def override(opts = {})
opts.each_key do |k|
raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
end
@override_config = opts.empty? ? nil : deep_dup(opts).freeze
end
Comment thread
kamui marked this conversation as resolved.

def reset_override
@override_config = nil
end

def with_context(context_key, options = {}, &block)
if !config.contexts.key?(context_key)
contexts = merged_contexts

if !contexts.key?(context_key)
raise ArgumentError,
"#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
"#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
end

return unless block_given?

retriable(config.contexts[context_key].merge(options), &block)
context_options = merged_context_options(contexts, context_key, options)

retriable(context_options, &block)
end

def retriable(opts = {}, &block)
local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts))
if opts.empty? && !override_config
local_config = config
else
local_config_hash = config.to_h.merge(opts)
local_config_hash = deep_merge(local_config_hash, override_config) if override_config
local_config = Config.new(local_config_hash)
end

tries = local_config.tries
intervals = build_intervals(local_config, tries)
Expand Down Expand Up @@ -128,13 +170,52 @@ def hash_exception_match?(exception, on, exception_list)
end
end

def override_config
@override_config
end

def merged_contexts
return config.contexts unless override_config&.key?(:contexts)

base_contexts = config.contexts
override_contexts = override_config[:contexts]

if override_contexts.is_a?(Hash)
return deep_merge(base_contexts.is_a?(Hash) ? base_contexts : {}, override_contexts)
end
return {} if override_contexts.nil?

base_contexts
end

def merged_context_options(contexts, context_key, options)
base = contexts[context_key]
base = {} unless base.is_a?(Hash)
context_options = base.merge(options)

Comment thread
kamui marked this conversation as resolved.
Comment thread
kamui marked this conversation as resolved.
return context_options unless override_config

override_contexts = override_config[:contexts]
return context_options unless override_contexts.is_a?(Hash)

override_context_options = override_contexts[context_key]
return context_options unless override_context_options.is_a?(Hash)

deep_merge(context_options, override_context_options)
end
Comment on lines +191 to +205
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merged_context_options assumes both contexts[context_key] and override_config[:contexts][context_key] are Hashes. If either is nil or non-Hash (possible via configure or override), .merge / deep_merge will raise at runtime. Add type checks (or normalize to {} / raise a clear ArgumentError) before merging to avoid NoMethodError/TypeError from misconfiguration.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in the same commit (254b653). merged_context_options now validates both values with .is_a?(Hash) guards:

  • contexts[context_key] → line 193: base = {} unless base.is_a?(Hash)
  • override_contexts[context_key] → line 202: return context_options unless override_context_options.is_a?(Hash)

Non-Hash values (including nil) fall back to {} or skip the override merge entirely, so no NoMethodError/TypeError is possible.


private_class_method(
:deep_merge,
:deep_dup,
:execute_tries,
:build_intervals,
:call_with_timeout,
:call_on_retry,
:can_retry?,
:retriable_exception?,
:hash_exception_match?,
:override_config,
:merged_contexts,
:merged_context_options,
)
end
2 changes: 1 addition & 1 deletion lib/retriable/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Retriable
VERSION = "3.4.1"
VERSION = "3.4.2"
end
Loading