ruby_llm-resilience 0.6.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.
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ruby_llm-resilience — circuit breakers and fallback chains for LLM calls.
4
+ # Docs: https://github.com/danielstpaul/ruby_llm-resilience
5
+ #
6
+ # Everything below is optional; the gem works with defaults out of the box
7
+ # (per-process memory store, threshold 5, cooldown 120s). The two settings
8
+ # most apps DO want are cache_store (multi-process correctness) and
9
+ # fallback_models (the actual routing).
10
+ RubyLLM::Resilience.configure do |config|
11
+ # --- Store -----------------------------------------------------------
12
+ # REQUIRED for multi-process apps: the default MemoryStore is per-process,
13
+ # so a breaker tripped in one worker stays closed in the others. Any store
14
+ # with read / write(expires_in:, unless_exist:) / increment(expires_in:) /
15
+ # delete / delete_multi works — Redis is the usual choice:
16
+ #
17
+ # config.cache_store = ActiveSupport::Cache::RedisCacheStore.new(
18
+ # url: ENV["REDIS_URL"], namespace: "circuit_breaker"
19
+ # )
20
+
21
+ # --- Breaker knobs ----------------------------------------------------
22
+ # config.failure_threshold = 5 # consecutive failures before trip
23
+ # config.cooldown_seconds = 120 # open duration before a probe
24
+ # config.failures_window_seconds = 3600 # failure-counter window
25
+ #
26
+ # Per-service overrides (a fast-recovery moderation endpoint shouldn't
27
+ # share a cooldown with an expensive batch endpoint):
28
+ # config.services = {
29
+ # "api:openai:moderation" => { failure_threshold: 2, cooldown_seconds: 30 }
30
+ # }
31
+
32
+ # --- Fallback routing -------------------------------------------------
33
+ # One deliberate tier-hop per model is the recommended shape. Values may
34
+ # be a single model or an array of hops.
35
+ # config.fallback_models = {
36
+ # "claude-haiku-4-5" => "claude-sonnet-4-6",
37
+ # "claude-sonnet-4-6" => "claude-opus-4-7",
38
+ # "gemini-3.5-flash" => "claude-sonnet-4-6" # cross-provider safety net
39
+ # }
40
+
41
+ # --- Telemetry (alert on the error, graph the gauge, trend the counter) —
42
+ # config.on_error = ->(error, context) {
43
+ # Rails.error.report(error, handled: true, context: context)
44
+ # }
45
+ # config.on_status = ->(service, state) {
46
+ # Appsignal.set_gauge("circuit_breaker.state", state == :open ? 1 : 0, service: service)
47
+ # }
48
+ # config.on_fallback = ->(from:, to:, error:) {
49
+ # Appsignal.increment_counter("llm.fallback", 1,
50
+ # from: from[:service], to: to[:service], error: error.class.name)
51
+ # }
52
+
53
+ # --- Dashboard (mount RubyLLM::Resilience::Engine in routes.rb) --------
54
+ # DENY-BY-DEFAULT: every dashboard request 404s until you configure this.
55
+ # config.dashboard_auth = ->(controller) {
56
+ # controller.head :not_found unless controller.respond_to?(:current_user) &&
57
+ # controller.current_user&.admin?
58
+ # }
59
+ #
60
+ # Show a static fleet (instead of only breakers seen since boot), with
61
+ # descriptions for the About column:
62
+ # config.dashboard_services = %w[api:anthropic:sonnet api:openai:moderation]
63
+ # config.service_metadata = {
64
+ # "api:anthropic:sonnet" => { description: "Coaching", consumers: "Chat" }
65
+ # }
66
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module RubyLLM
6
+ module Resilience
7
+ # Cache-backed circuit breaker for one service.
8
+ #
9
+ # State machine: CLOSED → OPEN → HALF_OPEN → CLOSED
10
+ #
11
+ # Three keys per service in the configured store:
12
+ # {service}:failures — consecutive-failure counter (windowed TTL)
13
+ # {service}:open_until — epoch float; presence means open/half-open
14
+ # {service}:probe_lock — SETNX lock so exactly one caller probes
15
+ #
16
+ # API purity contract (learned the hard way in production):
17
+ # allow_request? — the MUTATING gate. In half-open it CONSUMES the
18
+ # probe slot. Call it exactly once per real request.
19
+ # open?/closed?/state/failure_count/seconds_until_probe — PURE reads,
20
+ # safe for dashboards, logging, and health checks.
21
+ #
22
+ # Fail-open everywhere: if the store is unreachable, the breaker reports
23
+ # closed and records nothing. The breaker must never take the app down
24
+ # when Redis blips — the API call itself is the thing being protected.
25
+ class Breaker
26
+ attr_reader :service
27
+
28
+ # Constants (not class-ivars) so subclasses share one registry —
29
+ # apps may subclass Breaker to add their own service lists/aliases.
30
+ REGISTRY = Set.new
31
+ REGISTRY_MUTEX = Mutex.new
32
+ private_constant :REGISTRY, :REGISTRY_MUTEX
33
+
34
+ class << self
35
+ def register(service)
36
+ REGISTRY_MUTEX.synchronize { REGISTRY.add(service) }
37
+ end
38
+
39
+ # Per-process registry of breakers seen since boot. A cache-store
40
+ # contract can't enumerate keys, so this (plus an explicit list) is
41
+ # how dashboards discover services.
42
+ def known_services
43
+ REGISTRY_MUTEX.synchronize { REGISTRY.to_a.sort }
44
+ end
45
+
46
+ def dashboard_status(services: nil)
47
+ config = Resilience.config
48
+ (services || known_services).map do |service|
49
+ breaker = new(service)
50
+ settings = config.settings_for(service)
51
+ {
52
+ service: service,
53
+ state: breaker.state,
54
+ failure_count: breaker.failure_count,
55
+ seconds_until_probe: breaker.seconds_until_probe,
56
+ metadata: config.metadata_for(service),
57
+ failure_threshold: settings.failure_threshold,
58
+ cooldown_seconds: settings.cooldown_seconds,
59
+ overridden: config.services.key?(service)
60
+ }
61
+ end
62
+ end
63
+
64
+ def reset_registry!
65
+ REGISTRY_MUTEX.synchronize { REGISTRY.clear }
66
+ end
67
+ end
68
+
69
+ def initialize(service)
70
+ @service = service.to_s
71
+ self.class.register(@service)
72
+ end
73
+
74
+ # The mutating gate: true if this request may proceed. In half-open,
75
+ # acquires the atomic probe lock — exactly one caller across all
76
+ # processes gets true; everyone else is treated as open.
77
+ def allow_request?
78
+ case current_state
79
+ when :closed then true
80
+ when :open then false
81
+ when :half_open then acquire_probe_lock
82
+ end
83
+ end
84
+
85
+ # Pure: true only when fully open. Half-open reports false (a request
86
+ # MAY be allowed). Never consumes the probe slot — dashboard-safe.
87
+ def open?
88
+ current_state == :open
89
+ end
90
+
91
+ def closed?
92
+ !open?
93
+ end
94
+
95
+ def state
96
+ current_state
97
+ end
98
+
99
+ def failure_count
100
+ safely(0) { cache.read(failures_key) }.to_i
101
+ end
102
+
103
+ # Seconds until the breaker will allow a probe (nil if closed).
104
+ def seconds_until_probe
105
+ open_until = safely { cache.read(open_until_key) }
106
+ return nil unless open_until
107
+
108
+ remaining = open_until.to_f - Time.now.to_f
109
+ remaining.positive? ? remaining.ceil : 0
110
+ end
111
+
112
+ # Reset failure count and close the breaker. Fires on_status with
113
+ # :closed on EVERY success — gauge semantics (idempotent), matching
114
+ # the production original. It is not a once-per-transition event.
115
+ def record_success
116
+ safely { cache.delete_multi([ failures_key, open_until_key, probe_lock_key ]) }
117
+ notify_status(:closed)
118
+ end
119
+
120
+ # Increment the failure counter; trip at threshold. In half-open, a
121
+ # single probe failure re-opens immediately (force: the open_until key
122
+ # still exists in half-open and must be overwritten, not skipped).
123
+ def record_failure
124
+ if current_state == :half_open
125
+ trip!(force: true)
126
+ else
127
+ count = safely do
128
+ cache.increment(failures_key, 1, expires_in: settings.failures_window_seconds)
129
+ end
130
+ trip! if count && count >= settings.failure_threshold
131
+ end
132
+ end
133
+
134
+ # Force-close (admin/console use).
135
+ def reset!
136
+ record_success
137
+ end
138
+
139
+ private
140
+
141
+ def current_state
142
+ open_until = safely(:store_error) { cache.read(open_until_key) }
143
+ return :closed if open_until == :store_error # fail open
144
+ return :closed if open_until.nil?
145
+
146
+ Time.now.to_f < open_until.to_f ? :open : :half_open
147
+ end
148
+
149
+ # Trips are ATOMIC: from closed, the open_until write uses SETNX so
150
+ # racing threads that all crossed the threshold produce exactly one
151
+ # transition (and one set of callbacks). From half-open (force:), the
152
+ # key already exists and must be overwritten — no race is possible
153
+ # there because the probe lock admitted exactly one caller.
154
+ def trip!(force: false)
155
+ cooldown = settings.cooldown_seconds
156
+ open_until = Time.now.to_f + cooldown
157
+
158
+ transitioned = safely(false) do
159
+ if force
160
+ cache.write(open_until_key, open_until, expires_in: cooldown * 2)
161
+ true
162
+ else
163
+ cache.write(open_until_key, open_until, expires_in: cooldown * 2, unless_exist: true)
164
+ end
165
+ end
166
+ return unless transitioned
167
+
168
+ safely { cache.delete(failures_key) }
169
+ notify_error(
170
+ BreakerTripped.new("Circuit breaker tripped for #{@service}"),
171
+ { service: @service, cooldown_seconds: cooldown }
172
+ )
173
+ notify_status(:open)
174
+ end
175
+
176
+ # Atomic SETNX: exactly one fiber/thread/process becomes the probe.
177
+ # Lock TTL = cooldown, so a probe that dies without reporting frees
178
+ # the slot after one cooldown period.
179
+ def acquire_probe_lock
180
+ safely(false) do
181
+ cache.write(probe_lock_key, true, expires_in: settings.cooldown_seconds, unless_exist: true)
182
+ end
183
+ end
184
+
185
+ # Store operations never raise into the caller: report via on_error
186
+ # and return the fallback. The call being protected matters more than
187
+ # the bookkeeping around it.
188
+ def safely(fallback = nil)
189
+ yield
190
+ rescue StandardError => e
191
+ notify_error(e, { service: @service, phase: "circuit_breaker_store" })
192
+ fallback
193
+ end
194
+
195
+ # User callbacks must not be able to break the call path either.
196
+ def notify_status(state)
197
+ config.on_status.call(@service, state)
198
+ rescue StandardError
199
+ nil
200
+ end
201
+
202
+ def notify_error(error, context)
203
+ config.on_error.call(error, context)
204
+ rescue StandardError
205
+ nil
206
+ end
207
+
208
+ def failures_key = "#{@service}:failures"
209
+ def open_until_key = "#{@service}:open_until"
210
+ def probe_lock_key = "#{@service}:probe_lock"
211
+
212
+ def cache = config.cache_store
213
+ def config = Resilience.config
214
+ def settings = config.settings_for(@service)
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Resilience
5
+ # Orchestration: circuit-broken calls and fallback chains.
6
+ #
7
+ # The chain semantics are the part that isn't a textbook breaker:
8
+ # - steps whose breaker is open are SKIPPED, not attempted
9
+ # - the first success wins
10
+ # - if the chain is exhausted and ANY step was skipped-open, raise
11
+ # BreakerTripped (not the last real error) so callers can distinguish
12
+ # "the providers are down" (fail closed) from "your request failed"
13
+ module Chain
14
+ module_function
15
+
16
+ # Circuit-break a single call. Raises the original error on failure,
17
+ # or BreakerTripped if the circuit is open.
18
+ def run(service)
19
+ breaker = Breaker.new(service)
20
+ raise BreakerTripped, "Circuit open for #{service}" unless breaker.allow_request?
21
+
22
+ result = yield
23
+ breaker.record_success
24
+ result
25
+ rescue BreakerTripped
26
+ raise
27
+ rescue StandardError => error
28
+ breaker.record_failure if Resilience.trippable?(error)
29
+ raise
30
+ end
31
+
32
+ # Model fallback via the configured map (or an explicit override). The
33
+ # block receives the model to use (primary first; hops on trippable or
34
+ # fallback-class errors). Breaker names come from the service_namer.
35
+ #
36
+ # Resilience.run_with_model_fallback("claude-haiku-4-5") { |model|
37
+ # RubyLLM.chat(model: model).ask(prompt)
38
+ # }
39
+ #
40
+ # The fallback: keyword makes routing per-call configurable:
41
+ # fallback: :map — default: use config.fallback_models
42
+ # fallback: false / nil — no fallback; primary only
43
+ # fallback: "model-name" — explicit hop, ignoring the map
44
+ # fallback: [ "a", "b" ] — explicit multi-hop chain
45
+ def run_with_model_fallback(primary_model, fallback: :map, &block)
46
+ namer = Resilience.config.service_namer
47
+
48
+ hops =
49
+ case fallback
50
+ when :map then Resilience.config.fallbacks_for(primary_model)
51
+ when nil, false then []
52
+ else Array(fallback)
53
+ end
54
+
55
+ steps = [ primary_model, *hops ].map do |model|
56
+ { service: namer.call(model), model: model.to_s, call: -> { block.call(model) } }
57
+ end
58
+
59
+ run_with_fallback(*steps)
60
+ end
61
+
62
+ # Try steps in order; each is { service:, call: -> { ... } } (an
63
+ # optional :model key is carried through to on_fallback for context).
64
+ # Every advance past a failed or skipped step fires config.on_fallback
65
+ # with from:, to:, error: — the audit trail.
66
+ def run_with_fallback(*steps)
67
+ raise ArgumentError, "run_with_fallback requires at least one step" if steps.empty?
68
+
69
+ last_error = nil
70
+ any_breaker_tripped = false
71
+
72
+ steps.each_with_index do |step, index|
73
+ begin
74
+ return run(step[:service]) { step[:call].call }
75
+ rescue BreakerTripped => error
76
+ any_breaker_tripped = true
77
+ last_error = error
78
+ rescue *Resilience.config.resolved_fallback_errors => error
79
+ last_error = error
80
+ end
81
+
82
+ next_step = steps[index + 1]
83
+ notify_fallback(step, next_step, last_error) if next_step
84
+ end
85
+
86
+ raise BreakerTripped, last_error&.message if any_breaker_tripped
87
+ raise last_error
88
+ end
89
+
90
+ # Callback failures must never break the call path.
91
+ def notify_fallback(from, to, error)
92
+ Resilience.config.on_fallback.call(
93
+ from: from.except(:call), to: to.except(:call), error: error
94
+ )
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Resilience
5
+ # All five injection seams live here. Configure once at boot, then the
6
+ # configuration is frozen — concurrency safety comes from immutability,
7
+ # not locks. (Use RubyLLM::Resilience.reset_configuration! in tests.)
8
+ class Configuration
9
+ # Server-side unhealthiness — these TRIP the breaker. Client errors
10
+ # (4xx, auth, bad request) never do: those are your bug, not their
11
+ # outage. Resolved lazily so ruby_llm/faraday remain soft dependencies.
12
+ DEFAULT_TRIPPABLE_NAMES = %w[
13
+ RubyLLM::RateLimitError
14
+ RubyLLM::ServerError
15
+ RubyLLM::ServiceUnavailableError
16
+ RubyLLM::OverloadedError
17
+ Faraday::TimeoutError
18
+ Faraday::ConnectionFailed
19
+ Faraday::ServerError
20
+ ].freeze
21
+
22
+ # Errors that advance a fallback chain to its next step (without
23
+ # necessarily tripping). ModelNotFoundError is deliberate: it covers
24
+ # new-model rollout windows where a registry misses an ID. Programming
25
+ # bugs (NoMethodError, ArgumentError) are NOT here — those propagate so
26
+ # they surface in your error tracker instead of silently degrading.
27
+ DEFAULT_FALLBACK_NAMES = %w[
28
+ RubyLLM::Error
29
+ RubyLLM::ModelNotFoundError
30
+ Faraday::Error
31
+ ].freeze
32
+
33
+ attr_accessor :cache_store, :failure_threshold, :cooldown_seconds,
34
+ :failures_window_seconds, :fallback_models,
35
+ :on_error, :on_status, :on_fallback,
36
+ :provider_resolver, :service_namer,
37
+ :trippable_errors, :fallback_errors,
38
+ :services, :service_metadata, :dashboard_auth, :dashboard_services
39
+
40
+ # Normalized fallback hops for a model: always an Array (possibly empty).
41
+ def fallbacks_for(model_name)
42
+ Array(@fallback_models[model_name.to_s])
43
+ end
44
+
45
+ # Effective per-service settings (global defaults + per-service override).
46
+ Settings = Struct.new(:failure_threshold, :cooldown_seconds, :failures_window_seconds)
47
+
48
+ def initialize
49
+ @cache_store = MemoryStore.new
50
+ @failure_threshold = 5
51
+ @cooldown_seconds = 120
52
+ @failures_window_seconds = 3600
53
+ # Model → fallback(s). Values may be a single model or an array of
54
+ # hops, tried in order:
55
+ # c.fallback_models = {
56
+ # "claude-haiku-4-5" => "claude-sonnet-4-6", # one hop
57
+ # "gemini-3.5-flash" => ["claude-sonnet-4-6", "claude-opus-4-7"] # multi-hop
58
+ # }
59
+ # One deliberate hop is the recommended default (capacity incidents
60
+ # are tier-correlated; chains of desperation compound quality drift) —
61
+ # multi-hop is available, not encouraged.
62
+ @fallback_models = {}
63
+ @on_error = ->(_error, _context) {}
64
+ @on_status = ->(_service, _state) {}
65
+
66
+ # Fired every time a chain advances past a failed/skipped step —
67
+ # the audit trail that keeps fallback an emergency, not an ambient
68
+ # optimization:
69
+ # c.on_fallback = ->(from:, to:, error:) {
70
+ # Appsignal.increment_counter("llm.fallback", 1,
71
+ # from: from[:service], to: to[:service], error: error.class.name)
72
+ # }
73
+ @on_fallback = ->(from:, to:, error:) {}
74
+ @provider_resolver = default_provider_resolver
75
+ @service_namer = TierNamer
76
+ @trippable_errors = nil # nil => lazy defaults (see resolved_* below)
77
+ @fallback_errors = nil
78
+
79
+ # Per-service overrides for the three breaker knobs. A cheap
80
+ # fast-recovery moderation endpoint and an expensive batch endpoint
81
+ # shouldn't share one cooldown:
82
+ # c.services = { "api:openai:moderation" => { cooldown_seconds: 30 } }
83
+ @services = {}
84
+
85
+ # App knowledge for dashboards — descriptions belong in config, not
86
+ # hardcoded in a controller:
87
+ # c.service_metadata = { "api:anthropic:sonnet" =>
88
+ # { description: "Coaching + generation", consumers: "Chat, Practice" } }
89
+ @service_metadata = {}
90
+
91
+ # Default service list for the dashboard engine. nil = the
92
+ # per-process registry (services seen since boot). Apps with a known
93
+ # static fleet should set this so the dashboard is complete from the
94
+ # first request:
95
+ # c.dashboard_services = %w[api:anthropic:sonnet api:openai:gpt ...]
96
+ @dashboard_services = nil
97
+
98
+ # Auth hook for the mountable dashboard (see resilience/engine).
99
+ # DENY BY DEFAULT: mounting without configuring this renders 404 on
100
+ # every request. Override with e.g.
101
+ # c.dashboard_auth = ->(controller) {
102
+ # controller.head :not_found unless controller.current_user&.admin?
103
+ # }
104
+ @dashboard_auth = ->(controller) { controller.head :not_found }
105
+ end
106
+
107
+ def settings_for(service)
108
+ override = @services[service.to_s] || {}
109
+ Settings.new(
110
+ override[:failure_threshold] || failure_threshold,
111
+ override[:cooldown_seconds] || cooldown_seconds,
112
+ override[:failures_window_seconds] || failures_window_seconds
113
+ )
114
+ end
115
+
116
+ def metadata_for(service)
117
+ @service_metadata[service.to_s] || {}
118
+ end
119
+
120
+ def resolved_trippable_errors
121
+ @trippable_errors || ErrorResolution.resolve(DEFAULT_TRIPPABLE_NAMES)
122
+ end
123
+
124
+ def resolved_fallback_errors
125
+ @fallback_errors || ErrorResolution.resolve(DEFAULT_FALLBACK_NAMES)
126
+ end
127
+
128
+ private
129
+
130
+ # RubyLLM's Models#find RAISES ModelNotFoundError for unknown ids — and
131
+ # unknown ids occur precisely during new-model rollout windows. Rescue
132
+ # to "unknown" rather than letting the resolver take down the call.
133
+ def default_provider_resolver
134
+ lambda do |model_name|
135
+ return "unknown" unless defined?(::RubyLLM) && ::RubyLLM.respond_to?(:models)
136
+
137
+ begin
138
+ ::RubyLLM.models.find(model_name.to_s)&.provider.to_s
139
+ rescue StandardError
140
+ "unknown"
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional mountable dashboard. Deliberately NOT loaded by the default
4
+ # require — the core gem has zero dependencies and works outside Rails.
5
+ #
6
+ # # config/application.rb (or an initializer)
7
+ # require "ruby_llm/resilience/engine"
8
+ #
9
+ # # config/routes.rb
10
+ # mount RubyLLM::Resilience::Engine => "/resilience"
11
+ #
12
+ # SECURITY: the dashboard denies by default. Every request 404s until you
13
+ # configure an auth hook:
14
+ #
15
+ # RubyLLM::Resilience.configure do |c|
16
+ # c.dashboard_auth = ->(controller) {
17
+ # controller.head :not_found unless controller.respond_to?(:current_user) &&
18
+ # controller.current_user&.admin?
19
+ # }
20
+ # end
21
+ raise LoadError, "ruby_llm/resilience/engine requires Rails" unless defined?(::Rails::Engine)
22
+
23
+ require "ruby_llm/resilience"
24
+
25
+ module RubyLLM
26
+ module Resilience
27
+ class Engine < ::Rails::Engine
28
+ isolate_namespace RubyLLM::Resilience
29
+
30
+ # Zeitwerk camelizes "ruby_llm" to "RubyLlm" without this. Matches the
31
+ # acronym RubyLLM's own Rails integration docs tell users to register,
32
+ # so host apps that already have it are unaffected.
33
+ initializer "ruby_llm_resilience.inflections" do
34
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
35
+ inflect.acronym "LLM"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Resilience
5
+ # Raised when a call is blocked by an open circuit, or when a fallback
6
+ # chain is exhausted and at least one step was skipped because its
7
+ # breaker was open.
8
+ #
9
+ # Distinguishing this class from the underlying provider error lets
10
+ # callers fail CLOSED ("the provider is down, show the friendly banner")
11
+ # instead of treating it like a one-off request failure.
12
+ class BreakerTripped < StandardError; end
13
+
14
+ # Resolves error class names lazily so the gem has zero hard runtime
15
+ # dependencies: if ruby_llm or faraday isn't loaded, their error classes
16
+ # simply don't participate in the default lists.
17
+ module ErrorResolution
18
+ module_function
19
+
20
+ def resolve(names)
21
+ names.filter_map do |name|
22
+ Object.const_get(name)
23
+ rescue NameError
24
+ nil
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Resilience
5
+ # Thread-safe in-process store implementing the five-method cache
6
+ # contract the breaker needs:
7
+ #
8
+ # read(key)
9
+ # write(key, value, expires_in: nil, unless_exist: false) -> true/false
10
+ # increment(key, amount, expires_in: nil) -> Integer
11
+ # delete(key)
12
+ # delete_multi(keys)
13
+ #
14
+ # This is the DEFAULT store and it is per-process: two Puma workers each
15
+ # see their own breaker state. That's fine for development and small
16
+ # deployments, but multi-process production apps should configure a
17
+ # shared store (e.g. ActiveSupport::Cache::RedisCacheStore) so a breaker
18
+ # tripped in one process is open in all of them.
19
+ #
20
+ # TTL contract: `increment` applies `expires_in` only when it CREATES the
21
+ # counter; subsequent increments do not refresh the TTL. (This matches
22
+ # how the failure-counter window is meant to behave: N failures within
23
+ # the window of the first failure.)
24
+ class MemoryStore
25
+ Entry = Struct.new(:value, :expires_at)
26
+
27
+ def initialize
28
+ @data = {}
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ def read(key)
33
+ @mutex.synchronize { live_entry(key)&.value }
34
+ end
35
+
36
+ def write(key, value, expires_in: nil, unless_exist: false)
37
+ @mutex.synchronize do
38
+ return false if unless_exist && live_entry(key)
39
+
40
+ @data[key] = Entry.new(value, expires_in ? now + expires_in : nil)
41
+ true
42
+ end
43
+ end
44
+
45
+ def increment(key, amount = 1, expires_in: nil)
46
+ @mutex.synchronize do
47
+ entry = live_entry(key)
48
+ if entry
49
+ entry.value = entry.value.to_i + amount
50
+ else
51
+ entry = Entry.new(amount, expires_in ? now + expires_in : nil)
52
+ @data[key] = entry
53
+ end
54
+ entry.value
55
+ end
56
+ end
57
+
58
+ def delete(key)
59
+ @mutex.synchronize { !@data.delete(key).nil? }
60
+ end
61
+
62
+ def delete_multi(keys)
63
+ @mutex.synchronize { keys.count { |key| !@data.delete(key).nil? } }
64
+ end
65
+
66
+ def clear
67
+ @mutex.synchronize { @data.clear }
68
+ end
69
+
70
+ private
71
+
72
+ # Must be called while holding @mutex.
73
+ def live_entry(key)
74
+ entry = @data[key]
75
+ return nil unless entry
76
+
77
+ if entry.expires_at && now >= entry.expires_at
78
+ @data.delete(key)
79
+ nil
80
+ else
81
+ entry
82
+ end
83
+ end
84
+
85
+ def now
86
+ Time.now.to_f
87
+ end
88
+ end
89
+ end
90
+ end