Skip to main content

zendriver_transport/
error.rs

1//! Transport-layer errors.
2
3/// Connection-level failure modes — anything that happens "below" a CDP
4/// response getting routed back to its caller.
5#[derive(Debug, thiserror::Error)]
6#[non_exhaustive]
7pub enum TransportError {
8    /// The WebSocket closed without Chrome having sent a Close frame.
9    #[error("websocket closed unexpectedly")]
10    Disconnected,
11
12    /// Tungstenite raised an error on the underlying WebSocket.
13    #[error("websocket: {0}")]
14    Ws(#[from] tokio_tungstenite::tungstenite::Error),
15
16    /// JSON serialization or framing failed.
17    #[error("framing: {0}")]
18    Frame(#[from] serde_json::Error),
19
20    /// The actor task has been told to shut down — pending calls drain with
21    /// this variant so callers don't hang forever.
22    #[error("connection shut down")]
23    Shutdown,
24
25    /// The actor sent a reply but the oneshot receiver had already been
26    /// dropped. Carries the originating command id for diagnostics.
27    #[error("response channel dropped before reply (id={id})")]
28    ResponseDropped {
29        /// Command id whose reply landed without a receiver.
30        id: u64,
31    },
32
33    /// An I/O error occurred (typically inside tungstenite).
34    #[error("io: {0}")]
35    Io(#[from] std::io::Error),
36}
37
38/// Result of a CDP call: either a transport-level failure, or a structured
39/// JSON-RPC error returned by Chrome. Higher layers (the `zendriver` crate)
40/// map `Rpc` into the typed `ZendriverError::Cdp` variant.
41#[derive(Debug, thiserror::Error)]
42#[non_exhaustive]
43pub enum CallError {
44    /// Connection-level failure (see [`TransportError`]).
45    #[error("transport: {0}")]
46    Transport(#[from] TransportError),
47    /// Chrome answered the command with a structured JSON-RPC error. Carries
48    /// the JSON-RPC `code`, `message`, and optional `data` payload.
49    #[error("CDP RPC error [{0}] {1}")]
50    Rpc(i32, String, Option<serde_json::Value>),
51
52    /// The command was written to the socket but Chrome never answered it
53    /// within the call's budget.
54    ///
55    /// Deliberately distinct from both siblings, because the three mean
56    /// different things and warrant different responses:
57    ///
58    /// - [`CallError::Rpc`] — Chrome heard the command and **said no**. The
59    ///   browser is healthy; the command was wrong. Retrying is pointless.
60    /// - [`CallError::Transport`] — the **connection broke**. Chrome may be
61    ///   gone; the handle is unusable.
62    /// - `Timeout` — the connection is **fine** and Chrome simply never
63    ///   replied. The browser is wedged, or the operation is slower than the
64    ///   budget allows. Retrying (or raising the budget) can be reasonable.
65    ///
66    /// Carries the method name because that is the diagnostic that makes a
67    /// stuck call actionable: "Chrome never answered" is not a bug report,
68    /// "`Page.navigate` went unanswered after 180s" is.
69    #[error("CDP call `{method}` went unanswered after {budget:?}")]
70    Timeout {
71        /// The CDP method that was never answered (e.g. `"Page.navigate"`).
72        method: String,
73        /// The budget that elapsed without a reply.
74        budget: std::time::Duration,
75    },
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn display_disconnected_is_stable() {
84        assert_eq!(
85            TransportError::Disconnected.to_string(),
86            "websocket closed unexpectedly"
87        );
88    }
89
90    #[test]
91    fn display_shutdown_is_stable() {
92        assert_eq!(TransportError::Shutdown.to_string(), "connection shut down");
93    }
94
95    #[test]
96    fn display_response_dropped_includes_id() {
97        let e = TransportError::ResponseDropped { id: 42 };
98        assert_eq!(
99            e.to_string(),
100            "response channel dropped before reply (id=42)"
101        );
102    }
103
104    #[test]
105    fn display_call_timeout_names_the_method_and_budget() {
106        let e = CallError::Timeout {
107            method: "Page.navigate".into(),
108            budget: std::time::Duration::from_secs(180),
109        };
110        assert_eq!(
111            e.to_string(),
112            "CDP call `Page.navigate` went unanswered after 180s"
113        );
114    }
115
116    #[test]
117    fn source_preserved_through_ws_wrap() {
118        // Construct a tungstenite error and wrap it; check source chain works.
119        let tung = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
120        let wrapped = TransportError::Ws(tung);
121        // Display starts with "websocket: "
122        assert!(wrapped.to_string().starts_with("websocket: "));
123        // source() returns the inner
124        assert!(std::error::Error::source(&wrapped).is_some());
125    }
126}