1use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use dashmap::DashMap;
14
15const FAILURE_TTL_MS: u64 = 10 * 60 * 1000;
17
18pub const ROTATE_AFTER_FAILURES: u32 = 2;
20
21const CLASS_TRANSIENT: u8 = 0;
23const CLASS_BLOCKED: u8 = 1;
24const CLASS_OTHER: u8 = 2;
25
26fn class_code(error_class: &str) -> u8 {
28 match error_class {
29 "blocked" => CLASS_BLOCKED,
30 "transient" => CLASS_TRANSIENT,
31 _ => CLASS_OTHER,
32 }
33}
34
35#[derive(Debug)]
37struct FailureRecord {
38 count: AtomicU64,
40 last_failure: AtomicU64,
42 error_class: AtomicU8,
45}
46
47impl FailureRecord {
48 fn new(now_ms: u64, class: u8) -> Self {
49 Self {
50 count: AtomicU64::new(1),
51 last_failure: AtomicU64::new(now_ms),
52 error_class: AtomicU8::new(class),
53 }
54 }
55}
56
57#[derive(Debug, Default)]
62pub struct FailureTracker {
63 failures: DashMap<String, FailureRecord>,
65}
66
67impl FailureTracker {
68 pub fn new() -> Self {
70 Self {
71 failures: DashMap::new(),
72 }
73 }
74
75 pub fn record_failure(&self, domain: &str, browser: &str) {
78 self.record_failure_class(domain, browser, "transient");
79 }
80
81 pub fn record_failure_class(&self, domain: &str, browser: &str, error_class: &str) {
84 let key = make_key(domain, browser);
85 let now = now_ms();
86 let class = class_code(error_class);
87
88 if let Some(existing) = self.failures.get(&key) {
90 existing.count.fetch_add(1, Ordering::Relaxed);
91 existing.last_failure.store(now, Ordering::Relaxed);
92 existing.error_class.store(class, Ordering::Relaxed);
93 return;
94 }
95
96 self.failures
101 .entry(key)
102 .and_modify(|rec| {
103 rec.count.fetch_add(1, Ordering::Relaxed);
104 rec.last_failure.store(now, Ordering::Relaxed);
105 rec.error_class.store(class, Ordering::Relaxed);
106 })
107 .or_insert_with(|| FailureRecord::new(now, class));
108 }
109
110 pub fn record_success(&self, domain: &str, browser: &str) {
112 self.failures.remove(&make_key(domain, browser));
113 }
114
115 pub fn failure_count(&self, domain: &str, browser: &str) -> u32 {
117 let key = make_key(domain, browser);
118 let now = now_ms();
119
120 if let Some(record) = self.failures.get(&key) {
121 let last = record.last_failure.load(Ordering::Relaxed);
122 if now.saturating_sub(last) > FAILURE_TTL_MS {
123 drop(record);
125 self.failures.remove(&key);
126 return 0;
127 }
128 record.count.load(Ordering::Relaxed) as u32
129 } else {
130 0
131 }
132 }
133
134 pub fn total_failure_count(&self, domain: &str) -> u32 {
136 let prefix = format!("{domain}::");
137 let now = now_ms();
138 let mut total: u32 = 0;
139
140 for entry in self.failures.iter() {
141 if entry.key().starts_with(&prefix) {
142 let last = entry.value().last_failure.load(Ordering::Relaxed);
143 if now.saturating_sub(last) < FAILURE_TTL_MS {
144 total += entry.value().count.load(Ordering::Relaxed) as u32;
145 }
146 }
147 }
148
149 total
150 }
151
152 pub fn clear(&self, domain: &str) {
154 let prefix = format!("{domain}::");
155 let keys_to_remove: Vec<String> = self
157 .failures
158 .iter()
159 .filter(|entry| entry.key().starts_with(&prefix))
160 .map(|entry| entry.key().clone())
161 .collect();
162
163 for key in keys_to_remove {
164 self.failures.remove(&key);
165 }
166 }
167
168 pub fn clear_class(&self, domain: &str, error_class: &str) {
175 let prefix = format!("{domain}::");
176 let class = class_code(error_class);
177 let keys_to_remove: Vec<String> = self
179 .failures
180 .iter()
181 .filter(|entry| {
182 entry.key().starts_with(&prefix)
183 && entry.value().error_class.load(Ordering::Relaxed) == class
184 })
185 .map(|entry| entry.key().clone())
186 .collect();
187
188 for key in keys_to_remove {
189 self.failures.remove(&key);
190 }
191 }
192
193 pub fn cleanup(&self) {
195 let now = now_ms();
196 let keys_to_remove: Vec<String> = self
197 .failures
198 .iter()
199 .filter(|entry| {
200 let last = entry.value().last_failure.load(Ordering::Relaxed);
201 now.saturating_sub(last) > FAILURE_TTL_MS
202 })
203 .map(|entry| entry.key().clone())
204 .collect();
205
206 for key in keys_to_remove {
207 self.failures.remove(&key);
208 }
209 }
210}
211
212fn make_key(domain: &str, browser: &str) -> String {
214 let mut key = String::with_capacity(domain.len() + 2 + browser.len());
215 key.push_str(domain);
216 key.push_str("::");
217 key.push_str(browser);
218 key
219}
220
221fn now_ms() -> u64 {
223 SystemTime::now()
224 .duration_since(UNIX_EPOCH)
225 .unwrap_or_default()
226 .as_millis() as u64
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn record_and_read_failure() {
235 let tracker = FailureTracker::new();
236 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 0);
237
238 tracker.record_failure("example.com", "chrome-h");
239 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 1);
240
241 tracker.record_failure("example.com", "chrome-h");
242 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 2);
243 }
244
245 #[test]
246 fn record_success_clears() {
247 let tracker = FailureTracker::new();
248 tracker.record_failure("example.com", "chrome-h");
249 tracker.record_failure("example.com", "chrome-h");
250 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 2);
251
252 tracker.record_success("example.com", "chrome-h");
253 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 0);
254 }
255
256 #[test]
257 fn total_failure_count_across_browsers() {
258 let tracker = FailureTracker::new();
259 tracker.record_failure("example.com", "chrome-h");
260 tracker.record_failure("example.com", "chrome-new");
261 tracker.record_failure("example.com", "chrome-new");
262 tracker.record_failure("other.com", "firefox");
263
264 assert_eq!(tracker.total_failure_count("example.com"), 3);
265 assert_eq!(tracker.total_failure_count("other.com"), 1);
266 assert_eq!(tracker.total_failure_count("missing.com"), 0);
267 }
268
269 #[test]
270 fn clear_domain_removes_all_browsers() {
271 let tracker = FailureTracker::new();
272 tracker.record_failure("example.com", "chrome-h");
273 tracker.record_failure("example.com", "firefox");
274 tracker.record_failure("other.com", "chrome-h");
275
276 tracker.clear("example.com");
277
278 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 0);
279 assert_eq!(tracker.failure_count("example.com", "firefox"), 0);
280 assert_eq!(tracker.failure_count("other.com", "chrome-h"), 1);
282 }
283
284 #[test]
285 fn cleanup_removes_nothing_when_fresh() {
286 let tracker = FailureTracker::new();
287 tracker.record_failure("example.com", "chrome-h");
288 tracker.cleanup();
289 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 1);
290 }
291
292 #[test]
293 fn clear_class_clears_only_matching_class() {
294 let tracker = FailureTracker::new();
295 tracker.record_failure_class("example.com", "chrome-h", "blocked");
296 tracker.record_failure_class("example.com", "chrome-h", "blocked");
297 tracker.record_failure_class("example.com", "firefox", "transient");
298 tracker.record_failure_class("other.com", "chrome-h", "blocked");
299
300 tracker.clear_class("example.com", "blocked");
302
303 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 0); assert_eq!(tracker.failure_count("example.com", "firefox"), 1); assert_eq!(tracker.failure_count("other.com", "chrome-h"), 1); }
307
308 #[test]
309 fn clear_class_uses_most_recent_class() {
310 let tracker = FailureTracker::new();
311 tracker.record_failure_class("example.com", "chrome-h", "blocked");
312 tracker.record_failure_class("example.com", "chrome-h", "transient"); tracker.clear_class("example.com", "blocked");
314 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 2); }
316
317 #[test]
318 fn record_failure_defaults_to_transient() {
319 let tracker = FailureTracker::new();
320 tracker.record_failure("example.com", "chrome-h"); tracker.clear_class("example.com", "blocked");
322 assert_eq!(tracker.failure_count("example.com", "chrome-h"), 1); }
324}