use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use metrics::SubsystemMetrics;
use observe::{BrainEvent, ObserveError, Observer};
use resilience::BreakerConfig;
use tokio::sync::broadcast;
pub use resilience::{Breaker, CircuitBreaker};
const REBROADCAST_CAPACITY: usize = 16;
pub struct MetricsObserver {
metrics: Arc<SubsystemMetrics>,
tx: broadcast::Sender<BrainEvent>,
}
impl MetricsObserver {
pub fn new(metrics: Arc<SubsystemMetrics>) -> Arc<Self> {
let (tx, _) = broadcast::channel(REBROADCAST_CAPACITY);
Arc::new(Self { metrics, tx })
}
}
#[async_trait]
impl Observer for MetricsObserver {
async fn publish(&self, ev: BrainEvent) -> Result<(), ObserveError> {
if let BrainEvent::BreakerStateChange { to, .. } = &ev {
match to.as_str() {
"open" => self.metrics.inc_circuit_open(),
"closed" => self.metrics.inc_circuit_reset(),
_ => {}
}
}
let _ = self.tx.send(ev);
Ok(())
}
fn subscribe(&self) -> broadcast::Receiver<BrainEvent> {
self.tx.subscribe()
}
}
pub fn http_breaker(
name: &str,
failure_threshold: u32,
cooldown_secs: u64,
metrics: Option<Arc<SubsystemMetrics>>,
) -> CircuitBreaker {
let cfg = BreakerConfig {
failure_threshold,
open_duration: Duration::from_secs(cooldown_secs),
half_open_required_successes: 1,
};
let mut cb = CircuitBreaker::new(name, cfg);
if let Some(m) = metrics {
cb = cb.with_observer(MetricsObserver::new(m));
}
cb
}
fn is_transient_status(status: reqwest::StatusCode) -> bool {
status.is_server_error()
|| status == reqwest::StatusCode::TOO_MANY_REQUESTS || status == reqwest::StatusCode::REQUEST_TIMEOUT }
fn is_transient(err: &reqwest::Error) -> bool {
if err.is_timeout() || err.is_connect() {
return true;
}
err.status().is_some_and(is_transient_status)
}
#[derive(Debug, thiserror::Error)]
pub enum ResilientSendError {
#[error("{0} circuit breaker is open — backend disabled until cooldown elapses")]
BreakerOpen(String),
#[error("{0}")]
Failed(String),
}
pub async fn resilient_send<F>(
build_request: F,
circuit_breaker: &impl Breaker,
max_retries: u32,
retry_base_ms: u64,
) -> Result<reqwest::Response, ResilientSendError>
where
F: Fn() -> reqwest::RequestBuilder,
{
if circuit_breaker.is_open().await {
return Err(ResilientSendError::BreakerOpen(
circuit_breaker.tool_id().to_string(),
));
}
let attempts = 1 + max_retries;
let mut last_err = None;
for attempt in 0..attempts {
if attempt > 0 {
let delay = retry_base_ms * (1u64 << (attempt - 1).min(5));
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
}
match build_request().send().await {
Ok(response) => {
if response.status().is_success() || !is_transient_status(response.status()) {
circuit_breaker.record_success().await;
return Ok(response);
}
let status = response.status();
tracing::debug!(
backend = %circuit_breaker.tool_id(),
attempt = attempt + 1,
status = %status,
"Transient HTTP error, will retry"
);
last_err = Some(format!("HTTP {}", status));
}
Err(e) => {
if !is_transient(&e) {
circuit_breaker.record_failure().await;
return Err(ResilientSendError::Failed(e.to_string()));
}
tracing::debug!(
backend = %circuit_breaker.tool_id(),
attempt = attempt + 1,
error = %e,
"Transient error, will retry"
);
last_err = Some(e.to_string());
}
}
}
circuit_breaker.record_failure().await;
Err(ResilientSendError::Failed(last_err.unwrap_or_else(|| {
"all retry attempts exhausted".to_string()
})))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(threshold: u32, cooldown_secs: u64) -> BreakerConfig {
BreakerConfig {
failure_threshold: threshold,
open_duration: Duration::from_secs(cooldown_secs),
half_open_required_successes: 1,
}
}
#[tokio::test]
async fn http_breaker_closed_by_default() {
let cb = http_breaker("test", 3, 60, None);
assert!(!cb.is_open().await);
}
#[tokio::test]
async fn breaker_opens_after_threshold() {
let cb = CircuitBreaker::new("test", cfg(3, 60));
cb.record_failure().await;
cb.record_failure().await;
assert!(
!cb.is_open().await,
"should still be closed below threshold"
);
cb.record_failure().await;
assert!(cb.is_open().await, "should be open at threshold");
}
#[tokio::test]
async fn breaker_resets_on_success() {
let cb = CircuitBreaker::new("test", cfg(3, 60));
cb.record_failure().await;
cb.record_failure().await;
cb.record_success().await;
assert!(!cb.is_open().await);
cb.record_failure().await;
cb.record_failure().await;
assert!(!cb.is_open().await, "should be closed — counter was reset");
}
#[tokio::test]
async fn breaker_half_open_after_cooldown() {
let cb = CircuitBreaker::new("test", cfg(2, 0));
cb.record_failure().await;
cb.record_failure().await;
assert!(
!cb.is_open().await,
"should be half-open after zero cooldown"
);
}
}