openlatch-provider 0.2.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Restart pacing and rate-limit accounting.
//!
//! Two concerns:
//!   1. **Delay between restarts** — exponential backoff with full jitter
//!      via `backon::ExponentialBuilder`, capped at 30s.
//!   2. **Rate-limit window** — at most `max_restarts` events in the last
//!      `window` (default 5 in 60s, systemd-parity). Past that, the
//!      supervisor marks the binding *degraded* and stops respawning.

use std::time::{Duration, Instant};

use crate::runtime::supervisor::spec::RestartLimitSpec;

/// Rolling window of recent restart events. Cheap to clone (a `VecDeque` of
/// `Instant`s with bounded size).
#[derive(Debug, Clone)]
pub struct RestartTracker {
    events: std::collections::VecDeque<Instant>,
    limit: RestartLimitSpec,
}

impl RestartTracker {
    pub fn new(limit: RestartLimitSpec) -> Self {
        Self {
            events: std::collections::VecDeque::with_capacity((limit.max_restarts as usize) + 1),
            limit,
        }
    }

    /// Total restart count ever (used for telemetry / display).
    pub fn total(&self) -> u32 {
        // VecDeque is bounded; track separately if we ever exceed u32.
        self.events.len() as u32
    }

    /// Record one restart and return whether the rate-limit has been hit.
    /// If `true` is returned, the caller should mark the binding degraded
    /// and stop respawning.
    pub fn record_and_check(&mut self, now: Instant) -> bool {
        let cutoff = now.checked_sub(self.limit.window);
        if let Some(cutoff) = cutoff {
            while self.events.front().map(|t| *t < cutoff).unwrap_or(false) {
                self.events.pop_front();
            }
        }
        self.events.push_back(now);
        // Limit is "no more than max_restarts within window".
        self.events.len() as u32 > self.limit.max_restarts
    }
}

/// Compute the next backoff sleep duration for a given restart index
/// (`0` = first restart). 500ms → 1s → 2s → 4s → 8s → 16s → 30s (capped),
/// with up to ±50% jitter so a fleet of misconfigured tools doesn't
/// synchronize.
pub fn backoff_for(attempt: u32) -> Duration {
    let base_ms: u64 = 500u64.saturating_mul(1u64 << attempt.min(6));
    let capped = base_ms.min(30_000);
    let jitter = pseudo_jitter(attempt, capped);
    Duration::from_millis(capped.saturating_add(jitter))
}

fn pseudo_jitter(attempt: u32, base: u64) -> u64 {
    // Cheap, deterministic-per-attempt jitter — we don't need
    // cryptographic randomness here. Hashes attempt + base through a
    // simple xorshift to get +0..+base/2 jitter range.
    let mut state: u64 = (attempt as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
    state ^= base.rotate_left(13);
    state ^= state >> 7;
    state ^= state << 17;
    state & (base / 2)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn limit(max: u32, window_ms: u64) -> RestartLimitSpec {
        RestartLimitSpec {
            max_restarts: max,
            window: Duration::from_millis(window_ms),
        }
    }

    #[test]
    fn fresh_tracker_under_limit() {
        let mut t = RestartTracker::new(limit(5, 60_000));
        let now = Instant::now();
        for _ in 0..5 {
            assert!(!t.record_and_check(now));
        }
        assert_eq!(t.total(), 5);
        // 6th in the same window trips.
        assert!(t.record_and_check(now));
    }

    #[test]
    fn old_events_age_out() {
        let mut t = RestartTracker::new(limit(2, 1_000));
        let t0 = Instant::now();
        assert!(!t.record_and_check(t0));
        assert!(!t.record_and_check(t0));
        // Window passes — older events fall off.
        let later = t0 + Duration::from_secs(2);
        assert!(!t.record_and_check(later));
    }

    #[test]
    fn backoff_caps_at_30s() {
        for attempt in 0..20 {
            let d = backoff_for(attempt);
            assert!(
                d.as_millis() <= 30_000 + 30_000 / 2 + 1,
                "attempt {attempt} = {d:?}"
            );
        }
    }

    #[test]
    fn backoff_starts_under_a_second() {
        let d = backoff_for(0);
        assert!(d.as_millis() >= 500);
        assert!(d.as_millis() < 1_000);
    }
}