Module: RubyLLM::Resilience::Chain

Defined in:
lib/ruby_llm/resilience/chain.rb

Overview

Orchestration: circuit-broken calls and fallback chains.

The chain semantics are the part that isn't a textbook breaker:

- steps whose breaker is open are SKIPPED, not attempted
- the first success wins
- if the chain is exhausted and ANY step was skipped-open, raise
BreakerTripped (not the last real error) so callers can distinguish
"the providers are down" (fail closed) from "your request failed"

Class Method Summary collapse

Class Method Details

.notify_fallback(from, to, error) ⇒ Object

Callback failures must never break the call path.



91
92
93
94
95
96
97
# File 'lib/ruby_llm/resilience/chain.rb', line 91

def notify_fallback(from, to, error)
  Resilience.config.on_fallback.call(
    from: from.except(:call), to: to.except(:call), error: error
  )
rescue StandardError
  nil
end

.run(service) ⇒ Object

Circuit-break a single call. Raises the original error on failure, or BreakerTripped if the circuit is open.



18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/ruby_llm/resilience/chain.rb', line 18

def run(service)
  breaker = Breaker.new(service)
  raise BreakerTripped, "Circuit open for #{service}" unless breaker.allow_request?

  result = yield
  breaker.record_success
  result
rescue BreakerTripped
  raise
rescue StandardError => error
  breaker.record_failure if Resilience.trippable?(error)
  raise
end

.run_with_fallback(*steps) ⇒ Object

Try steps in order; each is { service:, call: -> { ... } } (an optional :model key is carried through to on_fallback for context). Every advance past a failed or skipped step fires config.on_fallback with from:, to:, error: — the audit trail.

Raises:

  • (ArgumentError)


66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/ruby_llm/resilience/chain.rb', line 66

def run_with_fallback(*steps)
  raise ArgumentError, "run_with_fallback requires at least one step" if steps.empty?

  last_error = nil
  any_breaker_tripped = false

  steps.each_with_index do |step, index|
    begin
      return run(step[:service]) { step[:call].call }
    rescue BreakerTripped => error
      any_breaker_tripped = true
      last_error = error
    rescue *Resilience.config.resolved_fallback_errors => error
      last_error = error
    end

    next_step = steps[index + 1]
    notify_fallback(step, next_step, last_error) if next_step
  end

  raise BreakerTripped, last_error&.message if any_breaker_tripped
  raise last_error
end

.run_with_model_fallback(primary_model, fallback: :map, &block) ⇒ Object

Model fallback via the configured map (or an explicit override). The block receives the model to use (primary first; hops on trippable or fallback-class errors). Breaker names come from the service_namer.

Resilience.run_with_model_fallback("claude-haiku-4-5") { |model|
RubyLLM.chat(model: model).ask(prompt)
}

The fallback: keyword makes routing per-call configurable:

fallback: :map          — default: use config.fallback_models
fallback: false / nil   — no fallback; primary only
fallback: "model-name"  — explicit hop, ignoring the map
fallback: [ "a", "b" ]  — explicit multi-hop chain


45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/ruby_llm/resilience/chain.rb', line 45

def run_with_model_fallback(primary_model, fallback: :map, &block)
  namer = Resilience.config.service_namer

  hops =
    case fallback
    when :map       then Resilience.config.fallbacks_for(primary_model)
    when nil, false then []
    else Array(fallback)
    end

  steps = [ primary_model, *hops ].map do |model|
    { service: namer.call(model), model: model.to_s, call: -> { block.call(model) } }
  end

  run_with_fallback(*steps)
end