Skip to main content

lightstreamer_rs/
error.rs

1//! The public error taxonomy.
2//!
3//! There is one variant per condition a caller can meaningfully react to, and
4//! nothing is stringly typed: a code the server supplied is kept as a number
5//! in a structured field so it can be matched on, never folded into a message
6//! [`docs/spec/05-error-codes.md` §1].
7//!
8//! # No dependency's error type is exposed
9//!
10//! Every error this crate produces is one of its own, and every one of them is
11//! defined **here** — including [`TransportError`], which the private transport
12//! port produces but does not own. A type that is part of the semver promise
13//! belongs to the module that makes it; leaving it in the port would have made
14//! an adapter's internal decision a public commitment, and would have
15//! contradicted the promise that adding a transport is not a semver event
16//! (`docs/adr/0007-transport-port-shape.md`).
17//!
18//! The single boxed cause is [`TransportError::Connect`]'s, which hides the
19//! underlying failure behind `dyn std::error::Error` — a box, not a named type,
20//! so no third-party crate's semantic version becomes part of this crate's. It
21//! is for reading, not for downcasting; see the type.
22//!
23//! # No unreachable variant, either
24//!
25//! Every variant of [`Error`] is one this crate can produce or one a caller
26//! can meaningfully build — see the note on the type. A variant that merely
27//! *sounded* plausible would be worse than a missing one: it invites a match
28//! arm that never runs and a recovery path that is never tested. The parser's
29//! own failures are deliberately not among them. A line this client cannot
30//! read is never fatal and never a `Result`: it arrives as
31//! [`SessionEvent::Unrecognized`](crate::SessionEvent), with the line intact,
32//! so that a server newer than this crate cannot break it.
33//!
34//! # The two code catalogs
35//!
36//! TLCP has exactly two catalogs of numeric codes, and they overlap: fourteen
37//! numbers appear in both with different meanings
38//! [`docs/spec/05-error-codes.md` §2]. Which catalog a code belongs to is
39//! therefore not something a caller can work out from the number, so this
40//! taxonomy encodes it in the variant:
41//!
42//! | Variant | Catalog | Carried by |
43//! |---|---|---|
44//! | [`Error::Session`] | Appendix A, *Session Error Codes* | `CONERR`, `END` |
45//! | [`Error::Request`] | Appendix B, *Control Error Codes* | `REQERR`, `ERROR`, `MSGFAIL` |
46//!
47//! Matching on a code without knowing its catalog is a bug waiting to happen —
48//! code `20` means "session not found on a bind request" in one and "session
49//! not found" in the other, and code `48` means "maximum session duration
50//! reached" in one and "MPN device suspended" in the other.
51
52use std::fmt;
53
54use crate::client::DisconnectReason;
55use crate::config::ConfigError;
56use crate::session::{ServerCause, SessionClosed};
57
58/// The result type returned throughout this crate.
59pub type Result<T> = std::result::Result<T, Error>;
60
61/// Something went wrong moving bytes. Carries no protocol meaning.
62///
63/// **This type belongs to the public taxonomy, not to a transport.** It is
64/// defined here, beside [`Error`], because the distinction it draws is one a
65/// caller can act on — failing to *connect* and losing an *established* stream
66/// call for different responses — and because a type in this crate's semver
67/// promise must be owned by the module that makes that promise. The transports
68/// themselves stay private and merely produce these; adding one is not a semver
69/// event (`docs/adr/0007-transport-port-shape.md`), and it must not become one
70/// by an adapter widening this enum on its own account.
71///
72/// # On the cause of [`TransportError::Connect`]
73///
74/// It is a `Box<dyn Error>` rather than a named type so that no dependency's
75/// semantic version becomes part of this crate's. Read it, log it, print its
76/// chain — but do not downcast it: which concrete error arrives there depends
77/// on which transport ran and on the version of the crate underneath it, and
78/// neither is part of the promise made here. Everything worth branching on is
79/// the variant.
80#[derive(Debug, thiserror::Error)]
81#[non_exhaustive]
82pub enum TransportError {
83    /// The connection could not be established.
84    #[error("cannot connect to {target}: {source}")]
85    Connect {
86        /// What was being connected to. Never carries userinfo: an address with
87        /// a `user:password@` prefix is refused at configuration time, and what
88        /// reaches here is redacted regardless.
89        target: String,
90        /// The underlying failure. Opaque by design — see the type docs.
91        #[source]
92        source: Box<dyn std::error::Error + Send + Sync>,
93    },
94
95    /// The connection failed or was dropped while in use.
96    ///
97    /// This is transition T5 [`docs/spec/02-session-lifecycle.md` §2.2]: no
98    /// notification is received, the session is *not* closed, and the client
99    /// is still entitled to attempt recovery.
100    #[error("stream connection lost: {reason}")]
101    ConnectionLost {
102        /// What ended it.
103        reason: String,
104    },
105
106    /// A control request could not be delivered.
107    #[error("cannot send control request `{name}`: {reason}")]
108    Send {
109        /// The request name, as TLCP spells it — `control`, `msg`, `heartbeat`
110        /// [`docs/spec/03-requests.md` §1].
111        name: &'static str,
112        /// What went wrong.
113        reason: String,
114    },
115
116    /// The peer sent a frame that cannot be turned into TLCP lines — a binary
117    /// WebSocket message, or bytes that are not valid UTF-8.
118    #[error("malformed frame: {reason}")]
119    MalformedFrame {
120        /// What was wrong with it.
121        reason: String,
122    },
123
124    /// The peer sent more than this client is willing to buffer for it.
125    ///
126    /// Every dimension a server controls is capped, because a client that
127    /// allocates whatever it is told to is one malformed stream away from
128    /// taking its process with it. The connection is not usable afterwards:
129    /// what was buffered has been discarded, so the line stream has a hole in
130    /// it and the session must be recovered rather than resumed.
131    #[error("{reason}, exceeding this client's limit of {limit_bytes} bytes")]
132    Capacity {
133        /// The ceiling that was exceeded.
134        limit_bytes: usize,
135        /// What exceeded it. Never contains a credential.
136        reason: String,
137    },
138}
139
140/// A code and message exactly as the server supplied them.
141///
142/// The code is preserved as a number so a caller can branch on it; the message
143/// is whatever human-readable text accompanied it, and may be empty. Which
144/// catalog the code comes from is determined by the [`Error`] variant carrying
145/// this value — see the module documentation.
146///
147/// # Examples
148///
149/// ```
150/// use lightstreamer_rs::Error;
151///
152/// fn is_bad_credentials(error: &Error) -> bool {
153///     // Appendix A code 1 is "user/password check failed".
154///     matches!(error, Error::Session(cause) if cause.code() == 1)
155/// }
156/// ```
157#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
158pub struct ServerError {
159    code: i64,
160    message: String,
161}
162
163impl ServerError {
164    /// Wraps a code and message as the server sent them.
165    #[must_use]
166    pub fn new(code: i64, message: impl Into<String>) -> Self {
167        Self {
168            code,
169            message: message.into(),
170        }
171    }
172
173    /// The numeric code, exactly as received.
174    #[must_use]
175    #[inline]
176    pub const fn code(&self) -> i64 {
177        self.code
178    }
179
180    /// The accompanying text, which the server is allowed to leave empty.
181    #[must_use]
182    #[inline]
183    pub fn message(&self) -> &str {
184        &self.message
185    }
186
187    /// Whether this code came from the server's **Metadata Adapter** rather
188    /// than from the protocol's own catalog.
189    ///
190    /// A code of `0` or below is supplied by the Adapter and its meaning is
191    /// entirely application-specific: no interpretation in
192    /// `docs/spec/05-error-codes.md` applies to it, and only the application
193    /// that wrote the Adapter knows what it means
194    /// [`docs/spec/05-error-codes.md` §2].
195    #[must_use]
196    #[inline]
197    pub const fn is_adapter_defined(&self) -> bool {
198        self.code <= 0
199    }
200}
201
202impl fmt::Display for ServerError {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        if self.message.is_empty() {
205            write!(f, "code {}", self.code)
206        } else {
207            write!(f, "code {}: {}", self.code, self.message)
208        }
209    }
210}
211
212impl From<ServerCause> for ServerError {
213    fn from(cause: ServerCause) -> Self {
214        Self {
215            code: cause.code,
216            message: cause.message,
217        }
218    }
219}
220
221/// Anything that can go wrong in a Lightstreamer client.
222///
223/// The variants are grouped by what a caller can do about them:
224///
225/// - [`Error::Config`] — fix the configuration; nothing was sent.
226/// - [`Error::Session`], [`Error::Request`] — the server said no, and told you
227///   why with a code. Branch on the code.
228/// - [`Error::ReconnectExhausted`] — the connection kept failing and this
229///   crate has stopped trying. Build a new client when you want to try again.
230/// - [`Error::Disconnected`] — the client is shut down. Nothing is wrong; it
231///   is simply over.
232/// - [`Error::Transport`] — bytes could not be moved. Usually a network or
233///   TLS problem.
234/// - [`Error::ForeignSubscription`] — a handle from another client. Nothing
235///   was sent.
236/// - [`Error::Internal`] — a bug in this crate. Please report it.
237///
238/// # Which of these this crate returns, and which you construct
239///
240/// Everything above is *returned* by some call, except one:
241/// [`Error::Request`] is the way to **propagate** a refusal that arrived as an
242/// event. A refused control request is not the return value of anything —
243/// it reaches you as [`SessionEvent::RequestRejected`](crate::SessionEvent) or
244/// [`SubscriptionEvent::Rejected`](crate::SubscriptionEvent), because it
245/// happens long after the call that caused it returned. Wrapping the
246/// [`ServerError`] it carries in `Error::Request` is what lets an application
247/// hand it to `?` alongside the errors this crate returns, and
248/// [`ClosedReason::into_error`](crate::ClosedReason::into_error) does the same
249/// for the session-level events.
250#[derive(Debug, thiserror::Error)]
251#[non_exhaustive]
252pub enum Error {
253    /// A configuration value could not be used as given. Nothing was sent to
254    /// any server.
255    #[error("configuration error: {0}")]
256    Config(#[from] ConfigError),
257
258    /// The server refused to create or bind the session, or ended one that was
259    /// running, with a code from **Appendix A**
260    /// [`docs/spec/05-error-codes.md` §2].
261    ///
262    /// This crate reached this error only after deciding the code admits no
263    /// retry — for the codes the specification marks as temporary it keeps
264    /// trying, and you learn about that through the session event stream
265    /// instead.
266    #[error("session error: {0}")]
267    Session(ServerError),
268
269    /// The server refused a control request — a subscription, an
270    /// unsubscription, a reconfiguration or a message — with a code from
271    /// **Appendix B** [`docs/spec/05-error-codes.md` §2].
272    ///
273    /// The session itself is unaffected and remains usable.
274    #[error("request rejected: {0}")]
275    Request(ServerError),
276
277    /// The connection kept failing and the reconnection budget ran out. The
278    /// session is definitively lost and this client will not try again.
279    ///
280    /// The budget is [`RetryPolicy`](crate::RetryPolicy); raise it, or set it
281    /// to unlimited, if giving up is not what you want.
282    #[error("gave up reconnecting after {attempts} consecutive failed attempts")]
283    ReconnectExhausted {
284        /// How many consecutive attempts failed.
285        attempts: u32,
286        /// Why the last attempt failed, when there was one to report.
287        ///
288        /// Kept because the count on its own is not diagnosable: "eight
289        /// attempts failed" is the same sentence whether the credentials were
290        /// refused, the host did not resolve, or the connection kept going
291        /// silent. Boxed only to keep [`Error`] small.
292        last: Option<Box<DisconnectReason>>,
293    },
294
295    /// The client is no longer connected, because it was disconnected or
296    /// dropped.
297    ///
298    /// Not a failure in itself: it is what every pending operation returns
299    /// once the session has stopped.
300    #[error("the client is disconnected")]
301    Disconnected,
302
303    /// Something went wrong moving bytes — connecting, sending, or receiving.
304    #[error("transport error: {0}")]
305    Transport(#[from] TransportError),
306
307    /// A [`SubscriptionId`](crate::SubscriptionId) created by a different
308    /// client was passed to
309    /// [`Client::unsubscribe`](crate::Client::unsubscribe).
310    ///
311    /// Nothing was sent. Each client numbers its subscriptions from one, so
312    /// the same number names a different subscription on each of them; acting
313    /// on it would have cancelled somebody else's data.
314    #[error("subscription {id} was not created by this client")]
315    ForeignSubscription {
316        /// The number the handle carried, as
317        /// [`SubscriptionId::get`](crate::SubscriptionId::get) reports it.
318        id: u64,
319    },
320
321    /// This crate could not do something it should always be able to do.
322    ///
323    /// A bug, never a server condition or a configuration mistake.
324    #[error("internal error: {reason}")]
325    Internal {
326        /// What failed. Never contains a credential.
327        reason: String,
328    },
329}
330
331impl Error {
332    /// The server's code and message, when the server supplied one.
333    ///
334    /// A convenience for the common "log the code, then decide" shape. Use the
335    /// variant itself when you need to know which catalog the code came from.
336    ///
337    /// # Examples
338    ///
339    /// ```
340    /// use lightstreamer_rs::{Error, ServerError};
341    ///
342    /// let error = Error::Request(ServerError::new(19, "Specified subscription not found"));
343    /// assert_eq!(error.server_error().map(ServerError::code), Some(19));
344    ///
345    /// assert!(Error::Disconnected.server_error().is_none());
346    /// ```
347    #[must_use]
348    #[inline]
349    pub const fn server_error(&self) -> Option<&ServerError> {
350        match self {
351            Self::Session(cause) | Self::Request(cause) => Some(cause),
352            _ => None,
353        }
354    }
355
356    /// Whether the session behind this error is gone for good.
357    ///
358    /// True for a server-side refusal, an exhausted reconnection budget and a
359    /// disconnected client; false for a rejected control request, which leaves
360    /// the session running, and for a configuration mistake, which never
361    /// reached a server.
362    #[must_use]
363    #[inline]
364    pub const fn is_session_terminal(&self) -> bool {
365        matches!(
366            self,
367            Self::Session(_) | Self::ReconnectExhausted { .. } | Self::Disconnected
368        )
369    }
370}
371
372impl From<SessionClosed> for Error {
373    /// Maps the session layer's terminal reason onto the public taxonomy.
374    ///
375    /// Total by construction: every way a session can end has exactly one
376    /// error a caller can act on.
377    fn from(closed: SessionClosed) -> Self {
378        match closed {
379            SessionClosed::ByClient { .. } => Self::Disconnected,
380            SessionClosed::ByServer { cause } => Self::Session(cause.into()),
381            SessionClosed::RetriesExhausted { attempts, last } => Self::ReconnectExhausted {
382                attempts,
383                last: last.map(|reason| Box::new(DisconnectReason::from(reason))),
384            },
385            SessionClosed::Internal { reason } => Self::Internal { reason },
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_server_error_preserves_the_code_as_a_number() {
396        let cause = ServerError::new(48, "maximum session duration reached");
397        assert_eq!(cause.code(), 48);
398        assert_eq!(cause.message(), "maximum session duration reached");
399        assert!(!cause.is_adapter_defined());
400    }
401
402    #[test]
403    fn test_server_error_recognizes_adapter_defined_codes() {
404        // "If the code is 0 or negative, it has been supplied by the Metadata
405        // Adapter" [`docs/spec/05-error-codes.md` §2].
406        assert!(ServerError::new(0, "").is_adapter_defined());
407        assert!(ServerError::new(-7, "custom").is_adapter_defined());
408        assert!(!ServerError::new(1, "").is_adapter_defined());
409    }
410
411    #[test]
412    fn test_server_error_display_omits_an_empty_message() {
413        assert_eq!(ServerError::new(20, "").to_string(), "code 20");
414        assert_eq!(ServerError::new(20, "gone").to_string(), "code 20: gone");
415    }
416
417    #[test]
418    fn test_error_exposes_the_server_error_from_both_catalogs() {
419        let session = Error::Session(ServerError::new(1, "auth failed"));
420        let request = Error::Request(ServerError::new(23, "bad field schema"));
421        assert_eq!(session.server_error().map(ServerError::code), Some(1));
422        assert_eq!(request.server_error().map(ServerError::code), Some(23));
423        assert!(Error::Disconnected.server_error().is_none());
424        assert!(
425            Error::ReconnectExhausted {
426                attempts: 3,
427                last: None
428            }
429            .server_error()
430            .is_none()
431        );
432    }
433
434    #[test]
435    fn test_error_knows_which_conditions_end_the_session() {
436        assert!(Error::Session(ServerError::new(1, "")).is_session_terminal());
437        assert!(
438            Error::ReconnectExhausted {
439                attempts: 8,
440                last: None
441            }
442            .is_session_terminal()
443        );
444        assert!(Error::Disconnected.is_session_terminal());
445        assert!(!Error::Request(ServerError::new(19, "")).is_session_terminal());
446        assert!(!Error::Config(ConfigError::EmptyServerAddress).is_session_terminal());
447    }
448
449    #[test]
450    fn test_session_closed_maps_onto_the_public_taxonomy() {
451        let by_client = Error::from(SessionClosed::ByClient {
452            destroy_confirmed: true,
453            cause: None,
454        });
455        assert!(matches!(by_client, Error::Disconnected));
456
457        let by_server = Error::from(SessionClosed::ByServer {
458            cause: ServerCause {
459                code: 2,
460                message: "Requested Adapter Set not available".to_owned(),
461            },
462        });
463        assert!(matches!(by_server, Error::Session(cause) if cause.code() == 2));
464
465        let exhausted = Error::from(SessionClosed::RetriesExhausted {
466            attempts: 8,
467            last: None,
468        });
469        assert!(matches!(
470            exhausted,
471            Error::ReconnectExhausted {
472                attempts: 8,
473                last: None
474            }
475        ));
476
477        let internal = Error::from(SessionClosed::Internal {
478            reason: "cannot encode".to_owned(),
479        });
480        assert!(matches!(internal, Error::Internal { .. }));
481    }
482
483    #[test]
484    fn test_exhaustion_keeps_the_last_thing_that_went_wrong() {
485        // A-04: the attempt count alone cannot be acted on. What failed the
486        // last time must survive the mapping into the public taxonomy.
487        let closed = SessionClosed::RetriesExhausted {
488            attempts: 8,
489            last: Some(crate::session::UnbindReason::KeepaliveExpired {
490                budget: std::time::Duration::from_secs(8),
491            }),
492        };
493        match Error::from(closed) {
494            Error::ReconnectExhausted {
495                attempts: 8,
496                last: Some(reason),
497            } => assert!(matches!(*reason, DisconnectReason::Stalled { .. })),
498            other => panic!("the last cause was discarded: {other:?}"),
499        }
500
501        // And a refusal keeps its server code rather than becoming a count.
502        let refused = SessionClosed::RetriesExhausted {
503            attempts: 3,
504            last: Some(crate::session::UnbindReason::Rejected {
505                cause: ServerCause {
506                    code: 1,
507                    message: "User/password check failed".to_owned(),
508                },
509            }),
510        };
511        match Error::from(refused) {
512            Error::ReconnectExhausted {
513                last: Some(reason), ..
514            } => {
515                assert!(matches!(*reason, DisconnectReason::Refused(cause) if cause.code() == 1));
516            }
517            other => panic!("the last cause was discarded: {other:?}"),
518        }
519    }
520
521    #[test]
522    fn test_config_error_converts_into_the_public_error() {
523        let error: Error = ConfigError::EmptyServerAddress.into();
524        assert!(matches!(
525            error,
526            Error::Config(ConfigError::EmptyServerAddress)
527        ));
528    }
529}