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    /// Underlying I/O error.
90    #[error("I/O error: {0}")]
91    Io(#[from] std::io::Error),
92
93    /// The `RemoteObject` reference has been invalidated — the page navigated
94    /// or the DOM node was removed since the [`NodeHandle`][crate::page::NodeHandle]
95    /// was created.
96    #[error("Stale node handle (selector: {selector})")]
97    StaleNode {
98        /// CSS selector that produced the stale handle, for diagnostics.
99        selector: String,
100    },
101
102    /// One or more fields failed during `#[derive(Extract)]`-driven extraction.
103    ///
104    /// Wraps an [`crate::extract::ExtractionError`] produced by the generated
105    /// `Extractable` implementation.
106    #[cfg(feature = "extract")]
107    #[error("extraction failed: {0}")]
108    ExtractionFailed(#[from] crate::extract::ExtractionError),
109}
110
111impl From<chromiumoxide::error::CdpError> for BrowserError {
112    fn from(err: chromiumoxide::error::CdpError) -> Self {
113        Self::CdpError {
114            operation: "unknown".to_string(),
115            message: err.to_string(),
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn launch_failed_display() {
126        let e = BrowserError::LaunchFailed {
127            reason: "binary not found".to_string(),
128        };
129        assert!(e.to_string().contains("binary not found"));
130    }
131
132    #[test]
133    fn pool_exhausted_display() {
134        let e = BrowserError::PoolExhausted {
135            active: 10,
136            max: 10,
137        };
138        assert!(e.to_string().contains("10"));
139    }
140
141    #[test]
142    fn navigation_failed_includes_url() {
143        let e = BrowserError::NavigationFailed {
144            url: "https://example.com".to_string(),
145            reason: "DNS failure".to_string(),
146        };
147        assert!(e.to_string().contains("example.com"));
148        assert!(e.to_string().contains("DNS failure"));
149    }
150
151    #[test]
152    fn timeout_display() {
153        let e = BrowserError::Timeout {
154            operation: "page.load".to_string(),
155            duration_ms: 30_000,
156        };
157        assert!(e.to_string().contains("30000"));
158    }
159
160    #[test]
161    fn cdp_error_display() {
162        let e = BrowserError::CdpError {
163            operation: "Page.navigate".to_string(),
164            message: "Target closed".to_string(),
165        };
166        let s = e.to_string();
167        assert!(s.contains("Page.navigate"));
168        assert!(s.contains("Target closed"));
169    }
170
171    #[test]
172    fn script_execution_failed_display() {
173        let e = BrowserError::ScriptExecutionFailed {
174            script: "document.title".to_string(),
175            reason: "Execution context destroyed".to_string(),
176        };
177        assert!(e.to_string().contains("Execution context destroyed"));
178    }
179
180    #[test]
181    fn connection_error_display() {
182        let e = BrowserError::ConnectionError {
183            url: "ws://127.0.0.1:9222/json/version".to_string(),
184            reason: "connection refused".to_string(),
185        };
186        let s = e.to_string();
187        assert!(s.contains("connection refused"));
188    }
189
190    #[test]
191    fn config_error_display() {
192        let e = BrowserError::ConfigError("pool.max_size must be >= 1".to_string());
193        assert!(e.to_string().contains("pool.max_size"));
194    }
195
196    #[test]
197    fn io_error_wraps_std() {
198        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
199        let e = BrowserError::Io(io);
200        assert!(e.to_string().contains("file not found"));
201    }
202
203    #[test]
204    fn launch_failed_is_debug_printable() {
205        let e = BrowserError::LaunchFailed {
206            reason: "test".to_string(),
207        };
208        assert!(!format!("{e:?}").is_empty());
209    }
210
211    #[test]
212    fn pool_exhausted_reports_both_counts() {
213        let e = BrowserError::PoolExhausted { active: 5, max: 5 };
214        let s = e.to_string();
215        assert!(s.contains("active=5"));
216        assert!(s.contains("max=5"));
217    }
218
219    #[test]
220    fn stale_node_display_contains_selector() {
221        let e = BrowserError::StaleNode {
222            selector: "[data-ux=\"Section\"]".to_string(),
223        };
224        let s = e.to_string();
225        assert!(s.contains("[data-ux=\"Section\"]"), "display: {s}");
226    }
227
228    #[test]
229    fn stale_node_is_debug_printable() {
230        let e = BrowserError::StaleNode {
231            selector: "div.foo".to_string(),
232        };
233        assert!(!format!("{e:?}").is_empty());
234    }
235
236    #[test]
237    fn node_handle_stale_error_display() {
238        let e = BrowserError::StaleNode {
239            selector: "div.foo".to_string(),
240        };
241        let s = e.to_string().to_lowercase();
242        assert!(
243            s.contains("div.foo"),
244            "display should contain selector: {s}"
245        );
246        assert!(s.contains("stale"), "display should contain 'stale': {s}");
247    }
248}