Skip to main content

chia_query/peer/light_client/
error.rs

1//! [`LightClientError`] — why a light-client read could not complete, and its fail-closed mapping
2//! onto the canonical [`dig_chainsource_interface::ChainSourceError`].
3//!
4//! Deliberately NOT [`ChiaQueryError`](crate::ChiaQueryError). That type is the async router's, and
5//! it is neither `Clone` nor `PartialEq` because it carries a boxed cause chain across the
6//! peer/coinset race. This one crosses a SYNCHRONOUS [`ChainSource`] boundary where every variant
7//! must answer one question — *may this be reported as an absence?* — and the answer is always no.
8//!
9//! Every variant means the same thing to a consumer: **the peer could not reliably answer**. None of
10//! them is ever collapsed into an absence (`Ok(None)`) — that distinction is the crux of the
11//! [`ChainSource`](dig_chainsource_interface::ChainSource) fail-closed contract, so the mapping below
12//! turns every `LightClientError` into an `Err`, never a value.
13
14use dig_chainsource_interface::ChainSourceError;
15use thiserror::Error;
16
17/// The reason a Chia light-client peer read could not complete reliably.
18#[derive(Debug, Error, Clone, PartialEq, Eq)]
19pub enum LightClientError {
20    /// A transport/connection failure reaching the peer (socket, websocket, TLS). Carries the
21    /// backend's own message for diagnostics.
22    #[error("chia peer transport error: {0}")]
23    Transport(String),
24
25    /// The peer explicitly rejected the request (e.g. a reorg or subscription-limit rejection). The
26    /// answer is unknown, so the consumer must fail closed.
27    #[error("chia peer rejected the request: {0}")]
28    Rejected(String),
29
30    /// The peer responded but the payload could not be parsed into the expected chain type. The read
31    /// is untrustworthy, so fail closed.
32    #[error("malformed chia peer response: {0}")]
33    Malformed(String),
34
35    /// A request did not complete within the configured deadline. Whether the answer would have been
36    /// present is unknown, so fail closed.
37    #[error("chia peer request timed out")]
38    Timeout,
39
40    /// No usable full-node peer could be discovered/connected.
41    #[error("chia peer discovery failed")]
42    PeerDiscoveryFailed,
43
44    /// The light client is not currently connected to any peer.
45    #[error("chia light client is not connected")]
46    NotConnected,
47}
48
49impl LightClientError {
50    /// Whether this error's message names a timeout, so a timed-out transport string classifies as
51    /// [`ChainSourceError::Timeout`] rather than a generic transport failure.
52    fn looks_like_timeout(message: &str) -> bool {
53        let lower = message.to_ascii_lowercase();
54        lower.contains("timed out") || lower.contains("timeout")
55    }
56}
57
58impl From<LightClientError> for ChainSourceError {
59    /// Maps every peer error to a fail-closed [`ChainSourceError`]. Each variant is a "could not
60    /// reliably answer" signal — NEVER an `Ok(None)`; only the reason class differs, for diagnostics.
61    fn from(error: LightClientError) -> Self {
62        match error {
63            LightClientError::Timeout => ChainSourceError::Timeout,
64            LightClientError::Transport(msg) if LightClientError::looks_like_timeout(&msg) => {
65                ChainSourceError::Timeout
66            }
67            LightClientError::Transport(msg) => ChainSourceError::Transport(msg),
68            LightClientError::Rejected(msg) => ChainSourceError::Transport(msg),
69            LightClientError::Malformed(msg) => ChainSourceError::Malformed(msg),
70            LightClientError::PeerDiscoveryFailed => {
71                ChainSourceError::Transport("peer discovery failed".to_string())
72            }
73            LightClientError::NotConnected => {
74                ChainSourceError::Transport("light client is not connected".to_string())
75            }
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn timeout_variant_maps_to_timeout() {
86        assert_eq!(
87            ChainSourceError::from(LightClientError::Timeout),
88            ChainSourceError::Timeout
89        );
90    }
91
92    #[test]
93    fn timed_out_transport_message_maps_to_timeout() {
94        let mapped =
95            ChainSourceError::from(LightClientError::Transport("request timed out".into()));
96        assert_eq!(mapped, ChainSourceError::Timeout);
97    }
98
99    #[test]
100    fn plain_transport_maps_to_transport_never_absence() {
101        let mapped = ChainSourceError::from(LightClientError::Transport("socket reset".into()));
102        assert_eq!(mapped, ChainSourceError::Transport("socket reset".into()));
103    }
104
105    #[test]
106    fn rejected_maps_to_transport() {
107        let mapped = ChainSourceError::from(LightClientError::Rejected("reorg".into()));
108        assert!(matches!(mapped, ChainSourceError::Transport(_)));
109    }
110
111    #[test]
112    fn malformed_maps_to_malformed() {
113        let mapped = ChainSourceError::from(LightClientError::Malformed("bad bytes".into()));
114        assert!(matches!(mapped, ChainSourceError::Malformed(_)));
115    }
116
117    #[test]
118    fn not_connected_and_discovery_map_to_transport() {
119        assert!(matches!(
120            ChainSourceError::from(LightClientError::NotConnected),
121            ChainSourceError::Transport(_)
122        ));
123        assert!(matches!(
124            ChainSourceError::from(LightClientError::PeerDiscoveryFailed),
125            ChainSourceError::Transport(_)
126        ));
127    }
128}