use super::Provider;
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
const WINDOW: usize = 20;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ProviderHealth {
pub provider: Provider,
pub is_healthy: bool,
pub recent_successes: u32,
pub recent_failures: u32,
pub last_error: Option<String>,
pub requests_remaining_estimate: Option<f64>,
}
#[derive(Default)]
struct ProviderHealthState {
outcomes: VecDeque<bool>,
last_error: Option<String>,
}
pub(crate) struct HealthTracker {
state: Mutex<HashMap<Provider, ProviderHealthState>>,
}
impl HealthTracker {
pub(crate) fn new() -> Self {
Self {
state: Mutex::new(HashMap::new()),
}
}
pub(crate) fn record(&self, provider: Provider, success: bool, error: Option<String>) {
let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
let entry = guard.entry(provider).or_default();
entry.outcomes.push_back(success);
if entry.outcomes.len() > WINDOW {
entry.outcomes.pop_front();
}
if success {
entry.last_error = None;
} else if let Some(e) = error {
entry.last_error = Some(e);
}
}
pub(crate) fn snapshot(&self, provider: Provider) -> ProviderHealth {
let guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
let state = guard.get(&provider);
let total = state.map_or(0, |s| s.outcomes.len());
let successes = state.map_or(0, |s| s.outcomes.iter().filter(|o| **o).count());
ProviderHealth {
provider,
is_healthy: successes * 2 >= total,
recent_successes: successes as u32,
recent_failures: (total - successes) as u32,
last_error: state.and_then(|s| s.last_error.clone()),
requests_remaining_estimate: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_provider_with_no_calls_is_healthy_by_default() {
let tracker = HealthTracker::new();
let health = tracker.snapshot(Provider::Yahoo);
assert!(health.is_healthy);
assert_eq!(health.recent_successes, 0);
assert_eq!(health.recent_failures, 0);
assert!(health.last_error.is_none());
}
#[test]
fn all_successes_are_healthy() {
let tracker = HealthTracker::new();
for _ in 0..5 {
tracker.record(Provider::Yahoo, true, None);
}
let health = tracker.snapshot(Provider::Yahoo);
assert!(health.is_healthy);
assert_eq!(health.recent_successes, 5);
assert_eq!(health.recent_failures, 0);
}
#[test]
fn a_majority_of_failures_is_unhealthy() {
let tracker = HealthTracker::new();
tracker.record(Provider::Yahoo, true, None);
tracker.record(Provider::Yahoo, false, Some("boom".to_string()));
tracker.record(Provider::Yahoo, false, Some("boom again".to_string()));
let health = tracker.snapshot(Provider::Yahoo);
assert!(!health.is_healthy);
assert_eq!(health.recent_successes, 1);
assert_eq!(health.recent_failures, 2);
assert_eq!(health.last_error.as_deref(), Some("boom again"));
}
#[test]
fn a_success_clears_the_last_error() {
let tracker = HealthTracker::new();
tracker.record(Provider::Yahoo, false, Some("boom".to_string()));
tracker.record(Provider::Yahoo, true, None);
let health = tracker.snapshot(Provider::Yahoo);
assert!(health.last_error.is_none());
}
#[test]
fn window_evicts_the_oldest_outcome() {
let tracker = HealthTracker::new();
for _ in 0..WINDOW {
tracker.record(Provider::Yahoo, false, Some("boom".to_string()));
}
assert!(!tracker.snapshot(Provider::Yahoo).is_healthy);
for _ in 0..WINDOW {
tracker.record(Provider::Yahoo, true, None);
}
let health = tracker.snapshot(Provider::Yahoo);
assert!(health.is_healthy);
assert_eq!(health.recent_successes, WINDOW as u32);
assert_eq!(health.recent_failures, 0);
}
#[test]
fn providers_are_tracked_independently() {
let tracker = HealthTracker::new();
tracker.record(Provider::Yahoo, true, None);
tracker.record(Provider::Edgar, false, Some("boom".to_string()));
tracker.record(Provider::Edgar, false, Some("boom".to_string()));
assert!(tracker.snapshot(Provider::Yahoo).is_healthy);
assert!(!tracker.snapshot(Provider::Edgar).is_healthy);
}
}