resilient_call 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +17 -1
- data/README.md +8 -4
- data/lib/resilient_call/circuit.rb +89 -42
- data/lib/resilient_call/circuit_breaker.rb +5 -3
- data/lib/resilient_call/configuration.rb +9 -2
- data/lib/resilient_call/retrier.rb +41 -23
- data/lib/resilient_call/storage/base.rb +30 -0
- data/lib/resilient_call/storage/memory.rb +33 -0
- data/lib/resilient_call/storage/redis.rb +59 -0
- data/lib/resilient_call/version.rb +1 -1
- data/lib/resilient_call.rb +22 -5
- metadata +33 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 241b883a4916ae4e5847d47485005391f3ce1366fca15830d4cef1efe34c55ba
|
|
4
|
+
data.tar.gz: de5aa926f966d68cd92ca89a7e5fac3da22b2c4776fdc4c738f2a147dd6976dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2e7e07d4044973eb64aab0d9d49d1341807b5f828f2e5b02df653b3061785e5b1d15c532d0c190cd0d21a2fb65ad37a530ddad51d4db3f2cdab21a70994198c
|
|
7
|
+
data.tar.gz: 16922ff209347a4608988154489c8d26182dd5dbcc9a87fdcb2bf56dd4c8b91ddfe801420149c6188f7534608f4044ef299fb95b6544b9cd49a03cf2cd4e14a1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-07-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Pluggable circuit storage via `ResilientCall::Storage::Base` — lets the circuit
|
|
15
|
+
breaker share state across processes instead of a per-process in-memory Hash.
|
|
16
|
+
- `ResilientCall::Storage::Memory`, the default backend, extracted from the
|
|
17
|
+
`Circuit`/`CircuitBreaker` internals with no behavior change.
|
|
18
|
+
- `circuit_storage` global config option to swap the storage backend.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `Circuit` now delegates state persistence to the configured storage instead of
|
|
23
|
+
holding `@state`/`@failure_count` as instance variables.
|
|
24
|
+
|
|
10
25
|
## [0.1.0] - 2026-06-12
|
|
11
26
|
|
|
12
27
|
### Added
|
|
@@ -32,5 +47,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
32
47
|
`ResilientCall::RetriesExhaustedError` (the latter preserves the native
|
|
33
48
|
`#cause` chain).
|
|
34
49
|
|
|
35
|
-
[Unreleased]: https://github.com/VorynLabs/resilient_call/compare/v0.
|
|
50
|
+
[Unreleased]: https://github.com/VorynLabs/resilient_call/compare/v0.2.0...HEAD
|
|
51
|
+
[0.2.0]: https://github.com/VorynLabs/resilient_call/compare/v0.1.0...v0.2.0
|
|
36
52
|
[0.1.0]: https://github.com/VorynLabs/resilient_call/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -242,12 +242,16 @@ end
|
|
|
242
242
|
|
|
243
243
|
## Roadmap
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
Current release: **v0.1.0** — see the [CHANGELOG](CHANGELOG.md) for what shipped.
|
|
246
|
+
|
|
247
|
+
The versions below are planned, not yet released:
|
|
248
|
+
|
|
249
|
+
| Version | Planned feature |
|
|
246
250
|
| ------- | --------------------------------------------------------------- |
|
|
247
251
|
| `v0.2` | Pluggable storage for circuit state (Redis for multi-process) |
|
|
248
|
-
| `v0.3` |
|
|
249
|
-
| `v0.4` |
|
|
250
|
-
| `v0.5` |
|
|
252
|
+
| `v0.3` | Native timeout integrated into the retry loop |
|
|
253
|
+
| `v0.4` | `ActiveSupport::Notifications` instrumentation and metrics |
|
|
254
|
+
| `v0.5` | Mountable Rack dashboard for live circuit state |
|
|
251
255
|
| `v1.0` | Stable API, full documentation, published benchmarks |
|
|
252
256
|
|
|
253
257
|
## Contributing
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "storage/memory"
|
|
4
|
+
|
|
3
5
|
module ResilientCall
|
|
4
6
|
# Holds the state of a single named circuit and manages its transitions.
|
|
5
|
-
#
|
|
7
|
+
# The mutable state lives in the injected `storage` (memory by default, Redis
|
|
8
|
+
# for multi-process setups); this class owns only the transition rules.
|
|
9
|
+
#
|
|
10
|
+
# Thread-safe within a process: every read-modify-write runs under an internal
|
|
11
|
+
# Mutex, so concurrent calls on the same instance stay consistent. Across
|
|
12
|
+
# processes, consistency is bounded by the storage backend (see Storage::Redis).
|
|
6
13
|
#
|
|
7
14
|
# closed --(threshold failures)--> open --(reset_timeout elapsed)--> half_open
|
|
8
15
|
# ^ |
|
|
@@ -11,86 +18,71 @@ module ResilientCall
|
|
|
11
18
|
class Circuit
|
|
12
19
|
attr_reader :name, :threshold, :reset_timeout
|
|
13
20
|
|
|
14
|
-
def initialize(name, threshold: 5, reset_timeout: 60)
|
|
21
|
+
def initialize(name, threshold: 5, reset_timeout: 60, storage: nil)
|
|
15
22
|
@name = name
|
|
16
23
|
@threshold = threshold
|
|
17
24
|
@reset_timeout = reset_timeout
|
|
18
|
-
@
|
|
19
|
-
@failure_count = 0
|
|
20
|
-
@opened_at = nil
|
|
25
|
+
@storage = storage || Storage::Memory.new
|
|
21
26
|
@last_failure = nil
|
|
22
27
|
@mutex = Mutex.new
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
def state
|
|
26
|
-
|
|
31
|
+
read_field(:status)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def failure_count
|
|
30
|
-
|
|
35
|
+
read_field(:failure_count)
|
|
31
36
|
end
|
|
32
37
|
|
|
38
|
+
# The full Exception object captured most recently *in this process*. State
|
|
39
|
+
# shared through Redis only carries its message/class, so a fresh process
|
|
40
|
+
# reading an open circuit sees nil here — use CircuitOpenError for details.
|
|
33
41
|
def last_failure
|
|
34
42
|
@mutex.synchronize { @last_failure }
|
|
35
43
|
end
|
|
36
44
|
|
|
37
45
|
def opened_at
|
|
38
|
-
|
|
46
|
+
read_field(:opened_at)
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
# Whether a request may pass right now. An :open circuit whose reset_timeout
|
|
42
50
|
# has elapsed transitions to :half_open and lets a single probe through.
|
|
43
51
|
def allow_request?
|
|
44
52
|
@mutex.synchronize do
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
when :half_open
|
|
49
|
-
|
|
50
|
-
when :open
|
|
51
|
-
if Time.now - @opened_at >= @reset_timeout
|
|
52
|
-
@state = :half_open
|
|
53
|
-
true
|
|
54
|
-
else
|
|
55
|
-
false
|
|
56
|
-
end
|
|
53
|
+
state = read_state
|
|
54
|
+
|
|
55
|
+
case state[:status]
|
|
56
|
+
when :closed, :half_open then true
|
|
57
|
+
when :open then attempt_half_open(state)
|
|
57
58
|
end
|
|
58
59
|
end
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def record_success!
|
|
62
|
-
|
|
63
|
-
case
|
|
64
|
-
when :half_open
|
|
65
|
-
|
|
66
|
-
@failure_count = 0
|
|
67
|
-
when :closed
|
|
68
|
-
@failure_count = 0
|
|
63
|
+
transition do |state|
|
|
64
|
+
case state[:status]
|
|
65
|
+
when :half_open then state.merge!(status: :closed, failure_count: 0)
|
|
66
|
+
when :closed then state[:failure_count] = 0
|
|
69
67
|
end
|
|
70
68
|
end
|
|
71
69
|
end
|
|
72
70
|
|
|
73
71
|
def record_failure!(error)
|
|
74
|
-
|
|
75
|
-
@
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
elsif @state == :closed && @failure_count >= @threshold
|
|
82
|
-
@state = :open
|
|
83
|
-
@opened_at = Time.now
|
|
84
|
-
end
|
|
72
|
+
transition do |state|
|
|
73
|
+
@last_failure = error
|
|
74
|
+
state[:failure_count] += 1
|
|
75
|
+
state[:last_failure_message] = error.message
|
|
76
|
+
state[:last_failure_class] = error.class.name
|
|
77
|
+
|
|
78
|
+
trip_open!(state) if should_open?(state)
|
|
85
79
|
end
|
|
86
80
|
end
|
|
87
81
|
|
|
88
82
|
def reset!
|
|
89
83
|
@mutex.synchronize do
|
|
90
|
-
@
|
|
91
|
-
@
|
|
92
|
-
@opened_at = nil
|
|
93
|
-
@last_failure = nil
|
|
84
|
+
@last_failure = nil
|
|
85
|
+
@storage.reset(@name)
|
|
94
86
|
end
|
|
95
87
|
end
|
|
96
88
|
|
|
@@ -102,5 +94,60 @@ module ResilientCall
|
|
|
102
94
|
@reset_timeout = reset_timeout unless reset_timeout.nil?
|
|
103
95
|
end
|
|
104
96
|
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def read_field(key)
|
|
101
|
+
@mutex.synchronize { read_state[key] }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Reads the persisted state, yields it for mutation, then writes it back —
|
|
105
|
+
# the read-modify-write cycle shared by every state-changing method.
|
|
106
|
+
def transition
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
state = read_state
|
|
109
|
+
yield state
|
|
110
|
+
write_state(state)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Trips an elapsed :open circuit into :half_open so one probe can pass.
|
|
115
|
+
def attempt_half_open(state)
|
|
116
|
+
return false unless Time.now - state[:opened_at] >= @reset_timeout
|
|
117
|
+
|
|
118
|
+
state[:status] = :half_open
|
|
119
|
+
write_state(state)
|
|
120
|
+
true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def should_open?(state)
|
|
124
|
+
state[:status] == :half_open ||
|
|
125
|
+
(state[:status] == :closed && state[:failure_count] >= @threshold)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def trip_open!(state)
|
|
129
|
+
state[:status] = :open
|
|
130
|
+
state[:opened_at] = Time.now
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Current persisted state, or a fresh :closed state when the circuit has no
|
|
134
|
+
# record yet. Callers mutate the returned Hash and persist via write_state.
|
|
135
|
+
def read_state
|
|
136
|
+
@storage.read(@name) || default_state
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def write_state(state)
|
|
140
|
+
@storage.write(@name, state)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def default_state
|
|
144
|
+
{
|
|
145
|
+
status: :closed,
|
|
146
|
+
failure_count: 0,
|
|
147
|
+
opened_at: nil,
|
|
148
|
+
last_failure_message: nil,
|
|
149
|
+
last_failure_class: nil
|
|
150
|
+
}
|
|
151
|
+
end
|
|
105
152
|
end
|
|
106
153
|
end
|
|
@@ -7,10 +7,12 @@ module ResilientCall
|
|
|
7
7
|
@mutex = Mutex.new
|
|
8
8
|
|
|
9
9
|
# Returns the circuit for `name`, creating it on first access. Created with
|
|
10
|
-
#
|
|
11
|
-
# configured
|
|
10
|
+
# default thresholds and the globally configured storage; the entry point
|
|
11
|
+
# injects the configured thresholds through Circuit#update_config.
|
|
12
12
|
def self.[](name)
|
|
13
|
-
@mutex.synchronize
|
|
13
|
+
@mutex.synchronize do
|
|
14
|
+
@registry[name] ||= Circuit.new(name, storage: ResilientCall.configuration.circuit_storage)
|
|
15
|
+
end
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def self.reset_all!
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "storage/memory"
|
|
4
|
+
|
|
3
5
|
module ResilientCall
|
|
4
6
|
# Holds the global defaults and the registry of named profiles.
|
|
5
7
|
class Configuration
|
|
6
8
|
# retry
|
|
7
9
|
attr_accessor :retries, :wait, :base_wait, :max_wait, :jitter, :on
|
|
10
|
+
|
|
8
11
|
# circuit breaker
|
|
9
12
|
attr_accessor :threshold, :reset_timeout
|
|
13
|
+
|
|
10
14
|
# callbacks
|
|
11
15
|
attr_accessor :on_retry, :on_failure, :on_success
|
|
16
|
+
# circuit state backend (Storage::Memory by default, Storage::Redis for
|
|
17
|
+
# multi-process setups); shared by every named circuit
|
|
18
|
+
attr_accessor :circuit_storage
|
|
12
19
|
# named profiles registry
|
|
13
20
|
attr_accessor :profiles
|
|
14
21
|
|
|
@@ -19,14 +26,14 @@ module ResilientCall
|
|
|
19
26
|
@max_wait = 30.0
|
|
20
27
|
@jitter = true
|
|
21
28
|
@on = [StandardError]
|
|
22
|
-
|
|
23
29
|
@threshold = 5
|
|
24
30
|
@reset_timeout = 60
|
|
25
|
-
|
|
26
31
|
@on_retry = nil
|
|
27
32
|
@on_failure = nil
|
|
28
33
|
@on_success = nil
|
|
29
34
|
|
|
35
|
+
@circuit_storage = Storage::Memory.new
|
|
36
|
+
|
|
30
37
|
@profiles = {}
|
|
31
38
|
end
|
|
32
39
|
|
|
@@ -30,44 +30,62 @@ module ResilientCall
|
|
|
30
30
|
@on_success = opts[:on_success]
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# Runs the block. Returns its result, or raises RetriesExhaustedError once
|
|
34
|
-
# every attempt has been consumed.
|
|
35
33
|
def call
|
|
36
34
|
attempt = 0
|
|
37
35
|
|
|
38
36
|
begin
|
|
39
37
|
attempt += 1
|
|
40
38
|
result = yield
|
|
41
|
-
|
|
39
|
+
on_success&.call(result, attempt)
|
|
42
40
|
result
|
|
43
|
-
rescue
|
|
44
|
-
|
|
45
|
-
@on_retry&.call(attempt, err)
|
|
46
|
-
sleep(wait_time(attempt))
|
|
47
|
-
retry
|
|
48
|
-
end
|
|
41
|
+
rescue *on => err
|
|
42
|
+
raise_exhausted(err, attempt) unless retryable?(attempt)
|
|
49
43
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
on_retry&.call(attempt, err)
|
|
45
|
+
sleep(wait_time(attempt))
|
|
46
|
+
retry
|
|
53
47
|
end
|
|
54
48
|
end
|
|
55
49
|
|
|
56
50
|
private
|
|
57
51
|
|
|
58
|
-
|
|
52
|
+
attr_reader :retries, :wait, :base_wait, :max_wait, :jitter,
|
|
53
|
+
:on, :on_retry, :on_failure, :on_success
|
|
54
|
+
|
|
55
|
+
# True while retries remain. `attempt` is the 1-based number that just ran.
|
|
56
|
+
def retryable?(attempt)
|
|
57
|
+
attempt <= retries
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def raise_exhausted(err, attempt)
|
|
61
|
+
on_failure&.call(err)
|
|
62
|
+
# Raised inside the rescue so Ruby populates #cause with `err`.
|
|
63
|
+
raise RetriesExhaustedError.new(attempts: attempt)
|
|
64
|
+
end
|
|
65
|
+
|
|
59
66
|
def wait_time(attempt)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
[with_jitter(base_delay(attempt)), max_wait].min
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def base_delay(attempt)
|
|
71
|
+
case wait
|
|
72
|
+
when :exponential
|
|
73
|
+
base_wait * (2**attempt)
|
|
74
|
+
when :linear
|
|
75
|
+
base_wait * attempt
|
|
76
|
+
when :fixed
|
|
77
|
+
base_wait
|
|
78
|
+
when Proc
|
|
79
|
+
wait.call(attempt)
|
|
80
|
+
else
|
|
81
|
+
raise ArgumentError, "unknown wait strategy: #{wait.inspect}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def with_jitter(delay)
|
|
86
|
+
return delay unless jitter
|
|
68
87
|
|
|
69
|
-
|
|
70
|
-
[raw, @max_wait].min
|
|
88
|
+
delay + rand(0..base_wait * 0.3)
|
|
71
89
|
end
|
|
72
90
|
end
|
|
73
91
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResilientCall
|
|
4
|
+
module Storage
|
|
5
|
+
# Contract every storage backend must implement. A storage persists the
|
|
6
|
+
# mutable state of each named circuit as a serializable Hash:
|
|
7
|
+
#
|
|
8
|
+
# { status:, failure_count:, opened_at:, last_failure_message:, last_failure_class: }
|
|
9
|
+
#
|
|
10
|
+
# Backends decide *where* that state lives (process memory, Redis, ...); the
|
|
11
|
+
# Circuit stays agnostic and only reads/writes through this interface.
|
|
12
|
+
class Base
|
|
13
|
+
def read(circuit_name)
|
|
14
|
+
raise NotImplementedError, "#{self.class}#read"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def write(circuit_name, state)
|
|
18
|
+
raise NotImplementedError, "#{self.class}#write"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset(circuit_name)
|
|
22
|
+
raise NotImplementedError, "#{self.class}#reset"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset_all
|
|
26
|
+
raise NotImplementedError, "#{self.class}#reset_all"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module ResilientCall
|
|
6
|
+
module Storage
|
|
7
|
+
# Default storage: keeps circuit state in a Hash in the current process.
|
|
8
|
+
# Thread-safe on its own operations via an internal Mutex. State is not
|
|
9
|
+
# shared across processes — use Storage::Redis for multi-worker setups.
|
|
10
|
+
class Memory < Base
|
|
11
|
+
def initialize
|
|
12
|
+
@data = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read(circuit_name)
|
|
17
|
+
@mutex.synchronize { @data[circuit_name] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write(circuit_name, state)
|
|
21
|
+
@mutex.synchronize { @data[circuit_name] = state }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reset(circuit_name)
|
|
25
|
+
@mutex.synchronize { @data.delete(circuit_name) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset_all
|
|
29
|
+
@mutex.synchronize { @data.clear }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module ResilientCall
|
|
7
|
+
module Storage
|
|
8
|
+
# Shares circuit state across processes through Redis. The Redis client is
|
|
9
|
+
# injected, so this class has no hard dependency on the `redis` gem itself —
|
|
10
|
+
# any object answering to get/set/del/keys works (including fakes in tests).
|
|
11
|
+
#
|
|
12
|
+
# Concurrency note: read + write are separate round-trips, so two processes
|
|
13
|
+
# can race on the failure counter. The worst case is an occasional missed
|
|
14
|
+
# increment, never state corruption. Atomic Lua updates are a v0.3 concern.
|
|
15
|
+
class Redis < Base
|
|
16
|
+
KEY_PREFIX = "resilient_call:circuit:"
|
|
17
|
+
|
|
18
|
+
def initialize(redis_client)
|
|
19
|
+
@redis = redis_client
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read(circuit_name)
|
|
23
|
+
raw = @redis.get(key_for(circuit_name))
|
|
24
|
+
|
|
25
|
+
raw ? deserialize(raw) : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(circuit_name, state)
|
|
29
|
+
@redis.set(key_for(circuit_name), serialize(state))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset(circuit_name)
|
|
33
|
+
@redis.del(key_for(circuit_name))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset_all
|
|
37
|
+
keys = @redis.keys("#{KEY_PREFIX}*")
|
|
38
|
+
@redis.del(*keys) unless keys.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def key_for(circuit_name)
|
|
44
|
+
"#{KEY_PREFIX}#{circuit_name}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def serialize(state)
|
|
48
|
+
JSON.generate(state.merge(opened_at: state[:opened_at]&.to_i))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def deserialize(raw)
|
|
52
|
+
parsed = JSON.parse(raw, symbolize_names: true)
|
|
53
|
+
parsed[:status] = parsed[:status].to_sym if parsed[:status]
|
|
54
|
+
parsed[:opened_at] = Time.at(parsed[:opened_at]) if parsed[:opened_at]
|
|
55
|
+
parsed
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/resilient_call.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "resilient_call/version"
|
|
4
4
|
require_relative "resilient_call/errors"
|
|
5
|
+
require_relative "resilient_call/storage/base"
|
|
6
|
+
require_relative "resilient_call/storage/memory"
|
|
5
7
|
require_relative "resilient_call/configuration"
|
|
6
8
|
require_relative "resilient_call/circuit"
|
|
7
9
|
require_relative "resilient_call/circuit_breaker"
|
|
@@ -9,6 +11,12 @@ require_relative "resilient_call/retrier"
|
|
|
9
11
|
require_relative "resilient_call/mixin"
|
|
10
12
|
|
|
11
13
|
module ResilientCall
|
|
14
|
+
# Redis storage is optional — loaded only when explicitly referenced, so the
|
|
15
|
+
# gem stays usable in plain Ruby without pulling in a Redis client.
|
|
16
|
+
module Storage
|
|
17
|
+
autoload :Redis, "resilient_call/storage/redis"
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
class << self
|
|
13
21
|
# Runs `block` with retry and, when `circuit:` is given, circuit-breaker
|
|
14
22
|
# protection. Option precedence: inline > profile > global config > defaults.
|
|
@@ -16,11 +24,7 @@ module ResilientCall
|
|
|
16
24
|
options = resolve_options(options)
|
|
17
25
|
circuit = circuit_for(options)
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
return options[:fallback].call if options[:fallback]
|
|
21
|
-
|
|
22
|
-
raise circuit_open_error(circuit, options)
|
|
23
|
-
end
|
|
27
|
+
return reject_open_circuit(circuit, options) if circuit_blocking?(circuit)
|
|
24
28
|
|
|
25
29
|
run_with_retry(options, circuit, &block)
|
|
26
30
|
end
|
|
@@ -67,6 +71,19 @@ module ResilientCall
|
|
|
67
71
|
end
|
|
68
72
|
end
|
|
69
73
|
|
|
74
|
+
# True when a circuit exists and is currently rejecting requests.
|
|
75
|
+
def circuit_blocking?(circuit)
|
|
76
|
+
circuit && !circuit.allow_request?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Handles a request blocked by an open circuit: runs the fallback if one
|
|
80
|
+
# was given, otherwise raises CircuitOpenError.
|
|
81
|
+
def reject_open_circuit(circuit, options)
|
|
82
|
+
return options[:fallback].call if options[:fallback]
|
|
83
|
+
|
|
84
|
+
raise circuit_open_error(circuit, options)
|
|
85
|
+
end
|
|
86
|
+
|
|
70
87
|
def circuit_open_error(circuit, options)
|
|
71
88
|
CircuitOpenError.new(
|
|
72
89
|
circuit_name: options[:circuit],
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: resilient_call
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Wesley Sena
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -38,6 +38,34 @@ dependencies:
|
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '13.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: redis
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '5.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '5.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: mock_redis
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0.30'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0.30'
|
|
41
69
|
description: Wrapper for any Ruby block with configurable retries, jitter, named circuit
|
|
42
70
|
breaker, declarative fallback, and service class mixin support.
|
|
43
71
|
email:
|
|
@@ -56,6 +84,9 @@ files:
|
|
|
56
84
|
- lib/resilient_call/errors.rb
|
|
57
85
|
- lib/resilient_call/mixin.rb
|
|
58
86
|
- lib/resilient_call/retrier.rb
|
|
87
|
+
- lib/resilient_call/storage/base.rb
|
|
88
|
+
- lib/resilient_call/storage/memory.rb
|
|
89
|
+
- lib/resilient_call/storage/redis.rb
|
|
59
90
|
- lib/resilient_call/version.rb
|
|
60
91
|
homepage: https://github.com/VorynLabs/resilient_call
|
|
61
92
|
licenses:
|