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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +100 -0
- data/LICENSE.txt +22 -0
- data/README.md +226 -0
- data/app/controllers/ruby_llm/resilience/breakers_controller.rb +49 -0
- data/app/views/layouts/ruby_llm/resilience/application.html.erb +60 -0
- data/app/views/ruby_llm/resilience/breakers/index.html.erb +150 -0
- data/config/routes.rb +9 -0
- data/lib/generators/resilience/install_generator.rb +40 -0
- data/lib/generators/resilience/templates/initializer.rb +66 -0
- data/lib/ruby_llm/resilience/breaker.rb +217 -0
- data/lib/ruby_llm/resilience/chain.rb +100 -0
- data/lib/ruby_llm/resilience/configuration.rb +146 -0
- data/lib/ruby_llm/resilience/engine.rb +40 -0
- data/lib/ruby_llm/resilience/errors.rb +29 -0
- data/lib/ruby_llm/resilience/memory_store.rb +90 -0
- data/lib/ruby_llm/resilience/shorthand.rb +10 -0
- data/lib/ruby_llm/resilience/tier_namer.rb +62 -0
- data/lib/ruby_llm/resilience/version.rb +7 -0
- data/lib/ruby_llm/resilience.rb +83 -0
- data/lib/ruby_llm-resilience.rb +3 -0
- metadata +122 -0
|
@@ -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
|