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}