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}