Skip to main content

finance_query/providers/
health.rs

1//! Lightweight in-memory provider health tracking ([`ProviderHealth`]),
2//! exposed via [`crate::Providers::health`].
3
4use super::Provider;
5use std::collections::{HashMap, VecDeque};
6use std::sync::Mutex;
7
8/// How many recent call outcomes each provider's health snapshot considers.
9const WINDOW: usize = 20;
10
11/// Snapshot of one provider's recent health and (where derivable) remaining
12/// rate-limit budget.
13///
14/// Purely observational — computed from the last `WINDOW` dispatch
15/// outcomes recorded in-process by [`super::ProviderSet`]; it is not a
16/// circuit breaker and does not itself change dispatch behavior (routing and
17/// [`RetryPolicy`](super::retry::RetryPolicy) are unaffected by it).
18#[derive(Debug, Clone, PartialEq)]
19#[non_exhaustive]
20pub struct ProviderHealth {
21    /// The provider this snapshot describes.
22    pub provider: Provider,
23    /// `true` when at least half of the recent recorded outcomes succeeded,
24    /// or when no calls have been recorded yet (optimistic default).
25    pub is_healthy: bool,
26    /// Successes among the last `WINDOW` recorded outcomes.
27    pub recent_successes: u32,
28    /// Failures among the last `WINDOW` recorded outcomes.
29    pub recent_failures: u32,
30    /// The most recent failure's message. Cleared by any success, so it is
31    /// set only when the latest recorded outcome was a failure.
32    pub last_error: Option<String>,
33    /// Best-effort estimate of remaining rate-limit budget (tokens in the
34    /// adapter's own token bucket), when the provider exposes one via
35    /// [`super::ProviderAdapter::rate_limit_remaining`]. `None` for providers
36    /// with no local rate limiter to peek (e.g. Yahoo) or that haven't been
37    /// initialized.
38    pub requests_remaining_estimate: Option<f64>,
39}
40
41#[derive(Default)]
42struct ProviderHealthState {
43    outcomes: VecDeque<bool>,
44    last_error: Option<String>,
45}
46
47/// Per-provider ring buffer of recent success/failure outcomes.
48///
49/// A `std::sync::Mutex` per tracker (not per provider) is fine here: critical
50/// sections are a handful of `VecDeque` pushes, never held across an `.await`.
51pub(crate) struct HealthTracker {
52    state: Mutex<HashMap<Provider, ProviderHealthState>>,
53}
54
55impl HealthTracker {
56    pub(crate) fn new() -> Self {
57        Self {
58            state: Mutex::new(HashMap::new()),
59        }
60    }
61
62    /// Record one dispatch outcome for `provider`.
63    pub(crate) fn record(&self, provider: Provider, success: bool, error: Option<String>) {
64        let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
65        let entry = guard.entry(provider).or_default();
66        entry.outcomes.push_back(success);
67        if entry.outcomes.len() > WINDOW {
68            entry.outcomes.pop_front();
69        }
70        if success {
71            entry.last_error = None;
72        } else if let Some(e) = error {
73            entry.last_error = Some(e);
74        }
75    }
76
77    /// Snapshot the current health for `provider` (optimistic default with no
78    /// recorded calls yet).
79    pub(crate) fn snapshot(&self, provider: Provider) -> ProviderHealth {
80        let guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
81        let state = guard.get(&provider);
82        let total = state.map_or(0, |s| s.outcomes.len());
83        let successes = state.map_or(0, |s| s.outcomes.iter().filter(|o| **o).count());
84        ProviderHealth {
85            provider,
86            // No calls recorded yet reads as healthy (0 >= 0), the optimistic default.
87            is_healthy: successes * 2 >= total,
88            recent_successes: successes as u32,
89            recent_failures: (total - successes) as u32,
90            last_error: state.and_then(|s| s.last_error.clone()),
91            requests_remaining_estimate: None,
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn a_provider_with_no_calls_is_healthy_by_default() {
102        let tracker = HealthTracker::new();
103        let health = tracker.snapshot(Provider::Yahoo);
104        assert!(health.is_healthy);
105        assert_eq!(health.recent_successes, 0);
106        assert_eq!(health.recent_failures, 0);
107        assert!(health.last_error.is_none());
108    }
109
110    #[test]
111    fn all_successes_are_healthy() {
112        let tracker = HealthTracker::new();
113        for _ in 0..5 {
114            tracker.record(Provider::Yahoo, true, None);
115        }
116        let health = tracker.snapshot(Provider::Yahoo);
117        assert!(health.is_healthy);
118        assert_eq!(health.recent_successes, 5);
119        assert_eq!(health.recent_failures, 0);
120    }
121
122    #[test]
123    fn a_majority_of_failures_is_unhealthy() {
124        let tracker = HealthTracker::new();
125        tracker.record(Provider::Yahoo, true, None);
126        tracker.record(Provider::Yahoo, false, Some("boom".to_string()));
127        tracker.record(Provider::Yahoo, false, Some("boom again".to_string()));
128        let health = tracker.snapshot(Provider::Yahoo);
129        assert!(!health.is_healthy);
130        assert_eq!(health.recent_successes, 1);
131        assert_eq!(health.recent_failures, 2);
132        assert_eq!(health.last_error.as_deref(), Some("boom again"));
133    }
134
135    #[test]
136    fn a_success_clears_the_last_error() {
137        let tracker = HealthTracker::new();
138        tracker.record(Provider::Yahoo, false, Some("boom".to_string()));
139        tracker.record(Provider::Yahoo, true, None);
140        let health = tracker.snapshot(Provider::Yahoo);
141        assert!(health.last_error.is_none());
142    }
143
144    #[test]
145    fn window_evicts_the_oldest_outcome() {
146        let tracker = HealthTracker::new();
147        // Fill the window with failures, then enough successes to push every
148        // failure out of the window.
149        for _ in 0..WINDOW {
150            tracker.record(Provider::Yahoo, false, Some("boom".to_string()));
151        }
152        assert!(!tracker.snapshot(Provider::Yahoo).is_healthy);
153        for _ in 0..WINDOW {
154            tracker.record(Provider::Yahoo, true, None);
155        }
156        let health = tracker.snapshot(Provider::Yahoo);
157        assert!(health.is_healthy);
158        assert_eq!(health.recent_successes, WINDOW as u32);
159        assert_eq!(health.recent_failures, 0);
160    }
161
162    #[test]
163    fn providers_are_tracked_independently() {
164        let tracker = HealthTracker::new();
165        tracker.record(Provider::Yahoo, true, None);
166        tracker.record(Provider::Edgar, false, Some("boom".to_string()));
167        tracker.record(Provider::Edgar, false, Some("boom".to_string()));
168        assert!(tracker.snapshot(Provider::Yahoo).is_healthy);
169        assert!(!tracker.snapshot(Provider::Edgar).is_healthy);
170    }
171}