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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1006814fe7e55a8b61950aab8f70db4722183e6dedc28e9e00940ea408621af4
4
+ data.tar.gz: 07c2b081ae0277e41fc8aa90ea0545ccd3e467984639e045b103fc91099c0be4
5
+ SHA512:
6
+ metadata.gz: c932fdf3b844f0fef419a61ec71b47d6d9ee823c772fce83294b5d716c2423589c61d7b9e86c80c0f06f505ad30b19de57cf1774d5eab278be255bb42b471df3
7
+ data.tar.gz: fbc659c0e221d00511b19ed3e37b5117e2274629b4ae6d684371a9238337bbab1ba7c24358d13cad61e35ae2adc9e61c974b36587cc0e2243255e7dd4018c1de
data/CHANGELOG.md ADDED
@@ -0,0 +1,100 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.6.0] - 2026-07-14
6
+
7
+ Rails DX:
8
+
9
+ - `rails g resilience:install` — writes a fully-commented initializer
10
+ covering every configuration seam
11
+ - The dashboard engine now loads automatically under Rails (no Gemfile
12
+ `require:` needed; mounting stays opt-in). Non-Rails usage unchanged —
13
+ the core still has zero dependencies
14
+ - RubyGems Trusted Publishing release workflow (.github/workflows/release.yml)
15
+
16
+ ## [0.5.0] - 2026-07-14
17
+
18
+ - Dashboard table: click-to-sort columns (text/numeric aware) and a
19
+ service/state filter box — dependency-free vanilla JS, with sort/filter
20
+ state persisted in localStorage so it survives the 10s auto-refresh.
21
+
22
+ ## [0.4.0] - 2026-07-14
23
+
24
+ Dashboard fleshed out:
25
+
26
+ - **Fallback routes column** — model chains from `fallback_models`, grouped
27
+ by breaker service via the namer (`Resilience.fallback_routes` is public
28
+ API for custom dashboards)
29
+ - Failures shown against the effective per-service threshold (`1 / 5`);
30
+ cooldown column with per-service override markers
31
+ - **Configuration panel** — store, defaults, fallback-map size, and whether
32
+ each telemetry hook (`on_error`/`on_status`/`on_fallback`) is configured
33
+ or still a no-op
34
+ - `Breaker.dashboard_status` rows now include `failure_threshold`,
35
+ `cooldown_seconds`, `overridden`
36
+ - README: telemetry recipes (alert on the handled error, graph the gauge,
37
+ trend the counter)
38
+
39
+ ## [0.3.3] - 2026-07-14
40
+
41
+ - New `config.dashboard_services`: default service list for the engine
42
+ dashboard (nil = per-process registry). Apps with a known static fleet
43
+ get a complete dashboard from the first request. (Third
44
+ production-adoption catch.)
45
+
46
+ ## [0.3.2] - 2026-07-14
47
+
48
+ - Fix dashboard template lookup in apps using the ruby_llm gem: its Railtie
49
+ registers `acronym "RubyLLM"`, which changed the engine controller's
50
+ derived controller_path and broke view resolution. controller_path is now
51
+ pinned. (Second production-adoption catch.)
52
+
53
+ ## [0.3.1] - 2026-07-14
54
+
55
+ - Breaker registry now uses shared constants instead of class-ivars, making
56
+ Breaker safely subclassable (apps can add service lists/aliases in a
57
+ subclass). Found while dogfooding — the shim subclass crashed on v0.3.0.
58
+
59
+ ## [0.3.0] - 2026-07-13
60
+
61
+ Fallback routing is now fully user-configurable:
62
+
63
+ - **Multi-hop maps**: `fallback_models` values accept an array of hops
64
+ (`"gemini-3.5-flash" => ["claude-sonnet-4-6", "claude-opus-4-7"]`) as well
65
+ as a single model. One deliberate hop remains the documented default.
66
+ - **Per-call override**: `run_with_model_fallback(model, fallback: ...)` —
67
+ `:map` (default), `false`/`nil` (breaker only, no routing), a model
68
+ string, or an array chain. Expresses variant→control patterns in one call.
69
+ - **`on_fallback` callback**: fires on every chain advance with
70
+ `from:`, `to:` (service + model) and the triggering `error:` — including
71
+ skipped-open steps. Fallbacks are now observable, not silent.
72
+
73
+ ## [0.2.0] - 2026-07-13
74
+
75
+ - **Mountable dashboard engine** (`require "ruby_llm/resilience/engine"`,
76
+ `mount RubyLLM::Resilience::Engine => "/resilience"`): live state pills,
77
+ failure counts, probe countdowns, metadata, reset buttons, auto-refresh.
78
+ Deny-by-default auth via `config.dashboard_auth` — every request 404s
79
+ until explicitly allowed. Core remains zero-dependency; the engine loads
80
+ only under Rails.
81
+ - **Per-service overrides**: `config.services = { "api:x" =>
82
+ { failure_threshold:, cooldown_seconds:, failures_window_seconds: } }`.
83
+ - **Service metadata**: `config.service_metadata` — dashboard descriptions
84
+ as config, not hardcoded controllers; included in `dashboard_status`.
85
+
86
+ ## [0.1.0] - 2026-07-13
87
+
88
+ Initial release. Design proven across 450k+ production LLM calls.
89
+
90
+ - `RubyLLM::Resilience.run(service)` — circuit-broken single calls
91
+ - `run_with_model_fallback(model)` — one tier-hop via `fallback_models`
92
+ - `run_with_fallback(*steps)` — skip-open chains, `BreakerTripped` on
93
+ exhaustion when any breaker was open
94
+ - `Breaker` — CLOSED→OPEN→HALF_OPEN state machine, atomic single-probe
95
+ SETNX lock, atomic trip transition, fail-open on store outage
96
+ - `allow_request?` (mutating gate) split from pure `open?`/`state` reads
97
+ - Five injection seams: `cache_store`, `on_error`, `on_status`,
98
+ `provider_resolver`, `service_namer`
99
+ - Zero runtime dependencies; lazy resolution of RubyLLM/Faraday error
100
+ classes; built-in thread-safe `MemoryStore`
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2026 Daniel St Paul
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,226 @@
1
+ # ruby_llm-resilience
2
+
3
+ **RubyLLM gives you every provider. This gives you what happens when one of
4
+ them goes down.**
5
+
6
+ Circuit breakers and fallback chains for LLM apps — battle-tested behind
7
+ **450,000+ production LLM calls**.
8
+
9
+ ```ruby
10
+ # Guard any call
11
+ RubyLLM::Resilience.run("api:openai:embeddings") { RubyLLM.embed(text) }
12
+
13
+ # Fallback routing when a model's tier is struggling — fully configurable:
14
+ RubyLLM::Resilience.run_with_model_fallback("claude-haiku-4-5") do |model|
15
+ RubyLLM.chat(model: model).ask(prompt) # hops per your fallback_models map
16
+ end
17
+ # fallback: false → no routing, breaker only
18
+ # fallback: "gpt-5.5" → explicit per-call hop, ignoring the map
19
+ # fallback: ["a", "b"] → explicit multi-hop chain
20
+
21
+ # Custom cross-provider chain
22
+ RubyLLM::Resilience.run_with_fallback(
23
+ { service: "api:openai:moderation", call: -> { RubyLLM.moderate(content) } },
24
+ { service: "api:anthropic:haiku", call: -> { my_anthropic_moderator.call(content) } }
25
+ )
26
+ ```
27
+
28
+ Zero runtime dependencies. Works with any cache store that speaks five
29
+ methods. Fails open when your store blips — the breaker never becomes the
30
+ outage.
31
+
32
+ ## Install
33
+
34
+ ```ruby
35
+ gem "ruby_llm-resilience"
36
+ ```
37
+
38
+ ```bash
39
+ rails g resilience:install # writes a fully-commented initializer
40
+ ```
41
+
42
+ Outside Rails, or by hand:
43
+
44
+ ```ruby
45
+ # config/initializers/resilience.rb
46
+ RubyLLM::Resilience.configure do |c|
47
+ # REQUIRED for multi-process apps — the default MemoryStore is per-process.
48
+ c.cache_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
49
+
50
+ c.fallback_models = {
51
+ "claude-haiku-4-5" => "claude-sonnet-4-6",
52
+ "claude-sonnet-4-6" => "claude-opus-4-7",
53
+ "claude-opus-4-7" => "claude-sonnet-4-6",
54
+ "gemini-3.5-flash" => "claude-sonnet-4-6" # cross-provider safety net
55
+ }
56
+
57
+ c.on_error = ->(error, ctx) { Rails.error.report(error, handled: true, context: ctx) }
58
+ c.on_status = ->(service, state) {
59
+ Appsignal.set_gauge("circuit_breaker.state", state == :open ? 1 : 0, service: service)
60
+ }
61
+ end
62
+ ```
63
+
64
+ ## How it works
65
+
66
+ State machine per service: `CLOSED → OPEN → HALF_OPEN → CLOSED`.
67
+
68
+ - **Trip**: `failure_threshold` consecutive server-side failures (default 5)
69
+ open the breaker for `cooldown_seconds` (default 120).
70
+ - **Probe**: after cooldown, an atomic SETNX lock lets **exactly one** caller
71
+ across all your processes probe the provider. Success closes; failure
72
+ re-opens immediately.
73
+ - **Chains**: steps whose breaker is open are *skipped*, first success wins,
74
+ and if the chain exhausts with any step skipped-open you get
75
+ `BreakerTripped` — so you can distinguish "the providers are down" (fail
76
+ closed, show the friendly banner) from "this request failed."
77
+
78
+ ## Design notes — what 450k production calls taught us
79
+
80
+ These are the decisions that differ from a textbook breaker, learned in
81
+ production:
82
+
83
+ - **4xx never trips.** Client errors are your bug, not their outage. Only
84
+ rate limits, 5xx, overloads and transport timeouts count toward the
85
+ threshold — and the failure count *survives* interleaved 4xx errors.
86
+ - **Tier-hop fallbacks, one hop only.** Provider capacity incidents are
87
+ tier-correlated: when Sonnet is overloaded, every Sonnet version is.
88
+ Same-tier version hops are theatre. Hopping tier (haiku→sonnet) puts you
89
+ on a separate breaker and a separate capacity pool. And chains don't
90
+ chase transitively — one deliberate escape per model.
91
+ - **Tier-level breaker names.** `api:anthropic:sonnet`, not
92
+ `api:anthropic:claude-sonnet-4-6` — a version rollover shouldn't fragment
93
+ your health state across two breakers.
94
+ - **`BreakerTripped` on exhaustion, not the last error.** Callers must be
95
+ able to fail *closed* on persistent provider failure while failing *open*
96
+ on one-off errors.
97
+ - **Fail-open on store outage.** If Redis is down, every breaker reports
98
+ closed and records nothing. The breaker must never take the app down.
99
+ - **`allow_request?` vs `open?`.** The gate that consumes the half-open
100
+ probe slot is separate from the pure reads — so your dashboard can poll
101
+ `state`/`open?` forever without stealing probes.
102
+ - **`ModelNotFoundError` advances chains.** New-model rollout windows leave
103
+ registries briefly stale; degrade to the fallback model instead of
104
+ surfacing "unknown model" to users.
105
+ - **Deterministic failover, not smart routing.** OpenRouter-style routing
106
+ optimizes continuously on fleet-wide signal a single app doesn't have —
107
+ and prompts are model-specific, so silently serving a Sonnet-tuned prompt
108
+ on GPT is an unlogged quality regression, not an optimization. Here the
109
+ map is explicit, hops are emergencies, and every hop fires `on_fallback`
110
+ so your telemetry sees exactly what routed where and why. (RubyLLM speaks
111
+ OpenRouter as a provider, so the two compose if you want both.)
112
+
113
+ ## The five seams
114
+
115
+ Everything app-specific is injectable:
116
+
117
+ | Seam | Default | Wire it to |
118
+ |---|---|---|
119
+ | `cache_store` | in-process `MemoryStore` | Redis (any object with `read`/`write(expires_in:, unless_exist:)`/`increment(expires_in:)`/`delete`/`delete_multi`) |
120
+ | `on_error` | no-op | `Rails.error.report`, Sentry, Honeybadger |
121
+ | `on_status` | no-op | AppSignal/Datadog gauge (fires on every success and every trip — gauge semantics) |
122
+ | `provider_resolver` | RubyLLM's model registry, rescued | your own model→provider logic |
123
+ | `service_namer` | `TierNamer` (per-provider tiers) | `->(model) { "api:#{model}" }` for per-model breakers |
124
+
125
+ Not LLM-specific, by the way: we run the same breakers around OCR, Slack
126
+ webhooks, CAPTCHA verification and email-validation APIs. Configure custom
127
+ `trippable_errors`/`fallback_errors` and guard anything.
128
+
129
+ ## Per-service configuration
130
+
131
+ One cooldown doesn't fit all: a cheap fast-recovery moderation endpoint and
132
+ an expensive batch endpoint shouldn't share settings.
133
+
134
+ ```ruby
135
+ RubyLLM::Resilience.configure do |c|
136
+ c.failure_threshold = 5 # global defaults...
137
+ c.cooldown_seconds = 120
138
+
139
+ c.services = { # ...with per-service overrides
140
+ "api:openai:moderation" => { failure_threshold: 2, cooldown_seconds: 30 },
141
+ "api:anthropic:batches" => { cooldown_seconds: 600 }
142
+ }
143
+
144
+ c.service_metadata = { # app knowledge for the dashboard — config, not code
145
+ "api:anthropic:sonnet" => { description: "Coaching + generation", consumers: "Chat, Practice" }
146
+ }
147
+ end
148
+ ```
149
+
150
+ ## Telemetry recipes
151
+
152
+ Three hooks, three different jobs — use all three:
153
+
154
+ | Hook | Fires | Wire it to | Use it for |
155
+ |---|---|---|---|
156
+ | `on_error` | every **trip** (with the `BreakerTripped` exception) and store outages | `Rails.error.report(error, handled: true, context: ctx)` → AppSignal/Sentry as a **handled error** | Alerting. Trips are rare and high-signal; handled errors group by class and dedupe. |
157
+ | `on_status` | every success (`:closed`, gauge-idempotent) and every trip (`:open`) | `Appsignal.set_gauge("circuit_breaker.state", state == :open ? 1 : 0, service:)` | State-over-time graphs; alert on a gauge stuck at 1. |
158
+ | `on_fallback` | every **hop** a chain takes past a failed/skipped step | `Appsignal.increment_counter("llm.fallback", 1, from:, to:, error:)` | Volume/cost trends — how often you're degrading to a pricier model. Not an alert; a dashboard line. |
159
+
160
+ Rule of thumb: **alert on the handled error, graph the gauge, trend the
161
+ counter.** The counter isn't an alternative to error reporting — a trip is
162
+ one event, but a tripped 5-minute window can be thousands of hops; you want
163
+ the former in your inbox and the latter on a chart.
164
+
165
+ ## The dashboard
166
+
167
+ An optional mountable engine ships with the gem (the core stays
168
+ zero-dependency — the engine only loads when you ask for it under Rails):
169
+
170
+ ```ruby
171
+ # config/application.rb
172
+ require "ruby_llm/resilience/engine"
173
+
174
+ # config/routes.rb
175
+ mount RubyLLM::Resilience::Engine => "/resilience"
176
+
177
+ # config/initializers/resilience.rb — REQUIRED: the dashboard denies by
178
+ # default; every request 404s until you explicitly allow it:
179
+ RubyLLM::Resilience.configure do |c|
180
+ c.dashboard_auth = ->(controller) {
181
+ controller.head :not_found unless controller.current_user&.admin?
182
+ }
183
+ end
184
+ ```
185
+
186
+ One page: live state pills per service, failures shown against their
187
+ *effective* threshold (`1 / 5`), probe countdowns, per-service cooldowns
188
+ (with override markers), **fallback routes** derived from your model map
189
+ (`claude-haiku-4-5 → claude-sonnet-4-6`), your `service_metadata`
190
+ descriptions, reset buttons, and a configuration panel showing your store,
191
+ defaults, and which telemetry hooks are wired vs still no-ops.
192
+ Auto-refreshes — safely, because **all dashboard reads are pure**: polling
193
+ never consumes half-open probe slots (that's why `allow_request?` and
194
+ `open?` are separate methods).
195
+
196
+ Prefer your own admin panel? The data API is public:
197
+
198
+ ```ruby
199
+ RubyLLM::Resilience::Breaker.dashboard_status
200
+ # => [{ service: "api:anthropic:sonnet", state: :closed, failure_count: 0,
201
+ # seconds_until_probe: nil, metadata: { description: "..." } }, ...]
202
+
203
+ RubyLLM::Resilience::Breaker.new("api:anthropic:sonnet").reset! # console escape hatch
204
+ ```
205
+
206
+ ## Vs. circuitbox / stoplight / semian / breaker_machines / ruby_llm-agents
207
+
208
+ - **[semian](https://github.com/Shopify/semian)** intercepts at the driver
209
+ level — superb for MySQL/Redis, structurally wrong for LLM SDK calls.
210
+ - **circuitbox / stoplight** are excellent *generic single breakers*. What
211
+ they don't do is the part this gem exists for: skip-open **fallback
212
+ chains** with fail-closed exhaustion semantics, model→tier naming, and an
213
+ LLM-tuned error taxonomy.
214
+ - **[breaker_machines](https://github.com/seuros/breaker_machines)** is a
215
+ modern generic breaker with per-class fallbacks and a rich DSL — the
216
+ closest generic cousin. It still isn't LLM-aware: no model→tier naming,
217
+ no multi-step skip-open chains, no trippable-vs-fallback error split.
218
+ - **ruby_llm-agents** bundles a breaker inside a full agent framework; this
219
+ is the extractable primitive with zero dependencies.
220
+
221
+ Why not build the chain layer on stoplight? Zero hard deps, a 5-method
222
+ store contract, and a ~200-line breaker meant vendoring beat depending.
223
+
224
+ ## License
225
+
226
+ MIT.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Resilience
5
+ class BreakersController < ActionController::Base
6
+ # ruby_llm's Railtie registers `acronym "RubyLLM"`, which changes this
7
+ # controller's DERIVED controller_path to "rubyllm/resilience/breakers"
8
+ # in host apps — breaking template lookup (views ship under
9
+ # app/views/ruby_llm/...). Pin the path so lookup is deterministic
10
+ # regardless of the host's inflections.
11
+ def self.controller_path
12
+ "ruby_llm/resilience/breakers"
13
+ end
14
+
15
+ protect_from_forgery with: :exception
16
+ layout "ruby_llm/resilience/application"
17
+
18
+ # Deny-by-default: the configured hook must explicitly allow the
19
+ # request (by not rendering). The default hook 404s everything.
20
+ before_action :authenticate_dashboard!
21
+
22
+ def index
23
+ services = params[:services].presence&.split(",") ||
24
+ Resilience.config.dashboard_services
25
+ @statuses = Breaker.dashboard_status(services: services)
26
+ @fallback_routes = Resilience.fallback_routes
27
+ @config = Resilience.config
28
+ end
29
+
30
+ # "configured" when the app replaced the default no-op lambda (defaults
31
+ # are all defined in configuration.rb — source_location tells them apart).
32
+ helper_method def hook_status(hook)
33
+ file = hook.respond_to?(:source_location) ? hook.source_location&.first : nil
34
+ file&.end_with?("configuration.rb") ? "default" : "configured"
35
+ end
36
+
37
+ def reset
38
+ Breaker.new(params[:id]).reset!
39
+ redirect_to root_path, notice: "Reset #{params[:id]}"
40
+ end
41
+
42
+ private
43
+
44
+ def authenticate_dashboard!
45
+ Resilience.config.dashboard_auth.call(self)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,60 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Circuit Breakers</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= yield :head %>
7
+ <style>
8
+ :root { --bg:#fff; --ink:#1a2330; --muted:#6b7686; --line:#e5e9f0;
9
+ --ok:#16803c; --ok-bg:#e8f6ed; --warn:#92610c; --warn-bg:#fdf3df;
10
+ --bad:#b42318; --bad-bg:#fdeceb; }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root { --bg:#0e1116; --ink:#e7ecf3; --muted:#8b96a5; --line:#232a33;
13
+ --ok-bg:#10301c; --warn-bg:#33270e; --bad-bg:#3a1512; }
14
+ }
15
+ body { margin:0; padding:2rem; background:var(--bg); color:var(--ink);
16
+ font:14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
17
+ h1 { font-size:18px; margin:0 0 4px; }
18
+ .sub { color:var(--muted); font-size:12px; margin:0 0 20px; }
19
+ table { border-collapse:collapse; width:100%; max-width:960px; }
20
+ th { text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:.06em;
21
+ color:var(--muted); padding:8px 12px; border-bottom:1px solid var(--line); }
22
+ td { padding:9px 12px; border-bottom:1px solid var(--line); vertical-align:top; }
23
+ code { font-size:13px; }
24
+ .pill { display:inline-block; padding:2px 9px; border-radius:999px; font-size:12px; font-weight:600; }
25
+ .pill.closed { color:var(--ok); background:var(--ok-bg); }
26
+ .pill.half_open { color:var(--warn); background:var(--warn-bg); }
27
+ .pill.open { color:var(--bad); background:var(--bad-bg); }
28
+ .meta { color:var(--muted); font-size:12px; }
29
+ .notice { padding:8px 12px; margin:0 0 16px; background:var(--ok-bg); color:var(--ok);
30
+ border-radius:6px; max-width:960px; font-size:13px; }
31
+ form { display:inline; }
32
+ button { font:inherit; font-size:12px; padding:3px 10px; border:1px solid var(--line);
33
+ border-radius:6px; background:transparent; color:var(--ink); cursor:pointer; }
34
+ button:hover { border-color:var(--muted); }
35
+ .empty { color:var(--muted); padding:24px 12px; }
36
+ .routes div { margin:1px 0; }
37
+ .routes code { font-size:12px; }
38
+ .panel { margin-top:28px; max-width:960px; }
39
+ .panel h2 { font-size:13px; text-transform:uppercase; letter-spacing:.06em;
40
+ color:var(--muted); margin:0 0 8px; }
41
+ table.config th { width:140px; font-size:12px; text-transform:none;
42
+ letter-spacing:0; vertical-align:top; }
43
+ .hook { display:inline-block; padding:1px 7px; border-radius:999px; font-size:11px; }
44
+ .hook.configured { color:var(--ok); background:var(--ok-bg); }
45
+ .hook.default { color:var(--muted); background:var(--line); }
46
+ #breaker-filter { display:block; margin:0 0 12px; padding:6px 10px; width:280px;
47
+ font:inherit; font-size:13px; color:var(--ink); background:var(--bg);
48
+ border:1px solid var(--line); border-radius:6px; }
49
+ #breaker-filter:focus { outline:none; border-color:var(--muted); }
50
+ th[data-sort] { cursor:pointer; user-select:none; }
51
+ th[data-sort]:hover { color:var(--ink); }
52
+ th.sorted-asc::after { content:" ▲"; font-size:9px; }
53
+ th.sorted-desc::after { content:" ▼"; font-size:9px; }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <% if notice.present? %><div class="notice"><%= notice %></div><% end %>
58
+ <%= yield %>
59
+ </body>
60
+ </html>
@@ -0,0 +1,150 @@
1
+ <% content_for :head do %>
2
+ <meta http-equiv="refresh" content="10">
3
+ <% end %>
4
+
5
+ <h1>Circuit Breakers</h1>
6
+ <p class="sub">
7
+ All reads on this page are pure — polling never consumes probe slots.
8
+ Auto-refreshes every 10s.
9
+ </p>
10
+
11
+ <% if @statuses.empty? %>
12
+ <p class="empty">
13
+ No breakers registered yet in this process. Breakers appear here after
14
+ their first use, configure <code>dashboard_services</code> for a static
15
+ fleet, or pass an explicit list with <code>?services=api:a,api:b</code>.
16
+ </p>
17
+ <% else %>
18
+ <input id="breaker-filter" type="search" placeholder="Filter by service or state…"
19
+ autocomplete="off" aria-label="Filter breakers">
20
+ <table id="breakers" data-sortable>
21
+ <thead>
22
+ <tr>
23
+ <th data-sort="text">Service</th><th data-sort="text">State</th>
24
+ <th data-sort="num">Failures</th><th data-sort="num">Probe in</th>
25
+ <th data-sort="num">Cooldown</th><th>Fallback routes</th><th>About</th><th></th>
26
+ </tr>
27
+ </thead>
28
+ <tbody>
29
+ <% @statuses.each do |status| %>
30
+ <tr data-filter="<%= status[:service] %> <%= status[:state] %>">
31
+ <td><code><%= status[:service] %></code></td>
32
+ <td data-value="<%= status[:state] %>"><span class="pill <%= status[:state] %>"><%= status[:state].to_s.tr("_", "-") %></span></td>
33
+ <td data-value="<%= status[:failure_count] %>">
34
+ <%= status[:failure_count] %> / <%= status[:failure_threshold] %>
35
+ </td>
36
+ <td data-value="<%= status[:seconds_until_probe] || -1 %>"><%= status[:seconds_until_probe] ? "#{status[:seconds_until_probe]}s" : "—" %></td>
37
+ <td data-value="<%= status[:cooldown_seconds] %>">
38
+ <%= status[:cooldown_seconds] %>s<% if status[:overridden] %> <span class="meta" title="Per-service override via config.services">•</span><% end %>
39
+ </td>
40
+ <td class="routes">
41
+ <% routes = @fallback_routes[status[:service]] %>
42
+ <% if routes.present? %>
43
+ <% routes.each do |route| %>
44
+ <div><code><%= route %></code></div>
45
+ <% end %>
46
+ <% else %>
47
+ <span class="meta">— none configured</span>
48
+ <% end %>
49
+ </td>
50
+ <td class="meta">
51
+ <% if status[:metadata].any? %>
52
+ <%= status[:metadata].map { |k, v| "#{k}: #{v}" }.join(" · ") %>
53
+ <% end %>
54
+ </td>
55
+ <td>
56
+ <%= button_to "Reset", reset_breaker_path(id: status[:service]),
57
+ method: :post,
58
+ data: { turbo_confirm: "Force-close #{status[:service]}?" } %>
59
+ </td>
60
+ </tr>
61
+ <% end %>
62
+ </tbody>
63
+ </table>
64
+
65
+ <script>
66
+ // Sort + filter, no dependencies. State persists in localStorage so it
67
+ // survives the 10s auto-refresh.
68
+ (function () {
69
+ var table = document.getElementById("breakers");
70
+ var filter = document.getElementById("breaker-filter");
71
+ if (!table || !filter) return;
72
+ var KEY = "rlr-dashboard-v1";
73
+ var state = {};
74
+ try { state = JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) {}
75
+
76
+ function rows() { return Array.prototype.slice.call(table.tBodies[0].rows); }
77
+
78
+ function applyFilter() {
79
+ var q = filter.value.trim().toLowerCase();
80
+ rows().forEach(function (row) {
81
+ var hay = (row.getAttribute("data-filter") || "").toLowerCase();
82
+ row.style.display = !q || hay.indexOf(q) !== -1 ? "" : "none";
83
+ });
84
+ }
85
+
86
+ function cellValue(row, idx, kind) {
87
+ var cell = row.cells[idx];
88
+ var v = cell.getAttribute("data-value");
89
+ if (v === null) v = cell.textContent.trim();
90
+ return kind === "num" ? parseFloat(v) : v.toLowerCase();
91
+ }
92
+
93
+ function applySort() {
94
+ if (state.col == null) return;
95
+ var ths = table.tHead.rows[0].cells;
96
+ var kind = ths[state.col] && ths[state.col].getAttribute("data-sort");
97
+ if (!kind) return;
98
+ var dir = state.dir === "desc" ? -1 : 1;
99
+ rows().sort(function (a, b) {
100
+ var av = cellValue(a, state.col, kind), bv = cellValue(b, state.col, kind);
101
+ return av < bv ? -dir : av > bv ? dir : 0;
102
+ }).forEach(function (row) { table.tBodies[0].appendChild(row); });
103
+ Array.prototype.forEach.call(ths, function (th, i) {
104
+ th.classList.toggle("sorted-asc", i === state.col && dir === 1);
105
+ th.classList.toggle("sorted-desc", i === state.col && dir === -1);
106
+ });
107
+ }
108
+
109
+ function save() { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {} }
110
+
111
+ Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th, i) {
112
+ if (!th.getAttribute("data-sort")) return;
113
+ th.addEventListener("click", function () {
114
+ state.dir = state.col === i && state.dir !== "desc" ? "desc" : "asc";
115
+ state.col = i;
116
+ save(); applySort();
117
+ });
118
+ });
119
+ filter.addEventListener("input", function () {
120
+ state.q = filter.value;
121
+ save(); applyFilter();
122
+ });
123
+
124
+ if (state.q) filter.value = state.q;
125
+ applyFilter(); applySort();
126
+ })();
127
+ </script>
128
+ <% end %>
129
+
130
+ <div class="panel">
131
+ <h2>Configuration</h2>
132
+ <table class="config">
133
+ <tr><th>Store</th><td><code><%= @config.cache_store.class.name %></code></td></tr>
134
+ <tr><th>Defaults</th><td><%= @config.failure_threshold %> failures / <%= @config.cooldown_seconds %>s cooldown / <%= @config.failures_window_seconds %>s window</td></tr>
135
+ <tr>
136
+ <th>Telemetry hooks</th>
137
+ <td>
138
+ <code>on_error</code> <span class="hook <%= hook_status(@config.on_error) %>"><%= hook_status(@config.on_error) %></span> ·
139
+ <code>on_status</code> <span class="hook <%= hook_status(@config.on_status) %>"><%= hook_status(@config.on_status) %></span> ·
140
+ <code>on_fallback</code> <span class="hook <%= hook_status(@config.on_fallback) %>"><%= hook_status(@config.on_fallback) %></span>
141
+ </td>
142
+ </tr>
143
+ <tr><th>Fallback map</th><td><%= @config.fallback_models.size %> model route<%= "s" unless @config.fallback_models.size == 1 %> · <%= @config.services.size %> per-service override<%= "s" unless @config.services.size == 1 %></td></tr>
144
+ </table>
145
+ <p class="meta">
146
+ Trips fire <code>on_error</code> (wire to your error tracker as a handled
147
+ error), state changes fire the <code>on_status</code> gauge, fallback hops
148
+ fire <code>on_fallback</code> — see the README's telemetry recipes.
149
+ </p>
150
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ RubyLLM::Resilience::Engine.routes.draw do
4
+ root to: "breakers#index"
5
+ # Service names contain colons ("api:anthropic:sonnet") — allow anything
6
+ # but a slash in the segment.
7
+ post "breakers/:id/reset", to: "breakers#reset", as: :reset_breaker,
8
+ constraints: { id: %r{[^/]+} }
9
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RubyLLM
6
+ module Resilience
7
+ # rails g resilience:install
8
+ #
9
+ # Writes a fully-commented initializer covering every configuration seam.
10
+ # (Namespaced inside RubyLLM::Resilience — the top-level ::Resilience
11
+ # constant is reserved for the opt-in shorthand alias.)
12
+ class InstallGenerator < Rails::Generators::Base
13
+ namespace "resilience:install"
14
+ source_root File.expand_path("templates", __dir__)
15
+
16
+ desc "Creates config/initializers/resilience.rb with a commented example configuration"
17
+
18
+ def create_initializer
19
+ template "initializer.rb", "config/initializers/resilience.rb"
20
+ end
21
+
22
+ def show_next_steps
23
+ say <<~NEXT, :green
24
+
25
+ ruby_llm-resilience installed.
26
+
27
+ Next steps:
28
+ 1. Review config/initializers/resilience.rb — the defaults work,
29
+ but multi-process apps should configure a shared cache_store.
30
+ 2. Optional dashboard: mount it behind YOUR auth in config/routes.rb:
31
+ mount RubyLLM::Resilience::Engine => "/resilience"
32
+ (it 404s everything until dashboard_auth is configured — see the
33
+ initializer.)
34
+ 3. Guard a call:
35
+ RubyLLM::Resilience.run("api:openai:embeddings") { RubyLLM.embed(text) }
36
+ NEXT
37
+ end
38
+ end
39
+ end
40
+ end