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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b6710bbb609417fc8131a025b0a6064bac8bbd1e71d55e87b8a506d90709375
4
- data.tar.gz: 106f9defb53a23687ca85e7186e90cdf2732bf5cffeab86472be6521d2478b56
3
+ metadata.gz: 241b883a4916ae4e5847d47485005391f3ce1366fca15830d4cef1efe34c55ba
4
+ data.tar.gz: de5aa926f966d68cd92ca89a7e5fac3da22b2c4776fdc4c738f2a147dd6976dc
5
5
  SHA512:
6
- metadata.gz: 61edc4419731654f9b6c0d19f3cbae025766fb2048d6f17e0fad9d177c729ce8580dcdb4f5b5ea6f9eef25209a335fa97c15c5f400c4e95a8fa347435b4fd381
7
- data.tar.gz: a63fad56c64a7c4e93ba76837140dea471f09a2e0368fde195db068934155cc1f8020607d5bb20e0285a6a2ddeed1b4db67980ad0900445a3a925c5cbc4a13fc
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.1.0...HEAD
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
- | Version | Feature |
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` | `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 |
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
- # Thread-safe: every public method runs under an internal Mutex.
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
- @state = :closed
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
- @mutex.synchronize { @state }
31
+ read_field(:status)
27
32
  end
28
33
 
29
34
  def failure_count
30
- @mutex.synchronize { @failure_count }
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
- @mutex.synchronize { @opened_at }
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
- 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
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
- @mutex.synchronize do
63
- case @state
64
- when :half_open
65
- @state = :closed
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
- @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
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
- @state = :closed
91
- @failure_count = 0
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
- # the scope-3 defaults (threshold: 5, reset_timeout: 60); scope 4 will inject
11
- # configured values through the entry point.
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 { @registry[name] ||= Circuit.new(name) }
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
- @on_success&.call(result, attempt)
39
+ on_success&.call(result, attempt)
42
40
  result
43
- rescue *@on => err
44
- if attempt <= @retries
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
- @on_failure&.call(err)
51
- # Raised inside the rescue so Ruby populates #cause with `err`.
52
- raise RetriesExhaustedError.new(attempts: attempt)
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
- # 1-based attempt number. Applies jitter before capping at max_wait.
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
- 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
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
- raw += rand(0..@base_wait * 0.3) if @jitter
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ResilientCall
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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
- if circuit && !circuit.allow_request?
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.1.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-06-12 00:00:00.000000000 Z
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: