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
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
|