resilient_call 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1b6710bbb609417fc8131a025b0a6064bac8bbd1e71d55e87b8a506d90709375
4
+ data.tar.gz: 106f9defb53a23687ca85e7186e90cdf2732bf5cffeab86472be6521d2478b56
5
+ SHA512:
6
+ metadata.gz: 61edc4419731654f9b6c0d19f3cbae025766fb2048d6f17e0fad9d177c729ce8580dcdb4f5b5ea6f9eef25209a335fa97c15c5f400c4e95a8fa347435b4fd381
7
+ data.tar.gz: a63fad56c64a7c4e93ba76837140dea471f09a2e0368fde195db068934155cc1f8020607d5bb20e0285a6a2ddeed1b4db67980ad0900445a3a925c5cbc4a13fc
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-06-12
11
+
12
+ ### Added
13
+
14
+ - `ResilientCall.call` — wraps any block with configurable retry and optional
15
+ circuit-breaker protection.
16
+ - Retry engine with `:exponential`, `:linear`, `:fixed`, and custom lambda
17
+ backoff strategies, plus jitter and a `max_wait` cap.
18
+ - `on:` option to retry only the listed exception classes, propagating others
19
+ immediately.
20
+ - Named, thread-safe circuit breaker — `Circuit` state machine plus the
21
+ `CircuitBreaker` registry — with `threshold`, `reset_timeout`, and half-open
22
+ probing.
23
+ - `fallback:` executed while the circuit is open; `CircuitOpenError` raised when
24
+ no fallback is set.
25
+ - Lifecycle callbacks: `on_retry`, `on_failure`, and `on_success`.
26
+ - Global defaults via `ResilientCall.configure` and reusable named option sets
27
+ via `ResilientCall.define_profile`.
28
+ - Option precedence: inline options > profile > global config > gem defaults.
29
+ - `ResilientCall::Mixin` with the `resilient_method` macro to declare resilience
30
+ at the method level.
31
+ - Error classes `ResilientCall::CircuitOpenError` and
32
+ `ResilientCall::RetriesExhaustedError` (the latter preserves the native
33
+ `#cause` chain).
34
+
35
+ [Unreleased]: https://github.com/VorynLabs/resilient_call/compare/v0.1.0...HEAD
36
+ [0.1.0]: https://github.com/VorynLabs/resilient_call/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VorynLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # resilient_call
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/resilient_call.svg)](https://rubygems.org/gems/resilient_call)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-red.svg)](https://www.ruby-lang.org)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ > Retry with exponential backoff and a circuit breaker for Ruby — zero dependencies.
8
+
9
+ `resilient_call` wraps any block of code with configurable retries (exponential,
10
+ linear, fixed, or custom backoff) and a named circuit breaker that protects your
11
+ system when an external service is consistently failing. It works with plain Ruby
12
+ and Rails, relies only on the standard library, and is thread-safe.
13
+
14
+ ## Installation
15
+
16
+ Add it to your `Gemfile`:
17
+
18
+ ```ruby
19
+ gem "resilient_call"
20
+ ```
21
+
22
+ Then run `bundle install`, or install it directly:
23
+
24
+ ```bash
25
+ gem install resilient_call
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ ```ruby
31
+ require "resilient_call"
32
+
33
+ result = ResilientCall.call { ExternalApi.fetch }
34
+ ```
35
+
36
+ That single call retries `ExternalApi.fetch` on failure using the default
37
+ exponential backoff, returning the result as soon as it succeeds.
38
+
39
+ ## Usage
40
+
41
+ ### Basic retry
42
+
43
+ ```ruby
44
+ ResilientCall.call(retries: 3, base_wait: 0.5) do
45
+ HttpClient.get("https://api.example.com/status")
46
+ end
47
+ ```
48
+
49
+ If the block keeps failing past the configured attempts, `ResilientCall.call`
50
+ raises `ResilientCall::RetriesExhaustedError`.
51
+
52
+ ### Retry strategies
53
+
54
+ Control the wait between attempts with `wait:`. The `attempt` number is 1-based.
55
+
56
+ | `wait:` | Behavior | Example (`base_wait: 0.5`) |
57
+ | -------------- | ------------------------------ | -------------------------- |
58
+ | `:exponential` | `base_wait * (2 ** attempt)` | 1s, 2s, 4s, 8s… |
59
+ | `:linear` | `base_wait * attempt` | 0.5s, 1s, 1.5s, 2s… |
60
+ | `:fixed` | `base_wait` | 0.5s, 0.5s, 0.5s… |
61
+ | `->(n) { … }` | custom, receives `attempt` | any logic |
62
+
63
+ With `jitter: true` (the default), a random `rand(0..base_wait * 0.3)` is added
64
+ to the computed wait before capping it at `max_wait`. This avoids the thundering
65
+ herd problem when many clients retry at once.
66
+
67
+ ```ruby
68
+ ResilientCall.call(wait: :exponential, base_wait: 0.5, max_wait: 30.0, jitter: true) do
69
+ PaymentGateway.charge(amount: 100)
70
+ end
71
+ ```
72
+
73
+ ### Selecting which errors trigger a retry
74
+
75
+ ```ruby
76
+ ResilientCall.call(on: [Net::OpenTimeout, Net::ReadTimeout]) do
77
+ HttpClient.get(url)
78
+ end
79
+ ```
80
+
81
+ Only the listed exception classes are retried; anything else propagates
82
+ immediately. The default is `[StandardError]`.
83
+
84
+ ### Circuit breaker
85
+
86
+ Pass `circuit:` to protect a dependency with a named circuit breaker. After
87
+ `threshold` consecutive failures the circuit opens and stops sending requests
88
+ until `reset_timeout` seconds have elapsed, when it allows a single probe.
89
+
90
+ ```ruby
91
+ ResilientCall.call(circuit: :stripe, threshold: 5, reset_timeout: 30) do
92
+ StripeClient.charge(amount: 100)
93
+ end
94
+ ```
95
+
96
+ While the circuit is open, `ResilientCall.call` raises
97
+ `ResilientCall::CircuitOpenError` (unless a `fallback` is given).
98
+
99
+ ### Fallback
100
+
101
+ ```ruby
102
+ ResilientCall.call(circuit: :stripe, fallback: -> { CachedResult.last }) do
103
+ StripeClient.charge(amount: 100)
104
+ end
105
+ ```
106
+
107
+ When the circuit is open the `fallback` runs instead of the block, and its
108
+ return value becomes the result of the call.
109
+
110
+ ### Callbacks
111
+
112
+ ```ruby
113
+ ResilientCall.call(
114
+ on_retry: ->(attempt, error) { Rails.logger.warn("retry ##{attempt}: #{error.message}") },
115
+ on_failure: ->(error) { Sentry.capture_exception(error) },
116
+ on_success: ->(result, tries) { StatsD.increment("ok", tags: ["tries:#{tries}"]) }
117
+ ) do
118
+ StripeClient.charge(amount: 100)
119
+ end
120
+ ```
121
+
122
+ ### Global configuration
123
+
124
+ Set defaults once — in `config/initializers/resilient_call.rb` on Rails, or at
125
+ boot in plain Ruby. Options passed to `.call` always override these.
126
+
127
+ ```ruby
128
+ ResilientCall.configure do |c|
129
+ c.retries = 3
130
+ c.wait = :exponential
131
+ c.base_wait = 0.5
132
+ c.max_wait = 30.0
133
+ c.jitter = true
134
+ c.on = [StandardError]
135
+
136
+ c.threshold = 5
137
+ c.reset_timeout = 60
138
+
139
+ c.on_retry = ->(attempt, error) { Rails.logger.warn("[resilient_call] retry ##{attempt}: #{error.message}") }
140
+ c.on_failure = ->(error) { Rails.logger.error("[resilient_call] failed: #{error.message}") }
141
+ end
142
+ ```
143
+
144
+ ### Reusable profiles
145
+
146
+ Profiles are named option sets. They merge over the global defaults and can be
147
+ overridden inline. Precedence: **inline options > profile > global config > gem defaults**.
148
+
149
+ ```ruby
150
+ ResilientCall.define_profile :payment,
151
+ retries: 3, wait: :exponential, circuit: :stripe, threshold: 5, reset_timeout: 30
152
+
153
+ ResilientCall.define_profile :fast_api,
154
+ retries: 1, wait: :fixed, base_wait: 0.1, on: [Net::OpenTimeout]
155
+
156
+ ResilientCall.call(profile: :payment) { StripeClient.charge(amount: 100) }
157
+ ResilientCall.call(profile: :fast_api, retries: 2) { SlackNotifier.ping(message) }
158
+ ```
159
+
160
+ ### Service object mixin
161
+
162
+ Declare resilience at the method level — transparent to the caller.
163
+
164
+ ```ruby
165
+ class PaymentService
166
+ include ResilientCall::Mixin
167
+
168
+ def charge(amount)
169
+ StripeClient.charge(amount: amount)
170
+ end
171
+ resilient_method :charge, profile: :payment
172
+
173
+ def refund(charge_id)
174
+ StripeClient.refund(charge_id)
175
+ end
176
+ resilient_method :refund, retries: 2, circuit: :stripe
177
+ end
178
+
179
+ PaymentService.new.charge(100) # retries + circuit breaker applied automatically
180
+ ```
181
+
182
+ `resilient_method` must be called after the method is defined. It forwards
183
+ positional arguments, keyword arguments, and blocks unchanged.
184
+
185
+ ### Inspecting circuits at runtime
186
+
187
+ ```ruby
188
+ circuit = ResilientCall::CircuitBreaker[:stripe]
189
+
190
+ circuit.state # => :closed | :open | :half_open
191
+ circuit.failure_count # => Integer
192
+ circuit.last_failure # => Exception or nil
193
+ circuit.opened_at # => Time or nil
194
+ circuit.reset! # force it closed — handy in a console, rake task, or test
195
+
196
+ ResilientCall::CircuitBreaker.reset_all! # reset every registered circuit
197
+ ```
198
+
199
+ ## Error reference
200
+
201
+ ### `ResilientCall::CircuitOpenError`
202
+
203
+ Raised when the circuit is open and no `fallback` is set.
204
+
205
+ ```ruby
206
+ rescue ResilientCall::CircuitOpenError => e
207
+ e.circuit_name # => :stripe
208
+ e.opens_at # => Time the circuit opened
209
+ e.retry_after # => Integer seconds estimated until half-open
210
+ e.last_error # => the last exception that contributed to opening
211
+ end
212
+ ```
213
+
214
+ ### `ResilientCall::RetriesExhaustedError`
215
+
216
+ Raised when every retry has been consumed without success.
217
+
218
+ ```ruby
219
+ rescue ResilientCall::RetriesExhaustedError => e
220
+ e.attempts # => number of attempts made
221
+ e.cause # => the last captured exception (native Ruby #cause)
222
+ end
223
+ ```
224
+
225
+ ## Configuration reference
226
+
227
+ | Option | Default | Description |
228
+ | --------------- | ----------------- | -------------------------------------------------------- |
229
+ | `retries` | `3` | Attempts after the first failure |
230
+ | `wait` | `:exponential` | Backoff strategy (`:exponential`/`:linear`/`:fixed`/Proc)|
231
+ | `base_wait` | `0.5` | Base seconds for the backoff calculation |
232
+ | `max_wait` | `30.0` | Upper bound for the backoff, in seconds |
233
+ | `jitter` | `true` | Add random noise to the wait |
234
+ | `on` | `[StandardError]` | Exception classes that trigger a retry |
235
+ | `circuit` | `nil` | Named circuit (Symbol); no circuit breaker without it |
236
+ | `threshold` | `5` | Consecutive failures before the circuit opens |
237
+ | `reset_timeout` | `60` | Seconds in the open state before a half-open probe |
238
+ | `fallback` | `nil` | Callable run when the circuit is open |
239
+ | `on_retry` | `nil` | `->(attempt, error)` called on each retry |
240
+ | `on_failure` | `nil` | `->(error)` called when attempts are exhausted |
241
+ | `on_success` | `nil` | `->(result, attempts)` called on success |
242
+
243
+ ## Roadmap
244
+
245
+ | Version | Feature |
246
+ | ------- | --------------------------------------------------------------- |
247
+ | `v0.2` | Pluggable storage for circuit state (Redis for multi-process) |
248
+ | `v0.3` | `ActiveSupport::Notifications` instrumentation and metrics |
249
+ | `v0.4` | Mountable Rack dashboard for live circuit state |
250
+ | `v0.5` | Native timeout integrated into the retry loop |
251
+ | `v1.0` | Stable API, full documentation, published benchmarks |
252
+
253
+ ## Contributing
254
+
255
+ Bug reports and pull requests are welcome on GitHub at
256
+ <https://github.com/VorynLabs/resilient_call>. Run the test suite with
257
+ `bundle exec rspec` before submitting.
258
+
259
+ ## License
260
+
261
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ResilientCall
4
+ # Holds the state of a single named circuit and manages its transitions.
5
+ # Thread-safe: every public method runs under an internal Mutex.
6
+ #
7
+ # closed --(threshold failures)--> open --(reset_timeout elapsed)--> half_open
8
+ # ^ |
9
+ # +------------------------- record_success! -------------------------+
10
+ # half_open --(record_failure!)--> open (restarts the timer)
11
+ class Circuit
12
+ attr_reader :name, :threshold, :reset_timeout
13
+
14
+ def initialize(name, threshold: 5, reset_timeout: 60)
15
+ @name = name
16
+ @threshold = threshold
17
+ @reset_timeout = reset_timeout
18
+ @state = :closed
19
+ @failure_count = 0
20
+ @opened_at = nil
21
+ @last_failure = nil
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ def state
26
+ @mutex.synchronize { @state }
27
+ end
28
+
29
+ def failure_count
30
+ @mutex.synchronize { @failure_count }
31
+ end
32
+
33
+ def last_failure
34
+ @mutex.synchronize { @last_failure }
35
+ end
36
+
37
+ def opened_at
38
+ @mutex.synchronize { @opened_at }
39
+ end
40
+
41
+ # Whether a request may pass right now. An :open circuit whose reset_timeout
42
+ # has elapsed transitions to :half_open and lets a single probe through.
43
+ def allow_request?
44
+ @mutex.synchronize do
45
+ case @state
46
+ when :closed
47
+ true
48
+ when :half_open
49
+ true
50
+ when :open
51
+ if Time.now - @opened_at >= @reset_timeout
52
+ @state = :half_open
53
+ true
54
+ else
55
+ false
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def record_success!
62
+ @mutex.synchronize do
63
+ case @state
64
+ when :half_open
65
+ @state = :closed
66
+ @failure_count = 0
67
+ when :closed
68
+ @failure_count = 0
69
+ end
70
+ end
71
+ end
72
+
73
+ def record_failure!(error)
74
+ @mutex.synchronize do
75
+ @failure_count += 1
76
+ @last_failure = error
77
+
78
+ if @state == :half_open
79
+ @state = :open
80
+ @opened_at = Time.now
81
+ elsif @state == :closed && @failure_count >= @threshold
82
+ @state = :open
83
+ @opened_at = Time.now
84
+ end
85
+ end
86
+ end
87
+
88
+ def reset!
89
+ @mutex.synchronize do
90
+ @state = :closed
91
+ @failure_count = 0
92
+ @opened_at = nil
93
+ @last_failure = nil
94
+ end
95
+ end
96
+
97
+ # Lets the entry point inject configured thresholds onto a circuit that was
98
+ # created lazily by the registry with default values.
99
+ def update_config(threshold: nil, reset_timeout: nil)
100
+ @mutex.synchronize do
101
+ @threshold = threshold unless threshold.nil?
102
+ @reset_timeout = reset_timeout unless reset_timeout.nil?
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ResilientCall
4
+ # Global, thread-safe registry of named Circuit instances.
5
+ module CircuitBreaker
6
+ @registry = {}
7
+ @mutex = Mutex.new
8
+
9
+ # Returns the circuit for `name`, creating it on first access. Created with
10
+ # the scope-3 defaults (threshold: 5, reset_timeout: 60); scope 4 will inject
11
+ # configured values through the entry point.
12
+ def self.[](name)
13
+ @mutex.synchronize { @registry[name] ||= Circuit.new(name) }
14
+ end
15
+
16
+ def self.reset_all!
17
+ @mutex.synchronize { @registry.each_value(&:reset!) }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ResilientCall
4
+ # Holds the global defaults and the registry of named profiles.
5
+ class Configuration
6
+ # retry
7
+ attr_accessor :retries, :wait, :base_wait, :max_wait, :jitter, :on
8
+ # circuit breaker
9
+ attr_accessor :threshold, :reset_timeout
10
+ # callbacks
11
+ attr_accessor :on_retry, :on_failure, :on_success
12
+ # named profiles registry
13
+ attr_accessor :profiles
14
+
15
+ def initialize
16
+ @retries = 3
17
+ @wait = :exponential
18
+ @base_wait = 0.5
19
+ @max_wait = 30.0
20
+ @jitter = true
21
+ @on = [StandardError]
22
+
23
+ @threshold = 5
24
+ @reset_timeout = 60
25
+
26
+ @on_retry = nil
27
+ @on_failure = nil
28
+ @on_success = nil
29
+
30
+ @profiles = {}
31
+ end
32
+
33
+ # Every default except the profiles registry, ready to be merged in `.call`.
34
+ def to_h
35
+ {
36
+ retries: @retries,
37
+ wait: @wait,
38
+ base_wait: @base_wait,
39
+ max_wait: @max_wait,
40
+ jitter: @jitter,
41
+ on: @on,
42
+ threshold: @threshold,
43
+ reset_timeout: @reset_timeout,
44
+ on_retry: @on_retry,
45
+ on_failure: @on_failure,
46
+ on_success: @on_success
47
+ }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ResilientCall
4
+ # Raised when a request is rejected because its named circuit is open.
5
+ class CircuitOpenError < StandardError
6
+ attr_reader :circuit_name, :opens_at, :retry_after, :last_error
7
+
8
+ def initialize(circuit_name:, opens_at:, retry_after:, last_error:, message: nil)
9
+ @circuit_name = circuit_name
10
+ @opens_at = opens_at
11
+ @retry_after = retry_after
12
+ @last_error = last_error
13
+ super(message || "circuit #{circuit_name.inspect} is open")
14
+ end
15
+ end
16
+
17
+ # Raised when all configured retries have been exhausted without success.
18
+ # The original exception is available through Ruby's native `#cause` chain
19
+ # when this error is raised from inside a rescue block.
20
+ class RetriesExhaustedError < StandardError
21
+ attr_reader :attempts
22
+
23
+ def initialize(attempts:, message: nil)
24
+ @attempts = attempts
25
+ super(message || "exhausted after #{attempts} attempt(s)")
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ResilientCall
4
+ # Adds the `resilient_method` class macro to any class that includes it,
5
+ # wrapping the named instance method in a transparent ResilientCall.call.
6
+ module Mixin
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ end
10
+
11
+ module ClassMethods
12
+ # Wraps an already-defined instance method so every invocation runs
13
+ # through ResilientCall.call with the given options (retry, circuit, etc).
14
+ def resilient_method(method_name, **options)
15
+ original = instance_method(method_name)
16
+
17
+ define_method(method_name) do |*args, **kwargs, &block|
18
+ ResilientCall.call(**options) do
19
+ original.bind_call(self, *args, **kwargs, &block)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ResilientCall
4
+ # Executes a block with retry, computing the wait between attempts and firing
5
+ # the configured callbacks. Has no knowledge of circuit breakers.
6
+ class Retrier
7
+ DEFAULTS = {
8
+ retries: 3,
9
+ wait: :exponential,
10
+ base_wait: 0.5,
11
+ max_wait: 30.0,
12
+ jitter: true,
13
+ on: [StandardError],
14
+ on_retry: nil,
15
+ on_failure: nil,
16
+ on_success: nil
17
+ }.freeze
18
+
19
+ def initialize(options = {})
20
+ opts = DEFAULTS.merge(options)
21
+
22
+ @retries = opts[:retries]
23
+ @wait = opts[:wait]
24
+ @base_wait = opts[:base_wait]
25
+ @max_wait = opts[:max_wait]
26
+ @jitter = opts[:jitter]
27
+ @on = Array(opts[:on])
28
+ @on_retry = opts[:on_retry]
29
+ @on_failure = opts[:on_failure]
30
+ @on_success = opts[:on_success]
31
+ end
32
+
33
+ # Runs the block. Returns its result, or raises RetriesExhaustedError once
34
+ # every attempt has been consumed.
35
+ def call
36
+ attempt = 0
37
+
38
+ begin
39
+ attempt += 1
40
+ result = yield
41
+ @on_success&.call(result, attempt)
42
+ result
43
+ rescue *@on => err
44
+ if attempt <= @retries
45
+ @on_retry&.call(attempt, err)
46
+ sleep(wait_time(attempt))
47
+ retry
48
+ end
49
+
50
+ @on_failure&.call(err)
51
+ # Raised inside the rescue so Ruby populates #cause with `err`.
52
+ raise RetriesExhaustedError.new(attempts: attempt)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # 1-based attempt number. Applies jitter before capping at max_wait.
59
+ def wait_time(attempt)
60
+ raw = case @wait
61
+ when :exponential then @base_wait * (2**attempt)
62
+ when :linear then @base_wait * attempt
63
+ when :fixed then @base_wait
64
+ when Proc then @wait.call(attempt)
65
+ else
66
+ raise ArgumentError, "unknown wait strategy: #{@wait.inspect}"
67
+ end
68
+
69
+ raw += rand(0..@base_wait * 0.3) if @jitter
70
+ [raw, @max_wait].min
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ResilientCall
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resilient_call/version"
4
+ require_relative "resilient_call/errors"
5
+ require_relative "resilient_call/configuration"
6
+ require_relative "resilient_call/circuit"
7
+ require_relative "resilient_call/circuit_breaker"
8
+ require_relative "resilient_call/retrier"
9
+ require_relative "resilient_call/mixin"
10
+
11
+ module ResilientCall
12
+ class << self
13
+ # Runs `block` with retry and, when `circuit:` is given, circuit-breaker
14
+ # protection. Option precedence: inline > profile > global config > defaults.
15
+ def call(**options, &block)
16
+ options = resolve_options(options)
17
+ circuit = circuit_for(options)
18
+
19
+ if circuit && !circuit.allow_request?
20
+ return options[:fallback].call if options[:fallback]
21
+
22
+ raise circuit_open_error(circuit, options)
23
+ end
24
+
25
+ run_with_retry(options, circuit, &block)
26
+ end
27
+
28
+ def configure
29
+ yield configuration
30
+ end
31
+
32
+ def configuration
33
+ @configuration ||= Configuration.new
34
+ end
35
+
36
+ def define_profile(name, **options)
37
+ configuration.profiles[name] = options
38
+ end
39
+
40
+ # Restores global defaults — useful for isolating tests.
41
+ def reset_configuration!
42
+ @configuration = Configuration.new
43
+ end
44
+
45
+ private
46
+
47
+ # Merges defaults < profile < inline options into a single options hash.
48
+ def resolve_options(options)
49
+ merged = configuration.to_h
50
+
51
+ if (profile_name = options.delete(:profile))
52
+ merged = merged.merge(configuration.profiles[profile_name] || {})
53
+ end
54
+
55
+ merged.merge(options)
56
+ end
57
+
58
+ # Resolves the named circuit (if any) and applies the configured thresholds.
59
+ def circuit_for(options)
60
+ return unless (name = options[:circuit])
61
+
62
+ CircuitBreaker[name].tap do |circuit|
63
+ circuit.update_config(
64
+ threshold: options[:threshold],
65
+ reset_timeout: options[:reset_timeout]
66
+ )
67
+ end
68
+ end
69
+
70
+ def circuit_open_error(circuit, options)
71
+ CircuitOpenError.new(
72
+ circuit_name: options[:circuit],
73
+ opens_at: circuit.opened_at,
74
+ retry_after: (circuit.opened_at + options[:reset_timeout] - Time.now).to_i,
75
+ last_error: circuit.last_failure
76
+ )
77
+ end
78
+
79
+ # Runs the block through the Retrier, feeding circuit outcomes as it goes.
80
+ def run_with_retry(options, circuit, &block)
81
+ Retrier.new(options).call do
82
+ result = block.call
83
+ circuit&.record_success!
84
+ result
85
+ rescue StandardError => e
86
+ circuit&.record_failure!(e)
87
+ raise
88
+ end
89
+ end
90
+ end
91
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: resilient_call
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wesley Sena
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.12'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Wrapper for any Ruby block with configurable retries, jitter, named circuit
42
+ breaker, declarative fallback, and service class mixin support.
43
+ email:
44
+ - vorynworks@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - lib/resilient_call.rb
53
+ - lib/resilient_call/circuit.rb
54
+ - lib/resilient_call/circuit_breaker.rb
55
+ - lib/resilient_call/configuration.rb
56
+ - lib/resilient_call/errors.rb
57
+ - lib/resilient_call/mixin.rb
58
+ - lib/resilient_call/retrier.rb
59
+ - lib/resilient_call/version.rb
60
+ homepage: https://github.com/VorynLabs/resilient_call
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ allowed_push_host: https://rubygems.org
65
+ homepage_uri: https://github.com/VorynLabs/resilient_call
66
+ source_code_uri: https://github.com/VorynLabs/resilient_call
67
+ changelog_uri: https://github.com/VorynLabs/resilient_call/blob/main/CHANGELOG.md
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.0.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.5.11
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Retry with exponential backoff and circuit breaker for Ruby
87
+ test_files: []