use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
use crate::MultimuxError;
use crate::route::{HealthState, RouteHandle};
const DEFAULT_BACKOFF_MIN: Duration = Duration::from_millis(500);
const DEFAULT_BACKOFF_MAX: Duration = Duration::from_secs(30);
const DEFAULT_BACKOFF_FACTOR: f64 = 2.0;
const MAX_AUTH_ATTEMPTS_BEFORE_PERMANENT: u32 = 5;
fn is_auth_failure(err: &MultimuxError) -> bool {
matches!(err, MultimuxError::Auth { .. })
}
fn is_permanent_describe_not_found(err: &MultimuxError) -> bool {
matches!(
err,
MultimuxError::Protocol { phase, reason }
if *phase == "DESCRIBE" && reason == "non-success status Not Found"
)
}
#[derive(Debug, Clone)]
pub struct Backoff {
min: Duration,
max: Duration,
factor: f64,
current: Duration,
}
impl Backoff {
pub fn new(min: Duration, max: Duration, factor: f64) -> Self {
Backoff {
min,
max,
factor,
current: min,
}
}
pub fn production_default() -> Self {
Backoff::new(
DEFAULT_BACKOFF_MIN,
DEFAULT_BACKOFF_MAX,
DEFAULT_BACKOFF_FACTOR,
)
}
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Duration {
let delay = self.current;
let grown = self.current.mul_f64(self.factor);
self.current = grown.min(self.max);
delay
}
pub fn reset(&mut self) {
self.current = self.min;
}
}
fn record_route_up(name: &str, state: HealthState) {
let up = if matches!(state, HealthState::Live) {
1.0
} else {
0.0
};
metrics::gauge!(crate::prometheus::ROUTE_UP, "route" => name.to_string()).set(up);
}
fn record_reconnect(name: &str) {
metrics::counter!(crate::prometheus::SOURCE_RECONNECTS_TOTAL, "route" => name.to_string())
.increment(1);
}
#[tracing::instrument(
name = "route",
skip(attempt, route_handle, backoff, name, shutdown),
fields(route = %name)
)]
pub async fn supervise_driver<F, Fut>(
mut attempt: F,
route_handle: Arc<RouteHandle>,
mut backoff: Backoff,
name: String,
mut shutdown: watch::Receiver<bool>,
) where
F: FnMut(Arc<RouteHandle>) -> Fut + Send + 'static,
Fut: Future<Output = crate::Result<()>> + Send,
{
tracing::info!("connecting");
route_handle.set_health(HealthState::Connecting);
record_route_up(&name, HealthState::Connecting);
let mut attempt_no: u64 = 0;
let mut consecutive_auth_failures: u32 = 0;
loop {
if *shutdown.borrow() {
break;
}
route_handle.set_health(HealthState::Connecting);
let result = attempt(route_handle.clone()).await;
let reached_live = route_handle.health() == HealthState::Live;
match &result {
Ok(()) if reached_live => tracing::info!("ingest ended after being live"),
Ok(()) => {
attempt_no += 1;
tracing::warn!(attempt = attempt_no, "ended before ever becoming live");
}
Err(e) if reached_live => tracing::warn!(error = %e, "pipeline stopped"),
Err(e) => {
attempt_no += 1;
tracing::warn!(error = %e, attempt = attempt_no, "failed to connect");
}
}
if let Err(e) = &result
&& !reached_live
{
if is_auth_failure(e) {
consecutive_auth_failures += 1;
} else {
consecutive_auth_failures = 0;
}
if is_permanent_describe_not_found(e)
|| consecutive_auth_failures > MAX_AUTH_ATTEMPTS_BEFORE_PERMANENT
{
tracing::error!(
error = %e,
attempts = attempt_no,
"permanent failure — giving up, not retrying (issue #957)"
);
route_handle.set_health(HealthState::Failed);
record_route_up(&name, HealthState::Failed);
return;
}
} else {
consecutive_auth_failures = 0;
}
if reached_live {
attempt_no = 0;
backoff.reset();
}
route_handle.set_health(HealthState::Reconnecting);
record_route_up(&name, HealthState::Reconnecting);
record_reconnect(&name);
if *shutdown.borrow() {
break;
}
let delay = backoff.next();
tracing::warn!(
delay_ms = delay.as_millis() as u64,
attempt = attempt_no,
"reconnecting after backoff"
);
tokio::select! {
() = tokio::time::sleep(delay) => {}
_ = shutdown.changed() => {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn tiny_backoff() -> Backoff {
Backoff::new(Duration::from_millis(1), Duration::from_millis(20), 2.0)
}
async fn wait_until(timeout: Duration, mut f: impl FnMut() -> bool) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if f() {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
}
#[test]
fn backoff_grows_and_caps() {
let mut b = Backoff::new(Duration::from_millis(10), Duration::from_millis(100), 2.0);
assert_eq!(b.next(), Duration::from_millis(10));
assert_eq!(b.next(), Duration::from_millis(20));
assert_eq!(b.next(), Duration::from_millis(40));
assert_eq!(b.next(), Duration::from_millis(80));
assert_eq!(b.next(), Duration::from_millis(100));
assert_eq!(b.next(), Duration::from_millis(100), "stays capped");
}
#[test]
fn backoff_reset_returns_to_min() {
let mut b = Backoff::new(Duration::from_millis(10), Duration::from_millis(100), 2.0);
let _ = b.next();
let _ = b.next();
b.reset();
assert_eq!(
b.next(),
Duration::from_millis(10),
"back to min after reset"
);
}
#[test]
fn is_auth_failure_true_only_for_the_auth_variant() {
assert!(is_auth_failure(&MultimuxError::Auth {
reason: "DESCRIBE: 401 Unauthorized".into()
}));
assert!(!is_auth_failure(&MultimuxError::Connect {
reason: "connect refused".into()
}));
assert!(!is_auth_failure(&MultimuxError::Protocol {
phase: "DESCRIBE",
reason: "non-success status Not Found".into(),
}));
}
#[test]
fn is_permanent_describe_not_found_true_only_for_that_exact_shape() {
assert!(is_permanent_describe_not_found(&MultimuxError::Protocol {
phase: "DESCRIBE",
reason: "non-success status Not Found".into(),
}));
assert!(!is_permanent_describe_not_found(&MultimuxError::Protocol {
phase: "SETUP",
reason: "non-success status Not Found".into(),
}));
assert!(!is_permanent_describe_not_found(&MultimuxError::Protocol {
phase: "DESCRIBE",
reason: "non-success status Service Unavailable".into(),
}));
assert!(!is_permanent_describe_not_found(&MultimuxError::Auth {
reason: "DESCRIBE: 401 Unauthorized".into()
}));
}
fn always_fails(
err: fn() -> MultimuxError,
call_count: Arc<AtomicUsize>,
) -> impl FnMut(
Arc<RouteHandle>,
) -> std::pin::Pin<Box<dyn Future<Output = crate::Result<()>> + Send>>
+ Send
+ 'static {
move |_route_handle: Arc<RouteHandle>| {
call_count.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { Err(err()) })
}
}
#[tokio::test]
async fn a_wrong_password_stops_retrying_after_the_bound_and_marks_the_route_failed() {
let route = Arc::new(RouteHandle::new(1.0, 500, 8));
let call_count = Arc::new(AtomicUsize::new(0));
let attempt = always_fails(
|| MultimuxError::Auth {
reason: "DESCRIBE: 401 Unauthorized".into(),
},
call_count.clone(),
);
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise_driver(
attempt,
route.clone(),
tiny_backoff(),
"test-route".to_string(),
shutdown_rx,
));
tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect(
"supervise_driver must return on its own once a permanent \
auth failure is declared, not retry forever",
)
.expect("supervise_driver task did not panic");
assert_eq!(
route.health(),
HealthState::Failed,
"a permanently wrong password must mark the route Failed"
);
assert_eq!(
call_count.load(Ordering::SeqCst) as u32,
MAX_AUTH_ATTEMPTS_BEFORE_PERMANENT + 1,
"must attempt exactly bound+1 times before declaring permanence"
);
}
#[tokio::test]
async fn a_describe_404_is_permanent_on_the_first_attempt() {
let route = Arc::new(RouteHandle::new(1.0, 500, 8));
let call_count = Arc::new(AtomicUsize::new(0));
let attempt = always_fails(
|| MultimuxError::Protocol {
phase: "DESCRIBE",
reason: "non-success status Not Found".into(),
},
call_count.clone(),
);
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise_driver(
attempt,
route.clone(),
tiny_backoff(),
"test-route".to_string(),
shutdown_rx,
));
tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect("supervise_driver must return on its own for a DESCRIBE 404")
.expect("supervise_driver task did not panic");
assert_eq!(route.health(), HealthState::Failed);
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"a DESCRIBE 404 must be declared permanent on the very first attempt"
);
}
#[tokio::test]
async fn an_auth_failure_that_recovers_within_the_bound_still_reaches_live() {
let route = Arc::new(RouteHandle::new(1.0, 500, 8));
let call_count = Arc::new(AtomicUsize::new(0));
let fail_times = (MAX_AUTH_ATTEMPTS_BEFORE_PERMANENT - 1) as usize;
let cc = call_count.clone();
let attempt = move |route_handle: Arc<RouteHandle>| {
let cc = cc.clone();
Box::pin(async move {
let n = cc.fetch_add(1, Ordering::SeqCst);
if n < fail_times {
return Err(MultimuxError::Auth {
reason: "DESCRIBE: 401 Unauthorized".into(),
});
}
route_handle.set_health(HealthState::Live);
tokio::time::sleep(Duration::from_millis(20)).await;
Ok(())
}) as std::pin::Pin<Box<dyn Future<Output = crate::Result<()>> + Send>>
};
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise_driver(
attempt,
route.clone(),
tiny_backoff(),
"test-route".to_string(),
shutdown_rx,
));
let reached_live = wait_until(Duration::from_secs(10), || {
route.health() == HealthState::Live
})
.await;
assert!(
reached_live,
"a camera recovering within the auth bound must still reach Live, not be given up on"
);
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect("supervise_driver returns promptly on shutdown")
.expect("supervise_driver task did not panic");
}
#[tokio::test]
async fn a_non_auth_failure_keeps_retrying_past_the_auth_bound() {
let route = Arc::new(RouteHandle::new(1.0, 500, 8));
let call_count = Arc::new(AtomicUsize::new(0));
let attempt = always_fails(
|| MultimuxError::Connect {
reason: "connect refused".into(),
},
call_count.clone(),
);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise_driver(
attempt,
route.clone(),
tiny_backoff(),
"test-route".to_string(),
shutdown_rx,
));
let exceeded_bound = wait_until(Duration::from_secs(10), || {
call_count.load(Ordering::SeqCst) as u32 > MAX_AUTH_ATTEMPTS_BEFORE_PERMANENT + 2
})
.await;
assert!(
exceeded_bound,
"a non-auth failure must keep retrying past the auth-only bound"
);
assert_ne!(
route.health(),
HealthState::Failed,
"a transient connect failure must never be marked permanent"
);
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect("supervise_driver returns promptly on shutdown")
.expect("supervise_driver task did not panic");
}
fn flaky_attempt(
fail_times: usize,
call_count: Arc<AtomicUsize>,
) -> impl FnMut(
Arc<RouteHandle>,
) -> std::pin::Pin<Box<dyn Future<Output = crate::Result<()>> + Send>>
+ Send
+ 'static {
move |route_handle: Arc<RouteHandle>| {
let call_count = call_count.clone();
Box::pin(async move {
let n = call_count.fetch_add(1, Ordering::SeqCst);
if n < fail_times {
return Err(crate::MultimuxError::Connect {
reason: "flaky attempt: simulated failure".into(),
});
}
route_handle.set_health(HealthState::Live);
tokio::time::sleep(Duration::from_millis(20)).await;
Ok(())
})
}
}
#[tokio::test]
async fn reconnects_after_a_failing_attempt_reaches_live_and_retries_again_after_ending() {
let route = Arc::new(RouteHandle::new(1.0, 500, 8));
let call_count = Arc::new(AtomicUsize::new(0));
let attempt = flaky_attempt(1, call_count.clone());
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise_driver(
attempt,
route.clone(),
tiny_backoff(),
"test-route".to_string(),
shutdown_rx,
));
let reached_live = wait_until(Duration::from_secs(60), || {
route.health() == HealthState::Live
})
.await;
assert!(
reached_live,
"route must reach Live after one failing attempt"
);
let retried_again = wait_until(Duration::from_secs(60), || {
call_count.load(Ordering::SeqCst) >= 3
})
.await;
assert!(
retried_again,
"attempt must be called again after a live attempt ends"
);
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(60), handle)
.await
.expect("supervise_driver returns promptly on shutdown")
.expect("supervise_driver task did not panic");
}
#[tokio::test]
async fn shutdown_stops_supervise_driver_promptly_mid_backoff() {
let route = Arc::new(RouteHandle::new(1.0, 500, 8));
let call_count = Arc::new(AtomicUsize::new(0));
let attempt = flaky_attempt(usize::MAX, call_count);
let backoff = Backoff::new(Duration::from_secs(60), Duration::from_secs(90), 2.0);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise_driver(
attempt,
route,
backoff,
"test-route".to_string(),
shutdown_rx,
));
tokio::time::sleep(Duration::from_millis(20)).await;
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(5), handle)
.await
.expect("supervise_driver must return promptly on shutdown, not after the 90s backoff")
.expect("supervise_driver task did not panic");
}
}