Class: Insika::Reliability
- Inherits:
-
Object
- Object
- Insika::Reliability
- Defined in:
- lib/insika/reliability.rb
Overview
The reliability policy for the turn's provider interaction (WS3): retries
with exponential backoff, mid-turn ROTATION across the fallback chain, and
the circuit breaker — all as DATA on AgentProfile#reliability, never a
parallel code path. The failure classification is B9's
(ProviderErrorClassifier): ONLY :retryable / :rate_limited_* ever retry or
rotate; a :fatal is re-raised immediately (a poisoned credential must not
hammer N models).
Each ATTEMPT is a fresh chat built by the caller (the coordinator yields the
selection): a failed ask leaves its user message in the chat, so re-asking
the same chat would double the input. The customer-visible answer comes only
from the attempt that returns; the fragments of failed attempts ride
:intermediate (operator-side) and die with the turn.
The breaker is per (tenant, provider/model): a node whose circuit is open is skipped (fail-fast with CircuitOpenError when the PRIMARY is open — the turn dies in ms, no provider call); a node that tripped mid-turn moves on.
Constant Summary collapse
- DEFAULT_TIMEOUT =
seconds per attempt (reliability)
30
Instance Method Summary collapse
-
#call(policy:, tenant:, agent: nil, selection:, chain:, &attempt) ⇒ Object
policy: the profile's reliability data (string keys) — the caller skips this coordinator entirely when nil (parity).
-
#initialize(circuit_store:, event_stream:, sleeper: nil) ⇒ Reliability
constructor
circuit_store: CircuitState (the breaker's durable cells).
Constructor Details
#initialize(circuit_store:, event_stream:, sleeper: nil) ⇒ Reliability
circuit_store: CircuitState (the breaker's durable cells). event_stream: where the reliability events go (:provider_fallback, ...). sleeper: ->(seconds) — the backoff wait; injectable for specs.
27 28 29 30 31 |
# File 'lib/insika/reliability.rb', line 27 def initialize(circuit_store:, event_stream:, sleeper: nil) @circuit_store = circuit_store @event_stream = event_stream @sleeper = sleeper || method(:backoff_wait) end |
Instance Method Details
#call(policy:, tenant:, agent: nil, selection:, chain:, &attempt) ⇒ Object
policy: the profile's reliability data (string keys) — the caller skips this coordinator entirely when nil (parity). tenant: the command tenant (breaker scoping; nil = platform). agent: the agent id (event attribution — WS6 alerts read it). selection: the resolved primary ModelSelection. chain: [{ model:, provider: }] fallback candidates (profile's first, then the platform's resolved fallbacks). attempt: ->(selection, attempt_index) { response } — build the chat for that selection and ask. May RAISE a provider-family error.
-> the successful response. Raises CircuitOpenError (primary open), or the last retryable error when every node exhausted its retries.
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/insika/reliability.rb', line 45 def call(policy:, tenant:, agent: nil, selection:, chain:, &attempt) @agent = agent # event attribution (WF6 alerts) for THIS run nodes = ([selection] + Array(chain)).map { |node| { selection: node, tries: 0 } } retries = [policy["retries"].to_i, 0].max breaker = breaker_config(policy) # The per-attempt ceiling. A policy WITHOUT a timeout is DEFAULT_TIMEOUT — # nothing config overrides here (the old [.., 1].max silently made every # unset profile die in ~1s, WS3). configured_timeout = policy["timeout"].to_i timeout = configured_timeout.positive? ? configured_timeout : DEFAULT_TIMEOUT # declaraed HERE (not inside a block) so the post-loop `raise` sees the # method-local binding — a first assignment inside a block would not leak. last_error = nil # Fail-fast BEFORE any provider call: the PRIMARY's circuit open means # this provider family is known-dead — the turn dies in ms. if breaker && breaker_open?(tenant, selection, breaker) raise circuit_open(tenant, selection, breaker) end nodes.each do |node| selection = node[:selection] next if breaker && breaker_open?(tenant, selection, breaker) attempts = retries + 1 attempts.times do |index| node[:tries] += 1 begin response = with_attempt_timeout(timeout) { yield selection, node[:tries] } @circuit_store.record_success(tenant: tenant, ref: ref_of(selection)) if breaker return response rescue StandardError => e last_error = e # A :fatal provider error — or ANYTHING that is not a provider # failure at all (a bug, a domain error, a guardrail raise) — is # never retried, never rotated (B9's structural rule). Only # retryable/rate-limited (and the per-attempt timeout we raised) # spend the retry budget. The B9 classifier is class-name based, so # OUR TimeoutError reads as :fatal — the retryable_failure? check # (which owns the reliability-stage timeout) must decide FIRST, or # the :fatal guard would swallow it (WS3: a timeout never retried, # never rotated). retryable = retryable_failure?(e) raise unless retryable raise if kind_of(e) == :fatal && !e.is_a?(Insika::TimeoutError) record_failure(tenant, selection, breaker, e) # the last attempt of the last node re-raises; otherwise back off # and give the next attempt/node a turn. if index < attempts - 1 || node != nodes.last @sleeper.call(backoff_seconds(policy, index)) end end end end raise last_error if last_error raise Insika::Error, "reliability loop exhausted without a result" end |