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}