Skip to main content

actioncable/
error.rs

1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5use crate::protocol::SUBPROTOCOL_UNSUPPORTED;
6
7/// Everything a caller can get back from a cable. It is `Clone` because a client that stopped
8/// for good hands the same failure to everyone who asks it afterwards.
9///
10/// Which method returns what:
11///
12/// - `build`: [`InvalidHeader`](Error::InvalidHeader), [`NoTransport`](Error::NoTransport).
13/// - `connect`: [`AlreadyConnected`](Error::AlreadyConnected), then whatever stopped the
14///   client: [`Closed`](Error::Closed), [`NoProtocols`](Error::NoProtocols),
15///   [`UnsupportedSubprotocol`](Error::UnsupportedSubprotocol),
16///   [`Disconnected`](Error::Disconnected), [`GaveUp`](Error::GaveUp) or an error
17///   [`ClientBuilder::stop_on_error`](crate::ClientBuilder::stop_on_error) called terminal.
18/// - `subscribe`: [`NotConnected`](Error::NotConnected) before `connect`,
19///   [`Rejected`](Error::Rejected), or whatever stopped the client.
20/// - `perform`, `send` and `unsubscribe`: [`Closed`](Error::Closed),
21///   [`NotConnected`](Error::NotConnected), [`Transport`](Error::Transport),
22///   [`DataNotAnObject`](Error::DataNotAnObject) and [`Json`](Error::Json).
23/// - `Subscription::err`: [`Unsubscribed`](Error::Unsubscribed),
24///   [`Rejected`](Error::Rejected), or whatever stopped the client.
25/// - `Message::decode`: [`Json`](Error::Json).
26///
27/// The built-in transport reports three of its failures by name, for a caller that wants to
28/// act on them rather than read them: [`Handshake`](Error::Handshake),
29/// [`Close`](Error::Close) and [`MessageTooBig`](Error::MessageTooBig). Anything else a
30/// transport hits arrives as [`Transport`](Error::Transport), and whatever a
31/// [`HeaderProvider`](crate::HeaderProvider) failed on as [`Headers`](Error::Headers); both
32/// keep the original error, and [`cause`](Error::cause) hands it back for an application to
33/// recognize with `downcast_ref`.
34#[derive(Debug, Clone, thiserror::Error)]
35#[non_exhaustive]
36pub enum Error {
37    /// The client was closed, or nothing holds it any more. A client that stopped on its own
38    /// hands out what stopped it instead.
39    #[error("client closed")]
40    Closed,
41
42    /// A command couldn't be sent because the connection is down, or up but not yet
43    /// welcomed. Subscriptions recover on their own; a `perform` or `send` that hits this
44    /// is lost and has to be sent again. `subscribe` before `connect` gets it too.
45    #[error("not connected")]
46    NotConnected,
47
48    /// `connect` was called on a client that is already running.
49    #[error("already connected")]
50    AlreadyConnected,
51
52    /// The protocol list was replaced with nothing, so there is nothing to offer the server.
53    #[error("no protocols to offer")]
54    NoProtocols,
55
56    /// No transport was given and the `websocket` feature that supplies the built-in one is
57    /// off, so there is nothing to dial with.
58    #[error("no transport: set one with ClientBuilder::transport or enable the websocket feature")]
59    NoTransport,
60
61    /// The channel's `subscribed` method rejected the subscription. `key` is the identifier
62    /// as the server knows it, [`Identifier::key`](crate::Identifier::key).
63    #[error("subscription rejected: {key}")]
64    Rejected {
65        /// The identifier the server turned down.
66        key: String,
67    },
68
69    /// What [`Subscription::err`](crate::Subscription::err) reports after
70    /// [`unsubscribe`](crate::Subscription::unsubscribe).
71    #[error("unsubscribed")]
72    Unsubscribed,
73
74    /// The server negotiated a subprotocol none of the client's protocols speak, none at
75    /// all, or the `actioncable-unsupported` sentinel that says it speaks none of what was
76    /// offered. Reconnecting won't fix that, so the client stops.
77    #[error(
78        "unsupported subprotocol: {}",
79        describe_subprotocol(negotiated, offered)
80    )]
81    UnsupportedSubprotocol {
82        /// What the server named, empty when it named nothing.
83        negotiated: String,
84        /// The subprotocols that were offered, most preferred first.
85        offered: Vec<String>,
86    },
87
88    /// The server sent a disconnect frame. `reason` is `None` when the frame named none,
89    /// and `reconnect` is what the frame said about coming back: the client stops on a
90    /// disconnect that says not to, and dials again on one that says to.
91    #[error("server disconnected{}", describe_reason(reason.as_ref()))]
92    Disconnected {
93        /// Why the server hung up, as it spelled it.
94        reason: Option<DisconnectReason>,
95        /// Whether the server expects the client back.
96        reconnect: bool,
97    },
98
99    /// As many connection attempts failed in a row as
100    /// [`ClientBuilder::max_attempts`](crate::ClientBuilder::max_attempts) allows, so the
101    /// client stopped. A welcome resets the count, so this bounds one outage rather than the
102    /// client's lifetime.
103    #[error("gave up connecting after {attempts} attempts: {last}")]
104    GaveUp {
105        /// How many attempts failed in a row.
106        attempts: u32,
107        /// What the last of them failed on.
108        #[source]
109        last: Box<Error>,
110    },
111
112    /// The connection went quiet for longer than
113    /// [`ClientBuilder::stale_after`](crate::ClientBuilder::stale_after). The server beats a
114    /// ping every three seconds, so silence means the connection is dead; it is dropped and
115    /// redialed.
116    #[error("no frame in {after:?}")]
117    Stale {
118        /// How long the client waited.
119        after: Duration,
120    },
121
122    /// The server answered the upgrade request with something other than 101 Switching
123    /// Protocols. `status` tells a redirect from a refusal, and prints as the whole status
124    /// line.
125    #[error("server refused the upgrade with {status}")]
126    Handshake {
127        /// What the server answered instead.
128        status: http::StatusCode,
129    },
130
131    /// The server closed the connection with a close frame. `code` is the status code it
132    /// carried, 1005 when it carried none, and `reason` the text after it, if any.
133    #[error("server closed the connection: {}", describe_close(*code, reason))]
134    Close {
135        /// RFC 6455's close code.
136        code: u16,
137        /// What the frame said, empty when it said nothing.
138        reason: String,
139    },
140
141    /// The server sent a message larger than the transport allows. It is refused as soon as
142    /// its length is known, before any of it is read in, and the connection is failed.
143    #[error("message of {size} bytes exceeds the maximum of {limit}")]
144    MessageTooBig {
145        /// The size the frame claimed.
146        size: usize,
147        /// The largest message this transport accepts.
148        limit: usize,
149    },
150
151    /// The transport failed to dial, read or write. A failed dial or read is redialed and
152    /// only logged; a failed write comes back from the `perform`, `send` or `unsubscribe`
153    /// that asked for it, and the connection is dropped and redialed.
154    #[error("transport: {0}")]
155    Transport(#[source] Arc<dyn std::error::Error + Send + Sync>),
156
157    /// The header provider couldn't produce headers for a dial. That dial is turned down
158    /// and the client tries again on its backoff.
159    #[error("building headers: {0}")]
160    Headers(#[source] Arc<dyn std::error::Error + Send + Sync>),
161
162    /// A header given to the builder is not a valid HTTP header.
163    #[error("invalid header {name}: {source}")]
164    InvalidHeader {
165        /// The header that was refused.
166        name: String,
167        /// What `http` said about it.
168        #[source]
169        source: Arc<http::Error>,
170    },
171
172    /// The data given to `perform` encodes to something other than a JSON object, which is
173    /// the only shape Rails will route to an action.
174    #[error("data for {action:?} must encode to a JSON object")]
175    DataNotAnObject {
176        /// The action the data was for.
177        action: String,
178    },
179
180    /// A message didn't decode into the type asked for, data for `perform` or `send` didn't
181    /// encode, or a frame wasn't the JSON the protocol expects.
182    #[error("json: {0}")]
183    Json(#[source] Arc<serde_json::Error>),
184}
185
186impl Error {
187    /// Wraps a transport's own error, for [`Transport`](crate::Transport) and
188    /// [`Conn`](crate::Conn) implementations.
189    pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Error {
190        Error::Transport(Arc::new(source))
191    }
192
193    /// Wraps whatever stopped a [`HeaderProvider`](crate::HeaderProvider) from answering.
194    pub fn headers(source: impl std::error::Error + Send + Sync + 'static) -> Error {
195        Error::Headers(Arc::new(source))
196    }
197
198    /// The error underneath this one, as the transport or the header provider handed it
199    /// over, for a caller that wants to recognize its own with `downcast_ref`:
200    ///
201    /// ```
202    /// # use actioncable::Error;
203    /// # #[derive(Debug, thiserror::Error)]
204    /// # #[error("sign in again")]
205    /// # struct SignedOut;
206    /// let error = Error::headers(SignedOut);
207    ///
208    /// assert!(error.cause().is_some_and(|cause| cause.is::<SignedOut>()));
209    /// ```
210    ///
211    /// [`GaveUp`](Error::GaveUp) hands back what the last attempt failed on, since that is
212    /// the error the caller was waiting out. Everything else has no error underneath it and
213    /// answers `None`.
214    ///
215    /// This is here rather than left to [`source`](std::error::Error::source) because the
216    /// wrapped error is held behind an `Arc`, and a `source` walk hands back the `Arc`
217    /// rather than what is in it. `source` still prints the chain; `cause` is what
218    /// `downcast_ref` works on.
219    pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
220        match self {
221            Error::Transport(cause) | Error::Headers(cause) => Some(&**cause),
222            Error::GaveUp { last, .. } => last.cause(),
223            _ => None,
224        }
225    }
226}
227
228impl From<serde_json::Error> for Error {
229    fn from(source: serde_json::Error) -> Error {
230        Error::Json(Arc::new(source))
231    }
232}
233
234fn describe_subprotocol(negotiated: &str, offered: &[String]) -> String {
235    if negotiated == SUBPROTOCOL_UNSUPPORTED {
236        format!("the server speaks none of {}", offered.join(", "))
237    } else {
238        format!("{negotiated:?}")
239    }
240}
241
242fn describe_reason(reason: Option<&DisconnectReason>) -> String {
243    match reason {
244        Some(reason) => format!(": {reason}"),
245        None => String::new(),
246    }
247}
248
249fn describe_close(code: u16, reason: &str) -> String {
250    if reason.is_empty() {
251        code.to_string()
252    } else {
253        format!("{code} {reason}")
254    }
255}
256
257/// Why an Action Cable server hung up. Rails names four; anything else it might send in the
258/// future arrives as [`Other`](DisconnectReason::Other).
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum DisconnectReason {
261    /// Authentication or authorization failed.
262    Unauthorized,
263    /// The request wasn't a valid Action Cable upgrade.
264    InvalidRequest,
265    /// The Rails server is restarting.
266    ServerRestart,
267    /// The app closed this connection with `ActionCable.server.disconnect`.
268    Remote,
269    /// Something this client doesn't know, as the server spelled it.
270    Other(String),
271}
272
273impl DisconnectReason {
274    /// The reason from its wire spelling, for protocol implementations.
275    pub fn parse(reason: &str) -> DisconnectReason {
276        match reason {
277            "unauthorized" => DisconnectReason::Unauthorized,
278            "invalid_request" => DisconnectReason::InvalidRequest,
279            "server_restart" => DisconnectReason::ServerRestart,
280            "remote" => DisconnectReason::Remote,
281            other => DisconnectReason::Other(other.to_string()),
282        }
283    }
284
285    /// The reason as the server spells it.
286    pub fn as_str(&self) -> &str {
287        match self {
288            DisconnectReason::Unauthorized => "unauthorized",
289            DisconnectReason::InvalidRequest => "invalid_request",
290            DisconnectReason::ServerRestart => "server_restart",
291            DisconnectReason::Remote => "remote",
292            DisconnectReason::Other(other) => other,
293        }
294    }
295}
296
297impl fmt::Display for DisconnectReason {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        f.write_str(self.as_str())
300    }
301}
302
303#[cfg(test)]
304#[allow(
305    clippy::unwrap_used,
306    clippy::expect_used,
307    reason = "a test that can't have what it asked for has nothing left to assert"
308)]
309mod tests {
310    use std::error::Error as _;
311    use std::io;
312
313    use super::*;
314
315    #[test]
316    fn disconnect_reasons_round_trip() {
317        for (wire, reason) in [
318            ("unauthorized", DisconnectReason::Unauthorized),
319            ("invalid_request", DisconnectReason::InvalidRequest),
320            ("server_restart", DisconnectReason::ServerRestart),
321            ("remote", DisconnectReason::Remote),
322            ("moved", DisconnectReason::Other("moved".to_string())),
323        ] {
324            assert_eq!(reason, DisconnectReason::parse(wire));
325            assert_eq!(wire, DisconnectReason::parse(wire).to_string());
326        }
327    }
328
329    #[test]
330    fn a_disconnect_names_its_reason_when_the_server_gave_one() {
331        assert_eq!(
332            "server disconnected: unauthorized",
333            Error::Disconnected {
334                reason: Some(DisconnectReason::Unauthorized),
335                reconnect: false,
336            }
337            .to_string()
338        );
339        assert_eq!(
340            "server disconnected",
341            Error::Disconnected {
342                reason: None,
343                reconnect: false,
344            }
345            .to_string()
346        );
347    }
348
349    #[test]
350    fn unsupported_subprotocol_describes_what_was_offered() {
351        let sentinel = Error::UnsupportedSubprotocol {
352            negotiated: SUBPROTOCOL_UNSUPPORTED.to_string(),
353            offered: vec!["actioncable-v1-json".to_string(), "v2".to_string()],
354        };
355        let unknown = Error::UnsupportedSubprotocol {
356            negotiated: "actioncable-v9-telepathy".to_string(),
357            offered: vec!["actioncable-v1-json".to_string()],
358        };
359
360        assert_eq!(
361            "unsupported subprotocol: the server speaks none of actioncable-v1-json, v2",
362            sentinel.to_string()
363        );
364        assert_eq!(
365            "unsupported subprotocol: \"actioncable-v9-telepathy\"",
366            unknown.to_string()
367        );
368    }
369
370    #[test]
371    fn a_close_reads_as_the_frame_the_server_sent() {
372        assert_eq!(
373            "server closed the connection: 4401 unauthorized",
374            Error::Close {
375                code: 4401,
376                reason: "unauthorized".to_string(),
377            }
378            .to_string()
379        );
380        assert_eq!(
381            "server closed the connection: 1005",
382            Error::Close {
383                code: 1005,
384                reason: String::new(),
385            }
386            .to_string()
387        );
388    }
389
390    #[test]
391    fn a_handshake_failure_reads_as_the_status_line() {
392        let refused = Error::Handshake {
393            status: http::StatusCode::NOT_FOUND,
394        };
395
396        assert_eq!(
397            "server refused the upgrade with 404 Not Found",
398            refused.to_string()
399        );
400    }
401
402    #[test]
403    fn giving_up_keeps_what_the_last_attempt_failed_on() {
404        let gave_up = Error::GaveUp {
405            attempts: 2,
406            last: Box::new(Error::transport(io::Error::other("connection refused"))),
407        };
408
409        assert_eq!(
410            "gave up connecting after 2 attempts: transport: connection refused",
411            gave_up.to_string()
412        );
413        assert_eq!(
414            "connection refused",
415            gave_up
416                .cause()
417                .expect("the last attempt's own error")
418                .to_string()
419        );
420        assert!(gave_up.source().is_some(), "the chain still prints");
421    }
422
423    #[test]
424    fn a_wrapped_error_can_be_recognized_by_its_own_type() {
425        let signed_out = Error::headers(io::Error::other("sign in again"));
426
427        let cause = signed_out
428            .cause()
429            .and_then(<dyn std::error::Error + Send + Sync>::downcast_ref::<io::Error>)
430            .expect("the provider's own error");
431        assert_eq!("sign in again", cause.to_string());
432        assert_eq!(None, Error::Closed.cause().map(ToString::to_string));
433    }
434}