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 +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE +21 -0
- data/README.md +261 -0
- data/lib/resilient_call/circuit.rb +106 -0
- data/lib/resilient_call/circuit_breaker.rb +20 -0
- data/lib/resilient_call/configuration.rb +50 -0
- data/lib/resilient_call/errors.rb +28 -0
- data/lib/resilient_call/mixin.rb +25 -0
- data/lib/resilient_call/retrier.rb +73 -0
- data/lib/resilient_call/version.rb +5 -0
- data/lib/resilient_call.rb +91 -0
- metadata +87 -0
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
|
+
[](https://rubygems.org/gems/resilient_call)
|
|
4
|
+
[](https://www.ruby-lang.org)
|
|
5
|
+
[](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,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: []
|