1use crate::wire::config::ErrorConvention;
11
12#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum ClientError {
19 #[error("auth error: {message}")]
23 Auth {
24 message: String,
26 },
27 #[error("server error: {message}")]
29 Server {
30 message: String,
32 code: Option<String>,
35 },
36 #[error("connection error: {message}")]
40 Connection {
41 message: String,
43 },
44 #[error("timed out")]
47 Timeout,
48 #[error("frame too large: {message}")]
51 FrameTooLarge {
52 message: String,
54 },
55 #[error("decode error: {message}")]
59 Decode {
60 message: String,
62 },
63}
64
65impl ClientError {
66 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
107fn 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
117fn 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 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 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}