Skip to main content

thunder/client/
error.rs

1//! Typed client errors (CLT-050..052).
2//!
3//! `Result::Err(string)` replies are parsed per the profile's
4//! `error_codes` convention (PRO-014) into a [`ClientError`] carrying the
5//! raw message, an optional machine-readable `code` (from a leading
6//! `"[code] "` prefix), and a stable error **class**. Product SDKs and
7//! user code branch on the class and `code`, never on message text
8//! (CLT-052).
9
10use crate::wire::config::ErrorConvention;
11
12/// The stable error classes of the client contract (CLT-050).
13///
14/// Variants are the public API — matching on them is supported forever.
15/// `Clone` is required so one connection failure can fan out to every
16/// pending call (CLT-014).
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum ClientError {
19    /// Authentication / authorization failure — handshake rejections
20    /// (CLT-003) and `NOAUTH`/`WRONGPASS`/`NOPERM`-prefixed replies
21    /// (CLT-051).
22    #[error("auth error: {message}")]
23    Auth {
24        /// Raw server message, verbatim.
25        message: String,
26    },
27    /// The server answered the call with `Result::Err`.
28    #[error("server error: {message}")]
29    Server {
30        /// Raw server message, verbatim (any `[code]` prefix included).
31        message: String,
32        /// Machine-readable code extracted from a leading `"[code] "`
33        /// prefix under `BracketCode` / `Both` conventions (PRO-014).
34        code: Option<String>,
35    },
36    /// Transport-level failure: dial, write, or the connection dying
37    /// while the call was pending (CLT-004/030/031). Also raised for
38    /// invalid endpoints (CLT-070).
39    #[error("connection error: {message}")]
40    Connection {
41        /// Human-readable cause.
42        message: String,
43    },
44    /// The per-call (or connect) timeout elapsed (CLT-020). The pending
45    /// entry was removed; a late response is dropped per CLT-013.
46    #[error("timed out")]
47    Timeout,
48    /// The server sent a frame larger than the profile cap; the
49    /// connection was poisoned (WIRE-020 via CLT-014).
50    #[error("frame too large: {message}")]
51    FrameTooLarge {
52        /// Human-readable cause.
53        message: String,
54    },
55    /// The server sent a malformed frame (or a push frame under a
56    /// `Reserved` profile, CLT-060); the connection was poisoned
57    /// (CLT-014).
58    #[error("decode error: {message}")]
59    Decode {
60        /// Human-readable cause.
61        message: String,
62    },
63}
64
65impl ClientError {
66    /// Parse a server error string per the profile's convention
67    /// (CLT-050, PRO-014).
68    ///
69    /// - `Resp3Prefixes`: `NOAUTH`/`WRONGPASS`/`NOPERM` → [`Self::Auth`];
70    ///   everything else (`ERR …` included) → [`Self::Server`].
71    /// - `BracketCode`: a leading `"[code] "` is extracted into `code`;
72    ///   the auth prefixes still map to [`Self::Auth`] regardless of
73    ///   convention (CLT-051).
74    /// - `Both`: composes the two — bracket code first, then prefixes.
75    /// - `None`: no parsing; the raw message becomes [`Self::Server`].
76    ///
77    /// `message` always carries the raw string, verbatim.
78    pub fn from_server_message(message: impl Into<String>, convention: ErrorConvention) -> Self {
79        let message = message.into();
80        match convention {
81            ErrorConvention::None => Self::Server {
82                message,
83                code: None,
84            },
85            ErrorConvention::Resp3Prefixes => {
86                if starts_with_auth_prefix(&message) {
87                    Self::Auth { message }
88                } else {
89                    Self::Server {
90                        message,
91                        code: None,
92                    }
93                }
94            }
95            ErrorConvention::BracketCode | ErrorConvention::Both => {
96                let (code, rest) = split_bracket_code(&message);
97                if starts_with_auth_prefix(rest) {
98                    Self::Auth { message }
99                } else {
100                    Self::Server { message, code }
101                }
102            }
103        }
104    }
105}
106
107/// True when the message starts with one of the auth prefixes both
108/// family conventions use for authentication failures (CLT-051).
109fn starts_with_auth_prefix(message: &str) -> bool {
110    ["NOAUTH", "WRONGPASS", "NOPERM"].iter().any(|prefix| {
111        message
112            .strip_prefix(prefix)
113            .is_some_and(|rest| rest.is_empty() || rest.starts_with(' '))
114    })
115}
116
117/// Split a leading `"[code] "` prefix. The code must be non-empty and
118/// whitespace-free (machine-readable); anything else
119/// leaves the message untouched.
120fn split_bracket_code(message: &str) -> (Option<String>, &str) {
121    if let Some(inner) = message.strip_prefix('[') {
122        if let Some(end) = inner.find(']') {
123            let code = &inner[..end];
124            let after = &inner[end + 1..];
125            if !code.is_empty() && !code.contains(char::is_whitespace) {
126                if let Some(rest) = after.strip_prefix(' ') {
127                    return (Some(code.to_owned()), rest);
128                }
129            }
130        }
131    }
132    (None, message)
133}
134
135#[cfg(test)]
136#[allow(clippy::unwrap_used, clippy::expect_used)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn resp3_auth_prefixes_map_to_auth_class() {
142        for msg in [
143            "NOAUTH Authentication required.",
144            "WRONGPASS invalid username-password pair or user is disabled.",
145            "NOPERM this user has no permissions",
146            "NOAUTH",
147        ] {
148            let err = ClientError::from_server_message(msg, ErrorConvention::Resp3Prefixes);
149            assert_eq!(
150                err,
151                ClientError::Auth {
152                    message: msg.to_owned()
153                },
154                "{msg} must map to the auth class (CLT-051)"
155            );
156        }
157    }
158
159    #[test]
160    fn resp3_err_prefix_is_generic_server_error_without_code() {
161        let err =
162            ClientError::from_server_message("ERR unknown command", ErrorConvention::Resp3Prefixes);
163        assert_eq!(
164            err,
165            ClientError::Server {
166                message: "ERR unknown command".to_owned(),
167                code: None,
168            }
169        );
170    }
171
172    #[test]
173    fn resp3_prefix_must_be_word_aligned() {
174        // "NOAUTHx" is not the NOAUTH prefix.
175        let err = ClientError::from_server_message("NOAUTHx nope", ErrorConvention::Resp3Prefixes);
176        assert!(matches!(err, ClientError::Server { .. }));
177    }
178
179    #[test]
180    fn bracket_code_extracts_structured_code_and_keeps_raw_message() {
181        let raw = "[collection_not_found] no such collection: docs";
182        let err = ClientError::from_server_message(raw, ErrorConvention::BracketCode);
183        assert_eq!(
184            err,
185            ClientError::Server {
186                message: raw.to_owned(),
187                code: Some("collection_not_found".to_owned()),
188            }
189        );
190    }
191
192    #[test]
193    fn bracket_code_still_maps_auth_prefixes_to_auth_class() {
194        // CLT-051: auth prefixes win regardless of convention.
195        let raw = "[unauthorized] NOAUTH token expired";
196        let err = ClientError::from_server_message(raw, ErrorConvention::BracketCode);
197        assert_eq!(
198            err,
199            ClientError::Auth {
200                message: raw.to_owned()
201            }
202        );
203    }
204
205    #[test]
206    fn both_convention_composes_bracket_and_prefixes() {
207        let err = ClientError::from_server_message(
208            "[wrongpass] WRONGPASS bad credentials",
209            ErrorConvention::Both,
210        );
211        assert!(matches!(err, ClientError::Auth { .. }));
212
213        let err = ClientError::from_server_message(
214            "[index_missing] ERR no such index",
215            ErrorConvention::Both,
216        );
217        assert_eq!(
218            err,
219            ClientError::Server {
220                message: "[index_missing] ERR no such index".to_owned(),
221                code: Some("index_missing".to_owned()),
222            }
223        );
224    }
225
226    #[test]
227    fn none_convention_never_parses() {
228        let err = ClientError::from_server_message("NOAUTH raw passthrough", ErrorConvention::None);
229        assert_eq!(
230            err,
231            ClientError::Server {
232                message: "NOAUTH raw passthrough".to_owned(),
233                code: None,
234            }
235        );
236    }
237
238    #[test]
239    fn malformed_bracket_prefixes_are_left_alone() {
240        for msg in ["[] empty", "[has space] x", "[nospace]tail", "[unclosed"] {
241            let err = ClientError::from_server_message(msg, ErrorConvention::BracketCode);
242            assert_eq!(
243                err,
244                ClientError::Server {
245                    message: msg.to_owned(),
246                    code: None,
247                },
248                "{msg} must not yield a code"
249            );
250        }
251    }
252}