Skip to main content

stygian_browser/
error.rs

1//! Error types for browser automation operations
2//!
3//! All error variants carry structured context so callers can select retry
4//! strategies or surface meaningful diagnostics without string parsing.
5
6use thiserror::Error;
7
8/// Result type alias for browser operations.
9pub type Result<T> = std::result::Result<T, BrowserError>;
10
11/// Errors that can occur during browser automation.
12///
13/// Every variant carries enough structured context to decide on a retry policy
14/// or surface a useful diagnostic message without string-parsing.
15#[derive(Error, Debug)]
16pub enum BrowserError {
17    /// Browser process failed to start.
18    #[error("Browser launch failed: {reason}")]
19    LaunchFailed {
20        /// Human-readable explanation of the failure.
21        reason: String,
22    },
23
24    /// Chrome `DevTools` Protocol (CDP) operation failed.
25    #[error("CDP error during '{operation}': {message}")]
26    CdpError {
27        /// The CDP method or operation that failed.
28        operation: String,
29        /// Error detail from the protocol layer.
30        message: String,
31    },
32
33    /// All pool slots are occupied and the wait timeout elapsed.
34    #[error("Browser pool exhausted (active={active}, max={max})")]
35    PoolExhausted {
36        /// Current number of active browser instances.
37        active: usize,
38        /// Pool capacity limit.
39        max: usize,
40    },
41
42    /// An operation exceeded its configured timeout.
43    #[error("Timeout after {duration_ms}ms during '{operation}'")]
44    Timeout {
45        /// The operation that timed out.
46        operation: String,
47        /// Elapsed time in milliseconds.
48        duration_ms: u64,
49    },
50
51    /// Page navigation failed.
52    #[error("Navigation to '{url}' failed: {reason}")]
53    NavigationFailed {
54        /// Target URL.
55        url: String,
56        /// Failure reason.
57        reason: String,
58    },
59
60    /// JavaScript evaluation failed.
61    #[error("Script execution failed: {reason}")]
62    ScriptExecutionFailed {
63        /// Abbreviated script text (first 120 chars).
64        script: String,
65        /// Error detail.
66        reason: String,
67    },
68
69    /// WebSocket / transport connection error.
70    #[error("Browser connection error: {reason}")]
71    ConnectionError {
72        /// Connection endpoint (ws:// URL or socket path).
73        url: String,
74        /// Failure reason.
75        reason: String,
76    },
77
78    /// Invalid configuration value.
79    #[error("Configuration error: {0}")]
80    ConfigError(String),
81
82    /// Proxy acquisition failed — no proxy is available or the circuit breaker is open.
83    #[error("Proxy unavailable: {reason}")]
84    ProxyUnavailable {
85        /// Reason the proxy could not be acquired.
86        reason: String,
87    },
88
89    /// A remote session provider (e.g. Browserbase) rate-limited a
90    /// session-management request (HTTP 429).
91    #[error("Rate limited by remote provider (retry_after_ms={retry_after_ms:?})")]
92    RateLimited {
93        /// Provider-supplied retry delay, in milliseconds, if the
94        /// response included a `Retry-After` header.
95        retry_after_ms: Option<u64>,
96    },
97
98    /// Underlying I/O error.
99    #[error("I/O error: {0}")]
100    Io(#[from] std::io::Error),
101
102    /// The `RemoteObject` reference has been invalidated — the page navigated
103    /// or the DOM node was removed since the [`NodeHandle`][crate::page::NodeHandle]
104    /// was created.
105    #[error("Stale node handle (selector: {selector})")]
106    StaleNode {
107        /// CSS selector that produced the stale handle, for diagnostics.
108        selector: String,
109    },
110
111    /// One or more fields failed during `#[derive(Extract)]`-driven extraction.
112    ///
113    /// Wraps an [`crate::extract::ExtractionError`] produced by the generated
114    /// `Extractable` implementation.
115    #[cfg(feature = "extract")]
116    #[error("extraction failed: {0}")]
117    ExtractionFailed(#[from] crate::extract::ExtractionError),
118}
119
120impl From<chromiumoxide::error::CdpError> for BrowserError {
121    fn from(err: chromiumoxide::error::CdpError) -> Self {
122        Self::CdpError {
123            operation: "unknown".to_string(),
124            message: err.to_string(),
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn launch_failed_display() {
135        let e = BrowserError::LaunchFailed {
136            reason: "binary not found".to_string(),
137        };
138        assert!(e.to_string().contains("binary not found"));
139    }
140
141    #[test]
142    fn pool_exhausted_display() {
143        let e = BrowserError::PoolExhausted {
144            active: 10,
145            max: 10,
146        };
147        assert!(e.to_string().contains("10"));
148    }
149
150    #[test]
151    fn navigation_failed_includes_url() {
152        let e = BrowserError::NavigationFailed {
153            url: "https://example.com".to_string(),
154            reason: "DNS failure".to_string(),
155        };
156        assert!(e.to_string().contains("example.com"));
157        assert!(e.to_string().contains("DNS failure"));
158    }
159
160    #[test]
161    fn timeout_display() {
162        let e = BrowserError::Timeout {
163            operation: "page.load".to_string(),
164            duration_ms: 30_000,
165        };
166        assert!(e.to_string().contains("30000"));
167    }
168
169    #[test]
170    fn cdp_error_display() {
171        let e = BrowserError::CdpError {
172            operation: "Page.navigate".to_string(),
173            message: "Target closed".to_string(),
174        };
175        let s = e.to_string();
176        assert!(s.contains("Page.navigate"));
177        assert!(s.contains("Target closed"));
178    }
179
180    #[test]
181    fn script_execution_failed_display() {
182        let e = BrowserError::ScriptExecutionFailed {
183            script: "document.title".to_string(),
184            reason: "Execution context destroyed".to_string(),
185        };
186        assert!(e.to_string().contains("Execution context destroyed"));
187    }
188
189    #[test]
190    fn connection_error_display() {
191        let e = BrowserError::ConnectionError {
192            url: "ws://127.0.0.1:9222/json/version".to_string(),
193            reason: "connection refused".to_string(),
194        };
195        let s = e.to_string();
196        assert!(s.contains("connection refused"));
197    }
198
199    #[test]
200    fn config_error_display() {
201        let e = BrowserError::ConfigError("pool.max_size must be >= 1".to_string());
202        assert!(e.to_string().contains("pool.max_size"));
203    }
204
205    #[test]
206    fn io_error_wraps_std() {
207        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
208        let e = BrowserError::Io(io);
209        assert!(e.to_string().contains("file not found"));
210    }
211
212    #[test]
213    fn launch_failed_is_debug_printable() {
214        let e = BrowserError::LaunchFailed {
215            reason: "test".to_string(),
216        };
217        assert!(!format!("{e:?}").is_empty());
218    }
219
220    #[test]
221    fn pool_exhausted_reports_both_counts() {
222        let e = BrowserError::PoolExhausted { active: 5, max: 5 };
223        let s = e.to_string();
224        assert!(s.contains("active=5"));
225        assert!(s.contains("max=5"));
226    }
227
228    #[test]
229    fn stale_node_display_contains_selector() {
230        let e = BrowserError::StaleNode {
231            selector: "[data-ux=\"Section\"]".to_string(),
232        };
233        let s = e.to_string();
234        assert!(s.contains("[data-ux=\"Section\"]"), "display: {s}");
235    }
236
237    #[test]
238    fn stale_node_is_debug_printable() {
239        let e = BrowserError::StaleNode {
240            selector: "div.foo".to_string(),
241        };
242        assert!(!format!("{e:?}").is_empty());
243    }
244
245    #[test]
246    fn node_handle_stale_error_display() {
247        let e = BrowserError::StaleNode {
248            selector: "div.foo".to_string(),
249        };
250        let s = e.to_string().to_lowercase();
251        assert!(
252            s.contains("div.foo"),
253            "display should contain selector: {s}"
254        );
255        assert!(s.contains("stale"), "display should contain 'stale': {s}");
256    }
257}