ruby_llm-resilience
RubyLLM gives you every provider. This gives you what happens when one of them goes down.
Circuit breakers and fallback chains for LLM apps — battle-tested behind 450,000+ production LLM calls.
# Guard any call
RubyLLM::Resilience.run("api:openai:embeddings") { RubyLLM.(text) }
# Fallback routing when a model's tier is struggling — fully configurable:
RubyLLM::Resilience.run_with_model_fallback("claude-haiku-4-5") do |model|
RubyLLM.chat(model: model).ask(prompt) # hops per your fallback_models map
end
# fallback: false → no routing, breaker only
# fallback: "gpt-5.5" → explicit per-call hop, ignoring the map
# fallback: ["a", "b"] → explicit multi-hop chain
# Custom cross-provider chain
RubyLLM::Resilience.run_with_fallback(
{ service: "api:openai:moderation", call: -> { RubyLLM.moderate(content) } },
{ service: "api:anthropic:haiku", call: -> { my_anthropic_moderator.call(content) } }
)
Zero runtime dependencies. Works with any cache store that speaks five methods. Fails open when your store blips — the breaker never becomes the outage.
Install
gem "ruby_llm-resilience"
rails g resilience:install # writes a fully-commented initializer
Outside Rails, or by hand:
# config/initializers/resilience.rb
RubyLLM::Resilience.configure do |c|
# REQUIRED for multi-process apps — the default MemoryStore is per-process.
c.cache_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
c.fallback_models = {
"claude-haiku-4-5" => "claude-sonnet-4-6",
"claude-sonnet-4-6" => "claude-opus-4-7",
"claude-opus-4-7" => "claude-sonnet-4-6",
"gemini-3.5-flash" => "claude-sonnet-4-6" # cross-provider safety net
}
c.on_error = ->(error, ctx) { Rails.error.report(error, handled: true, context: ctx) }
c.on_status = ->(service, state) {
Appsignal.set_gauge("circuit_breaker.state", state == :open ? 1 : 0, service: service)
}
end
How it works
State machine per service: CLOSED → OPEN → HALF_OPEN → CLOSED.
- Trip:
failure_thresholdconsecutive server-side failures (default 5) open the breaker forcooldown_seconds(default 120). - Probe: after cooldown, an atomic SETNX lock lets exactly one caller across all your processes probe the provider. Success closes; failure re-opens immediately.
- Chains: steps whose breaker is open are skipped, first success wins,
and if the chain exhausts with any step skipped-open you get
BreakerTripped— so you can distinguish "the providers are down" (fail closed, show the friendly banner) from "this request failed."
Design notes — what 450k production calls taught us
These are the decisions that differ from a textbook breaker, learned in production:
- 4xx never trips. Client errors are your bug, not their outage. Only rate limits, 5xx, overloads and transport timeouts count toward the threshold — and the failure count survives interleaved 4xx errors.
- Tier-hop fallbacks, one hop only. Provider capacity incidents are tier-correlated: when Sonnet is overloaded, every Sonnet version is. Same-tier version hops are theatre. Hopping tier (haiku→sonnet) puts you on a separate breaker and a separate capacity pool. And chains don't chase transitively — one deliberate escape per model.
- Tier-level breaker names.
api:anthropic:sonnet, notapi:anthropic:claude-sonnet-4-6— a version rollover shouldn't fragment your health state across two breakers. BreakerTrippedon exhaustion, not the last error. Callers must be able to fail closed on persistent provider failure while failing open on one-off errors.- Fail-open on store outage. If Redis is down, every breaker reports closed and records nothing. The breaker must never take the app down.
allow_request?vsopen?. The gate that consumes the half-open probe slot is separate from the pure reads — so your dashboard can pollstate/open?forever without stealing probes.ModelNotFoundErroradvances chains. New-model rollout windows leave registries briefly stale; degrade to the fallback model instead of surfacing "unknown model" to users.- Deterministic failover, not smart routing. OpenRouter-style routing
optimizes continuously on fleet-wide signal a single app doesn't have —
and prompts are model-specific, so silently serving a Sonnet-tuned prompt
on GPT is an unlogged quality regression, not an optimization. Here the
map is explicit, hops are emergencies, and every hop fires
on_fallbackso your telemetry sees exactly what routed where and why. (RubyLLM speaks OpenRouter as a provider, so the two compose if you want both.)
The five seams
Everything app-specific is injectable:
| Seam | Default | Wire it to |
|---|---|---|
cache_store |
in-process MemoryStore |
Redis (any object with read/write(expires_in:, unless_exist:)/increment(expires_in:)/delete/delete_multi) |
on_error |
no-op | Rails.error.report, Sentry, Honeybadger |
on_status |
no-op | AppSignal/Datadog gauge (fires on every success and every trip — gauge semantics) |
provider_resolver |
RubyLLM's model registry, rescued | your own model→provider logic |
service_namer |
TierNamer (per-provider tiers) |
->(model) { "api:#{model}" } for per-model breakers |
Not LLM-specific, by the way: we run the same breakers around OCR, Slack
webhooks, CAPTCHA verification and email-validation APIs. Configure custom
trippable_errors/fallback_errors and guard anything.
Per-service configuration
One cooldown doesn't fit all: a cheap fast-recovery moderation endpoint and an expensive batch endpoint shouldn't share settings.
RubyLLM::Resilience.configure do |c|
c.failure_threshold = 5 # global defaults...
c.cooldown_seconds = 120
c.services = { # ...with per-service overrides
"api:openai:moderation" => { failure_threshold: 2, cooldown_seconds: 30 },
"api:anthropic:batches" => { cooldown_seconds: 600 }
}
c. = { # app knowledge for the dashboard — config, not code
"api:anthropic:sonnet" => { description: "Coaching + generation", consumers: "Chat, Practice" }
}
end
Telemetry recipes
Three hooks, three different jobs — use all three:
| Hook | Fires | Wire it to | Use it for |
|---|---|---|---|
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. |
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. |
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. |
Rule of thumb: alert on the handled error, graph the gauge, trend the counter. The counter isn't an alternative to error reporting — a trip is one event, but a tripped 5-minute window can be thousands of hops; you want the former in your inbox and the latter on a chart.
The dashboard
An optional mountable engine ships with the gem (the core stays zero-dependency — the engine only loads when you ask for it under Rails):
# config/application.rb
require "ruby_llm/resilience/engine"
# config/routes.rb
mount RubyLLM::Resilience::Engine => "/resilience"
# config/initializers/resilience.rb — REQUIRED: the dashboard denies by
# default; every request 404s until you explicitly allow it:
RubyLLM::Resilience.configure do |c|
c.dashboard_auth = ->(controller) {
controller.head :not_found unless controller.current_user&.admin?
}
end
One page: live state pills per service, failures shown against their
effective threshold (1 / 5), probe countdowns, per-service cooldowns
(with override markers), fallback routes derived from your model map
(claude-haiku-4-5 → claude-sonnet-4-6), your service_metadata
descriptions, reset buttons, and a configuration panel showing your store,
defaults, and which telemetry hooks are wired vs still no-ops.
Auto-refreshes — safely, because all dashboard reads are pure: polling
never consumes half-open probe slots (that's why allow_request? and
open? are separate methods).
Prefer your own admin panel? The data API is public:
RubyLLM::Resilience::Breaker.dashboard_status
# => [{ service: "api:anthropic:sonnet", state: :closed, failure_count: 0,
# seconds_until_probe: nil, metadata: { description: "..." } }, ...]
RubyLLM::Resilience::Breaker.new("api:anthropic:sonnet").reset! # console escape hatch
Vs. circuitbox / stoplight / semian / breaker_machines / ruby_llm-agents
- semian intercepts at the driver level — superb for MySQL/Redis, structurally wrong for LLM SDK calls.
- circuitbox / stoplight are excellent generic single breakers. What they don't do is the part this gem exists for: skip-open fallback chains with fail-closed exhaustion semantics, model→tier naming, and an LLM-tuned error taxonomy.
- breaker_machines is a modern generic breaker with per-class fallbacks and a rich DSL — the closest generic cousin. It still isn't LLM-aware: no model→tier naming, no multi-step skip-open chains, no trippable-vs-fallback error split.
- ruby_llm-agents bundles a breaker inside a full agent framework; this is the extractable primitive with zero dependencies.
Why not build the chain layer on stoplight? Zero hard deps, a 5-method store contract, and a ~200-line breaker meant vendoring beat depending.
License
MIT.