Skip to main content

spider_browser/retry/
failure_tracker.rs

1//! Per-domain failure tracking (mirrors server hints.rs FailureTracker).
2//!
3//! Tracks `(domain, browser_type)` failure counts with a 10-minute TTL.
4//! Used by [`BrowserSelector`](super::browser_selector::BrowserSelector) to
5//! decide when to rotate browsers.
6//!
7//! Completely lock-free: uses [`DashMap`] for concurrent read/write access
8//! with no `Mutex` anywhere.
9
10use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use dashmap::DashMap;
14
15/// Mirrors `hints.rs` FAILURE_TTL (10 minutes).
16const FAILURE_TTL_MS: u64 = 10 * 60 * 1000;
17
18/// Mirrors `hints.rs` ROTATE_AFTER_FAILURES.
19pub const ROTATE_AFTER_FAILURES: u32 = 2;
20
21// Failure-class codes, stored atomically so the record stays lock-free.
22const CLASS_TRANSIENT: u8 = 0;
23const CLASS_BLOCKED: u8 = 1;
24const CLASS_OTHER: u8 = 2;
25
26/// Map a failure-class string to its atomic code.
27fn 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/// A single failure record: count + last failure timestamp + last failure class.
36#[derive(Debug)]
37struct FailureRecord {
38    /// Number of consecutive failures.
39    count: AtomicU64,
40    /// Timestamp (ms since UNIX epoch) of the last failure.
41    last_failure: AtomicU64,
42    /// Class code of the most recent failure (see `CLASS_*`). Used by
43    /// `clear_class` to selectively reset failures on stealth escalation.
44    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/// Per-domain, per-browser failure tracker with 10-minute TTL.
58///
59/// All methods are `&self` (no `&mut self`), making this safe to share
60/// across tasks via `Arc<FailureTracker>`.
61#[derive(Debug, Default)]
62pub struct FailureTracker {
63    /// Key: `"{domain}::{browser}"` -> failure record.
64    failures: DashMap<String, FailureRecord>,
65}
66
67impl FailureTracker {
68    /// Create a new, empty failure tracker.
69    pub fn new() -> Self {
70        Self {
71            failures: DashMap::new(),
72        }
73    }
74
75    /// Record a failure for a domain + browser pair (defaults to the
76    /// "transient" class). See [`record_failure_class`](Self::record_failure_class).
77    pub fn record_failure(&self, domain: &str, browser: &str) {
78        self.record_failure_class(domain, browser, "transient");
79    }
80
81    /// Record a failure tagged with its class ("blocked", "transient", ...) for
82    /// selective clearing on stealth escalation.
83    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        // Try to update an existing record first.
89        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        // Insert a new record. There is a benign race: another thread could
97        // insert between the `get` above and the `entry` below. `or_insert_with`
98        // handles that correctly -- the first writer wins and we just bump the
99        // existing entry.
100        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    /// Record a success -- clears the failure counter for a domain + browser.
111    pub fn record_success(&self, domain: &str, browser: &str) {
112        self.failures.remove(&make_key(domain, browser));
113    }
114
115    /// Get failure count (0 if expired or not found).
116    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                // Expired -- drop the read guard, then remove.
124                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    /// Get total failures across all browsers for a domain (unexpired only).
135    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    /// Clear all failure records for a domain (regardless of class).
153    pub fn clear(&self, domain: &str) {
154        let prefix = format!("{domain}::");
155        // Collect keys to remove -- cannot remove while iterating DashMap.
156        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    /// Clear only failures of a given class for a domain.
169    ///
170    /// Used on stealth escalation: `blocked` failures are cleared (a higher
171    /// stealth tier can bypass the block), while `transient`/disconnect failures
172    /// are retained (escalating stealth won't fix flaky infra, so we keep
173    /// skipping a browser that keeps dropping on this domain).
174    pub fn clear_class(&self, domain: &str, error_class: &str) {
175        let prefix = format!("{domain}::");
176        let class = class_code(error_class);
177        // Collect keys to remove -- cannot remove while iterating DashMap.
178        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    /// Clean up expired entries across all domains.
194    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
212/// Build the composite key `"{domain}::{browser}"`.
213fn 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
221/// Current time in milliseconds since UNIX epoch.
222fn 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        // other.com untouched
281        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        // stealth escalation: clear blocked, keep transient
301        tracker.clear_class("example.com", "blocked");
302
303        assert_eq!(tracker.failure_count("example.com", "chrome-h"), 0); // blocked cleared
304        assert_eq!(tracker.failure_count("example.com", "firefox"), 1); // transient retained
305        assert_eq!(tracker.failure_count("other.com", "chrome-h"), 1); // other domain untouched
306    }
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"); // latest = transient
313        tracker.clear_class("example.com", "blocked");
314        assert_eq!(tracker.failure_count("example.com", "chrome-h"), 2); // retained
315    }
316
317    #[test]
318    fn record_failure_defaults_to_transient() {
319        let tracker = FailureTracker::new();
320        tracker.record_failure("example.com", "chrome-h"); // default class
321        tracker.clear_class("example.com", "blocked");
322        assert_eq!(tracker.failure_count("example.com", "chrome-h"), 1); // not cleared
323    }
324}