Skip to main content

glass/browser/session/
retry.rs

1//! Retry policies for browser operations.
2//!
3//! Provides [`RetryPolicy`] and retry-aware wrappers around common
4//! browser operations. Transport and CDP protocol errors are retried;
5//! targeting ambiguity (element not found, ambiguous selector, stale
6//! reference) always fails immediately — Glass never guesses a
7//! destructive target.
8
9use super::*;
10use std::sync::Arc;
11use std::time::Duration;
12
13/// Predicate function for retry decisions.
14pub type RetryPredicate = Arc<dyn Fn(&(dyn std::error::Error + 'static)) -> bool + Send + Sync>;
15
16/// Policy controlling retry behaviour for browser operations.
17///
18/// Transport and CDP protocol errors are retried. Targeting ambiguity
19/// (element not found, ambiguous selector, stale reference) always
20/// fails immediately — Glass never guesses a destructive target.
21pub struct RetryPolicy {
22    pub max_attempts: usize,
23    pub backoff: Duration,
24    pub predicate: Option<RetryPredicate>,
25}
26
27impl Clone for RetryPolicy {
28    fn clone(&self) -> Self {
29        Self {
30            max_attempts: self.max_attempts,
31            backoff: self.backoff,
32            predicate: self.predicate.clone(),
33        }
34    }
35}
36
37impl std::fmt::Debug for RetryPolicy {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("RetryPolicy")
40            .field("max_attempts", &self.max_attempts)
41            .field("backoff", &self.backoff)
42            .field("predicate", &self.predicate.as_ref().map(|_| ".."))
43            .finish()
44    }
45}
46
47impl Default for RetryPolicy {
48    fn default() -> Self {
49        Self {
50            max_attempts: 3,
51            backoff: Duration::from_millis(200),
52            predicate: None,
53        }
54    }
55}
56
57impl RetryPolicy {
58    /// Create a retry policy with the given max attempts and backoff.
59    ///
60    /// `max_attempts` is clamped to a minimum of 1. No custom predicate
61    /// is set — use the struct directly if you need one.
62    pub fn new(max_attempts: usize, backoff: Duration) -> Self {
63        Self {
64            max_attempts: max_attempts.max(1),
65            backoff,
66            predicate: None,
67        }
68    }
69}
70
71fn is_retryable_default(error: &(dyn std::error::Error + 'static)) -> bool {
72    if error.downcast_ref::<TargetError>().is_some() {
73        return false;
74    }
75    if error
76        .downcast_ref::<crate::browser::cdp::CdpError>()
77        .is_some()
78    {
79        return true;
80    }
81    true
82}
83
84impl BrowserSession {
85    /// Click one element with retry on transport/CDP errors.
86    /// Targeting ambiguity always fails immediately.
87    pub async fn click_with_retry(
88        &self,
89        target: &str,
90        policy: &RetryPolicy,
91    ) -> BrowserResult<ActionOutcome> {
92        for attempt in 1..=policy.max_attempts {
93            match self.click(target).await {
94                Ok(outcome) => return Ok(outcome),
95                Err(error) => {
96                    let retryable = policy
97                        .predicate
98                        .as_ref()
99                        .map(|pred| pred(error.as_ref()))
100                        .unwrap_or_else(|| is_retryable_default(error.as_ref()));
101                    if !retryable || attempt == policy.max_attempts {
102                        return Err(error);
103                    }
104                    tracing::debug!(attempt, ?policy.backoff, "click retry");
105                    tokio::time::sleep(policy.backoff).await;
106                }
107            }
108        }
109        unreachable!("loop always returns")
110    }
111
112    /// Navigate to a URL with retry on transport/CDP errors.
113    pub async fn navigate_with_retry(
114        &self,
115        url: &str,
116        policy: &RetryPolicy,
117    ) -> BrowserResult<PageInfo> {
118        for attempt in 1..=policy.max_attempts {
119            match self.navigate(url).await {
120                Ok(page) => return Ok(page),
121                Err(error) => {
122                    let retryable = policy
123                        .predicate
124                        .as_ref()
125                        .map(|pred| pred(error.as_ref()))
126                        .unwrap_or_else(|| is_retryable_default(error.as_ref()));
127                    if !retryable || attempt == policy.max_attempts {
128                        return Err(error);
129                    }
130                    tracing::debug!(attempt, ?policy.backoff, "navigate retry");
131                    tokio::time::sleep(policy.backoff).await;
132                }
133            }
134        }
135        unreachable!("loop always returns")
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn retry_policy_defaults_to_three_attempts() {
145        let policy = RetryPolicy::default();
146        assert_eq!(policy.max_attempts, 3);
147        assert_eq!(policy.backoff, Duration::from_millis(200));
148    }
149
150    #[test]
151    fn retry_policy_new_clamps_min_attempts() {
152        let policy = RetryPolicy::new(0, Duration::from_millis(100));
153        assert_eq!(policy.max_attempts, 1);
154    }
155
156    #[test]
157    fn default_predicate_rejects_target_ambiguity() {
158        let target_error = TargetError {
159            kind: TargetErrorKind::Ambiguous,
160            reason: None,
161            candidates: vec![],
162            recovery: None,
163        };
164        assert!(!is_retryable_default(&target_error));
165    }
166
167    #[test]
168    fn default_predicate_rejects_stale_reference() {
169        let target_error = TargetError {
170            kind: TargetErrorKind::StaleReference,
171            reason: None,
172            candidates: vec![],
173            recovery: None,
174        };
175        assert!(!is_retryable_default(&target_error));
176    }
177
178    #[test]
179    fn default_predicate_rejects_not_actionable() {
180        let target_error = TargetError {
181            kind: TargetErrorKind::NotActionable,
182            reason: None,
183            candidates: vec![],
184            recovery: None,
185        };
186        assert!(!is_retryable_default(&target_error));
187    }
188
189    #[test]
190    fn retry_policy_supports_custom_predicate() {
191        let policy = RetryPolicy {
192            max_attempts: 2,
193            backoff: Duration::from_millis(50),
194            predicate: Some(Arc::new(|_| true)),
195        };
196        assert!(policy.predicate.is_some());
197    }
198
199    #[test]
200    fn retry_policy_is_cloneable() {
201        let policy = RetryPolicy::new(4, Duration::from_secs(1));
202        let cloned = policy.clone();
203        assert_eq!(cloned.max_attempts, 4);
204        assert_eq!(cloned.backoff, Duration::from_secs(1));
205    }
206
207    #[test]
208    fn custom_predicate_can_be_called() {
209        let predicate: RetryPredicate = Arc::new(|_err: &(dyn std::error::Error + 'static)| true);
210        let test_error: Box<dyn std::error::Error + 'static> =
211            Box::new(std::io::Error::other("test"));
212        assert!(predicate(test_error.as_ref()));
213
214        let negative: RetryPredicate = Arc::new(|_err: &(dyn std::error::Error + 'static)| false);
215        let test_error2: Box<dyn std::error::Error + 'static> =
216            Box::new(std::io::Error::other("test2"));
217        assert!(!negative(test_error2.as_ref()));
218    }
219
220    #[test]
221    fn retry_policy_debug_output_hides_predicate_closure() {
222        let policy = RetryPolicy::new(5, Duration::from_millis(10));
223        let debug = format!("{:?}", policy);
224        assert!(debug.contains("max_attempts: 5"));
225        assert!(debug.contains("backoff: 10ms"));
226        // Predicate is None, so it should not show ".."
227        assert!(debug.contains("predicate: None"));
228    }
229
230    #[test]
231    fn retry_policy_debug_output_indicates_custom_predicate() {
232        let policy = RetryPolicy {
233            max_attempts: 3,
234            backoff: Duration::from_millis(100),
235            predicate: Some(Arc::new(|_| true)),
236        };
237        let debug = format!("{:?}", policy);
238        assert!(debug.contains("predicate: Some(\"..\")"));
239    }
240}