Skip to main content

brainos_resilience/
lib.rs

1//! # Brain Resilience
2//!
3//! Resilience primitives for tool dispatch — currently a single Hystrix-style
4//! three-state circuit breaker (`Closed → Open → HalfOpen`). Higher-level
5//! layers (Tower-based stack: Timeout / RateLimit / LoopDetector / Retry /
6//! DLQ) will compose on top.
7//!
8//! The breaker is observer-aware: every state transition emits a
9//! `BrainEvent::BreakerStateChange` so the Live tab, `brain tail`, and
10//! remote subscribers can render breaker health alongside tool calls.
11
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use async_trait::async_trait;
17use chrono::Utc;
18use observe::{BrainEvent, Observer};
19use tokio::sync::{Mutex, RwLock};
20use tracing::{debug, info, warn};
21use uuid::Uuid;
22
23// ─── Config + state ─────────────────────────────────────────────────────────
24
25/// Tuning knobs for a [`CircuitBreaker`].
26#[derive(Debug, Clone)]
27pub struct BreakerConfig {
28    /// Consecutive failures from `Closed` that flip the breaker to `Open`.
29    pub failure_threshold: u32,
30    /// How long an `Open` breaker stays open before allowing a `HalfOpen`
31    /// probe.
32    pub open_duration: Duration,
33    /// Consecutive successes from `HalfOpen` that flip the breaker back to
34    /// `Closed`.
35    pub half_open_required_successes: u32,
36}
37
38impl Default for BreakerConfig {
39    fn default() -> Self {
40        Self {
41            failure_threshold: 3,
42            open_duration: Duration::from_secs(30),
43            half_open_required_successes: 1,
44        }
45    }
46}
47
48/// Three-state Hystrix machine. `Open` carries `opened_at` so the breaker
49/// can transparently transition itself to `HalfOpen` once enough wall-clock
50/// time has passed — callers do not need to drive the state machine.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum BreakerState {
53    Closed,
54    Open { opened_at: Instant },
55    HalfOpen,
56}
57
58impl BreakerState {
59    /// Lowercase wire form for [`BrainEvent::BreakerStateChange`].
60    pub fn as_str(&self) -> &'static str {
61        match self {
62            BreakerState::Closed => "closed",
63            BreakerState::Open { .. } => "open",
64            BreakerState::HalfOpen => "half_open",
65        }
66    }
67}
68
69struct Inner {
70    state: BreakerState,
71    /// Consecutive failures while in `Closed`. Reset on success.
72    consecutive_failures: u32,
73    /// Consecutive successes while in `HalfOpen`. Reset on failure.
74    probe_successes: u32,
75}
76
77// ─── Breaker trait ───────────────────────────────────────────────────────────
78
79/// The circuit-breaker contract callers depend on, so resilience consumers
80/// (`backends::resilient_send`, the per-tool registry, tests) can be written
81/// against the behaviour rather than the concrete [`CircuitBreaker`] struct —
82/// the SOLID dependency-inversion seam for the breaker.
83///
84/// This is the full lifecycle contract (gate + record both outcomes);
85/// [`intent::BreakerCheck`] is the narrower cross-crate *read-only* seam the
86/// capability router and `retry` use to gate on `is_open` alone.
87///
88/// The blanket [`Breaker`] impl on `Arc<B>` lets a shared `Arc<CircuitBreaker>`
89/// be passed wherever `&impl Breaker` is expected without a manual deref.
90#[async_trait]
91pub trait Breaker: Send + Sync {
92    /// Opaque identifier the breaker guards — used in logs, audit, and the
93    /// `BreakerOpen` error message.
94    fn tool_id(&self) -> &str;
95    /// `true` only when the breaker is actively rejecting calls. May
96    /// transparently drive `Open → HalfOpen` once the cooldown elapses.
97    async fn is_open(&self) -> bool;
98    /// Record a successful call against the guarded target.
99    async fn record_success(&self);
100    /// Record a failed call against the guarded target.
101    async fn record_failure(&self);
102}
103
104#[async_trait]
105impl<B: Breaker + ?Sized> Breaker for Arc<B> {
106    fn tool_id(&self) -> &str {
107        (**self).tool_id()
108    }
109    async fn is_open(&self) -> bool {
110        (**self).is_open().await
111    }
112    async fn record_success(&self) {
113        (**self).record_success().await
114    }
115    async fn record_failure(&self) {
116        (**self).record_failure().await
117    }
118}
119
120// ─── Breaker ────────────────────────────────────────────────────────────────
121
122/// Per-target circuit breaker. The `tool_id` is opaque to the breaker but
123/// is what consumers (the capability router, audit log, Live tab) key on,
124/// so it is included in every emitted [`BrainEvent::BreakerStateChange`].
125pub struct CircuitBreaker {
126    tool_id: String,
127    config: BreakerConfig,
128    inner: Mutex<Inner>,
129    observer: Option<Arc<dyn Observer>>,
130}
131
132impl CircuitBreaker {
133    pub fn new(tool_id: impl Into<String>, config: BreakerConfig) -> Self {
134        Self {
135            tool_id: tool_id.into(),
136            config,
137            inner: Mutex::new(Inner {
138                state: BreakerState::Closed,
139                consecutive_failures: 0,
140                probe_successes: 0,
141            }),
142            observer: None,
143        }
144    }
145
146    /// Wire an `Observer` so state transitions emit
147    /// `BrainEvent::BreakerStateChange`. Unwired breakers stay silent.
148    pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self {
149        self.observer = Some(observer);
150        self
151    }
152
153    pub fn tool_id(&self) -> &str {
154        &self.tool_id
155    }
156
157    pub fn config(&self) -> &BreakerConfig {
158        &self.config
159    }
160
161    /// Snapshot the current state. Does **not** drive the
162    /// `Open → HalfOpen` transition; use [`Self::is_open`] for that.
163    pub async fn state(&self) -> BreakerState {
164        self.inner.lock().await.state
165    }
166
167    /// Returns `true` only when the breaker is actively rejecting calls.
168    /// `Open` transparently transitions to `HalfOpen` once
169    /// `config.open_duration` has elapsed since the open transition —
170    /// callers in the hot path can rely on this method alone for gating.
171    pub async fn is_open(&self) -> bool {
172        let mut inner = self.inner.lock().await;
173        if let BreakerState::Open { opened_at } = inner.state {
174            if opened_at.elapsed() >= self.config.open_duration {
175                self.transition(&mut inner, BreakerState::HalfOpen).await;
176                return false;
177            }
178            return true;
179        }
180        false
181    }
182
183    pub async fn record_success(&self) {
184        let mut inner = self.inner.lock().await;
185        match inner.state {
186            BreakerState::Closed => {
187                inner.consecutive_failures = 0;
188            }
189            BreakerState::HalfOpen => {
190                inner.probe_successes += 1;
191                if inner.probe_successes >= self.config.half_open_required_successes {
192                    self.transition(&mut inner, BreakerState::Closed).await;
193                }
194            }
195            BreakerState::Open { .. } => {
196                // Shouldn't fire — the caller is expected to gate via
197                // `is_open` before issuing the request. Treat as a probe
198                // (move to HalfOpen with one success banked) so a manual
199                // success doesn't get lost.
200                self.transition(&mut inner, BreakerState::HalfOpen).await;
201                inner.probe_successes = 1;
202                if inner.probe_successes >= self.config.half_open_required_successes {
203                    self.transition(&mut inner, BreakerState::Closed).await;
204                }
205            }
206        }
207    }
208
209    pub async fn record_failure(&self) {
210        let mut inner = self.inner.lock().await;
211        match inner.state {
212            BreakerState::Closed => {
213                inner.consecutive_failures += 1;
214                if inner.consecutive_failures >= self.config.failure_threshold {
215                    self.transition(
216                        &mut inner,
217                        BreakerState::Open {
218                            opened_at: Instant::now(),
219                        },
220                    )
221                    .await;
222                }
223            }
224            BreakerState::HalfOpen => {
225                self.transition(
226                    &mut inner,
227                    BreakerState::Open {
228                        opened_at: Instant::now(),
229                    },
230                )
231                .await;
232            }
233            BreakerState::Open { .. } => {
234                // Already open — no-op. Renewing `opened_at` would extend
235                // the cooldown unfairly when retries pile in.
236            }
237        }
238    }
239
240    async fn transition(&self, inner: &mut Inner, to: BreakerState) {
241        let from = inner.state;
242        if from == to {
243            return;
244        }
245        inner.state = to;
246        match to {
247            BreakerState::Closed => {
248                inner.consecutive_failures = 0;
249                inner.probe_successes = 0;
250                info!(tool_id = %self.tool_id, "Circuit breaker closed");
251            }
252            BreakerState::Open { .. } => {
253                inner.probe_successes = 0;
254                warn!(
255                    tool_id = %self.tool_id,
256                    open_duration_secs = self.config.open_duration.as_secs(),
257                    "Circuit breaker OPEN",
258                );
259            }
260            BreakerState::HalfOpen => {
261                inner.probe_successes = 0;
262                debug!(tool_id = %self.tool_id, "Circuit breaker entering HalfOpen probe");
263            }
264        }
265        if let Some(observer) = &self.observer {
266            let _ = observer
267                .publish(BrainEvent::BreakerStateChange {
268                    id: Uuid::new_v4(),
269                    tool_id: self.tool_id.clone(),
270                    from: from.as_str().to_string(),
271                    to: to.as_str().to_string(),
272                    ts: Utc::now(),
273                })
274                .await;
275        }
276    }
277}
278
279/// The concrete breaker is the canonical [`Breaker`] implementation; the
280/// trait methods delegate to the inherent ones (which take method-resolution
281/// priority, so existing concrete callers are unaffected).
282#[async_trait]
283impl Breaker for CircuitBreaker {
284    fn tool_id(&self) -> &str {
285        CircuitBreaker::tool_id(self)
286    }
287    async fn is_open(&self) -> bool {
288        CircuitBreaker::is_open(self).await
289    }
290    async fn record_success(&self) {
291        CircuitBreaker::record_success(self).await
292    }
293    async fn record_failure(&self) {
294        CircuitBreaker::record_failure(self).await
295    }
296}
297
298// ─── Per-tool registry ──────────────────────────────────────────────────────
299
300/// Owns one [`CircuitBreaker`] per `tool_id`. The capability router queries
301/// it via the [`intent::BreakerCheck`] impl to exclude `Open` tools from
302/// scoring; the dispatch site records success/failure after each tool call.
303pub struct BreakerRegistry {
304    breakers: RwLock<HashMap<String, Arc<CircuitBreaker>>>,
305    config: BreakerConfig,
306    observer: Option<Arc<dyn Observer>>,
307}
308
309impl BreakerRegistry {
310    /// Build a registry that lazily creates per-tool breakers with the
311    /// provided default config. Each breaker is wired to the same observer
312    /// (if any) so all transitions reach the bus.
313    pub fn new(config: BreakerConfig) -> Self {
314        Self {
315            breakers: RwLock::new(HashMap::new()),
316            config,
317            observer: None,
318        }
319    }
320
321    pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self {
322        self.observer = Some(observer);
323        self
324    }
325
326    /// Look up the breaker for `tool_id`, creating one on first access.
327    pub async fn get_or_create(&self, tool_id: &str) -> Arc<CircuitBreaker> {
328        if let Some(existing) = self.breakers.read().await.get(tool_id) {
329            return existing.clone();
330        }
331        let mut guard = self.breakers.write().await;
332        // Re-check under the write lock — another task may have raced us.
333        if let Some(existing) = guard.get(tool_id) {
334            return existing.clone();
335        }
336        let mut cb = CircuitBreaker::new(tool_id, self.config.clone());
337        if let Some(obs) = &self.observer {
338            cb = cb.with_observer(obs.clone());
339        }
340        let arc = Arc::new(cb);
341        guard.insert(tool_id.to_string(), arc.clone());
342        arc
343    }
344
345    /// Snapshot lookup — `None` if no breaker has been minted yet.
346    pub async fn get(&self, tool_id: &str) -> Option<Arc<CircuitBreaker>> {
347        self.breakers.read().await.get(tool_id).cloned()
348    }
349
350    /// Convenience wrapper: mint the breaker for `tool_id` if needed and
351    /// record a success.
352    pub async fn record_success(&self, tool_id: &str) {
353        self.get_or_create(tool_id).await.record_success().await;
354    }
355
356    /// Convenience wrapper: mint the breaker for `tool_id` if needed and
357    /// record a failure.
358    pub async fn record_failure(&self, tool_id: &str) {
359        self.get_or_create(tool_id).await.record_failure().await;
360    }
361
362    pub async fn len(&self) -> usize {
363        self.breakers.read().await.len()
364    }
365
366    pub async fn is_empty(&self) -> bool {
367        self.breakers.read().await.is_empty()
368    }
369}
370
371#[async_trait]
372impl intent::BreakerCheck for BreakerRegistry {
373    async fn is_open(&self, tool_id: &str) -> bool {
374        // Only query existing breakers — never mint one just to read state.
375        // A tool with no recorded outcomes is considered closed.
376        match self.get(tool_id).await {
377            Some(cb) => cb.is_open().await,
378            None => false,
379        }
380    }
381}
382
383// ─── Retry primitive ───────────────────────────────────────────────────────
384
385mod retry;
386pub use retry::{retry, BreakerOpenAbort, RetryConfig, RetryOutcome};
387
388// ─── Timeout primitive ─────────────────────────────────────────────────────
389
390mod timeout;
391pub use timeout::{timeout, TimeoutError};
392
393// ─── RateLimit primitive ───────────────────────────────────────────────────
394
395mod rate_limit;
396pub use rate_limit::{RateLimitConfig, RateLimitRegistry, RateLimiter};
397
398// ─── LoopDetector primitive ────────────────────────────────────────────────
399
400mod loop_detector;
401pub use loop_detector::{LoopDetector, LoopDetectorConfig, LoopDetectorError};
402
403// ─── Dead-letter queue ─────────────────────────────────────────────────────
404
405mod dlq;
406pub use dlq::{DeadLetterQueue, DlqEntry, DlqError, InMemoryDlq};
407
408#[cfg(test)]
409mod tests;