glass/browser/session/
retry.rs1use super::*;
10use std::sync::Arc;
11use std::time::Duration;
12
13pub type RetryPredicate = Arc<dyn Fn(&(dyn std::error::Error + 'static)) -> bool + Send + Sync>;
15
16pub 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 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 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 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 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}