finance_query/providers/
health.rs1use super::Provider;
5use std::collections::{HashMap, VecDeque};
6use std::sync::Mutex;
7
8const WINDOW: usize = 20;
10
11#[derive(Debug, Clone, PartialEq)]
19#[non_exhaustive]
20pub struct ProviderHealth {
21 pub provider: Provider,
23 pub is_healthy: bool,
26 pub recent_successes: u32,
28 pub recent_failures: u32,
30 pub last_error: Option<String>,
33 pub requests_remaining_estimate: Option<f64>,
39}
40
41#[derive(Default)]
42struct ProviderHealthState {
43 outcomes: VecDeque<bool>,
44 last_error: Option<String>,
45}
46
47pub(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 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 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 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 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}