Skip to main content

ans_client/
error.rs

1//! Error types for the ANS client.
2
3use thiserror::Error;
4
5/// Result type alias for ANS client operations.
6pub type Result<T> = std::result::Result<T, ClientError>;
7
8/// HTTP transport error wrapper.
9///
10/// Wraps the underlying HTTP client error to avoid exposing third-party
11/// types in the public API.
12#[derive(Debug)]
13pub struct HttpError {
14    inner: reqwest::Error,
15}
16
17impl std::fmt::Display for HttpError {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        self.inner.fmt(f)
20    }
21}
22
23impl std::error::Error for HttpError {
24    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
25        self.inner.source()
26    }
27}
28
29impl From<reqwest::Error> for HttpError {
30    fn from(err: reqwest::Error) -> Self {
31        Self { inner: err }
32    }
33}
34
35/// Errors that can occur when using the ANS client.
36#[derive(Debug, Error)]
37#[non_exhaustive]
38pub enum ClientError {
39    /// Authentication failed (401).
40    #[error("[{status_code}] unauthorized: {message}")]
41    Unauthorized {
42        /// HTTP status code.
43        status_code: u16,
44        /// Error message from the server.
45        message: String,
46    },
47
48    /// Insufficient permissions (403).
49    #[error("[{status_code}] forbidden: {message}")]
50    Forbidden {
51        /// HTTP status code.
52        status_code: u16,
53        /// Error message from the server.
54        message: String,
55    },
56
57    /// Resource not found (404).
58    #[error("[{status_code}] not found: {message}")]
59    NotFound {
60        /// HTTP status code.
61        status_code: u16,
62        /// Error message from the server.
63        message: String,
64    },
65
66    /// Resource conflict (409).
67    #[error("[{status_code}] conflict: {message}")]
68    Conflict {
69        /// HTTP status code.
70        status_code: u16,
71        /// Error message from the server.
72        message: String,
73    },
74
75    /// Invalid request (400).
76    #[error("[{status_code}] invalid request: {message}")]
77    InvalidRequest {
78        /// HTTP status code.
79        status_code: u16,
80        /// Error message from the server.
81        message: String,
82    },
83
84    /// Unprocessable entity (422).
85    #[error("[{status_code}] unprocessable entity: {message}")]
86    UnprocessableEntity {
87        /// HTTP status code.
88        status_code: u16,
89        /// Error message from the server.
90        message: String,
91    },
92
93    /// Rate limited (429).
94    #[error("[{status_code}] rate limited: {message}")]
95    RateLimited {
96        /// HTTP status code.
97        status_code: u16,
98        /// Error message from the server.
99        message: String,
100    },
101
102    /// Server error (5xx).
103    #[error("[{status_code}] server error: {message}")]
104    ServerError {
105        /// HTTP status code.
106        status_code: u16,
107        /// Error message from the server.
108        message: String,
109    },
110
111    /// HTTP transport error.
112    #[error("http error: {0}")]
113    Http(#[from] HttpError),
114
115    /// JSON serialization/deserialization error.
116    #[error("json error: {0}")]
117    Json(#[from] serde_json::Error),
118
119    /// URL parsing error.
120    #[error("invalid url: {0}")]
121    InvalidUrl(String),
122
123    /// Configuration error.
124    #[error("configuration error: {0}")]
125    Configuration(String),
126}
127
128/// API error response from the server.
129#[derive(Debug, Clone, serde::Deserialize)]
130#[non_exhaustive]
131pub struct ApiErrorResponse {
132    /// Error status.
133    pub status: String,
134    /// Error code.
135    pub code: String,
136    /// Error message.
137    pub message: String,
138    /// Additional error details.
139    #[serde(default)]
140    pub details: serde_json::Value,
141}
142
143impl ClientError {
144    /// Create an error from an HTTP status code and response body.
145    pub fn from_response(status_code: u16, body: &str) -> Self {
146        let message = serde_json::from_str::<ApiErrorResponse>(body)
147            .map_or_else(|_| body.to_string(), |e| e.message);
148
149        match status_code {
150            401 => Self::Unauthorized {
151                status_code,
152                message,
153            },
154            403 => Self::Forbidden {
155                status_code,
156                message,
157            },
158            404 => Self::NotFound {
159                status_code,
160                message,
161            },
162            409 => Self::Conflict {
163                status_code,
164                message,
165            },
166            400 => Self::InvalidRequest {
167                status_code,
168                message,
169            },
170            422 => Self::UnprocessableEntity {
171                status_code,
172                message,
173            },
174            429 => Self::RateLimited {
175                status_code,
176                message,
177            },
178            500..=599 => Self::ServerError {
179                status_code,
180                message,
181            },
182            _ => Self::ServerError {
183                status_code,
184                message: format!("unexpected status {status_code}: {message}"),
185            },
186        }
187    }
188
189    /// Get the HTTP status code if this error originated from an HTTP response.
190    pub fn status_code(&self) -> Option<u16> {
191        match self {
192            Self::Unauthorized { status_code, .. }
193            | Self::Forbidden { status_code, .. }
194            | Self::NotFound { status_code, .. }
195            | Self::Conflict { status_code, .. }
196            | Self::InvalidRequest { status_code, .. }
197            | Self::UnprocessableEntity { status_code, .. }
198            | Self::RateLimited { status_code, .. }
199            | Self::ServerError { status_code, .. } => Some(*status_code),
200            _ => None,
201        }
202    }
203}
204
205#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    // ── from_response: every status code branch ──────────────────────
211
212    #[test]
213    fn from_response_401() {
214        let err = ClientError::from_response(401, "bad creds");
215        assert!(matches!(
216            err,
217            ClientError::Unauthorized {
218                status_code: 401,
219                ..
220            }
221        ));
222        assert_eq!(err.status_code(), Some(401));
223        assert!(err.to_string().contains("unauthorized"));
224    }
225
226    #[test]
227    fn from_response_403() {
228        let err = ClientError::from_response(403, "denied");
229        assert!(matches!(
230            err,
231            ClientError::Forbidden {
232                status_code: 403,
233                ..
234            }
235        ));
236        assert_eq!(err.status_code(), Some(403));
237    }
238
239    #[test]
240    fn from_response_404() {
241        let err = ClientError::from_response(404, "gone");
242        assert!(matches!(
243            err,
244            ClientError::NotFound {
245                status_code: 404,
246                ..
247            }
248        ));
249        assert_eq!(err.status_code(), Some(404));
250    }
251
252    #[test]
253    fn from_response_409() {
254        let err = ClientError::from_response(409, "conflict");
255        assert!(matches!(
256            err,
257            ClientError::Conflict {
258                status_code: 409,
259                ..
260            }
261        ));
262        assert_eq!(err.status_code(), Some(409));
263    }
264
265    #[test]
266    fn from_response_400() {
267        let err = ClientError::from_response(400, "bad req");
268        assert!(matches!(
269            err,
270            ClientError::InvalidRequest {
271                status_code: 400,
272                ..
273            }
274        ));
275        assert_eq!(err.status_code(), Some(400));
276    }
277
278    #[test]
279    fn from_response_422() {
280        let err = ClientError::from_response(422, "unprocessable");
281        assert!(matches!(
282            err,
283            ClientError::UnprocessableEntity {
284                status_code: 422,
285                ..
286            }
287        ));
288        assert_eq!(err.status_code(), Some(422));
289    }
290
291    #[test]
292    fn from_response_429() {
293        let err = ClientError::from_response(429, "slow down");
294        assert!(matches!(
295            err,
296            ClientError::RateLimited {
297                status_code: 429,
298                ..
299            }
300        ));
301        assert_eq!(err.status_code(), Some(429));
302    }
303
304    #[test]
305    fn from_response_500() {
306        let err = ClientError::from_response(500, "oops");
307        assert!(matches!(
308            err,
309            ClientError::ServerError {
310                status_code: 500,
311                ..
312            }
313        ));
314        assert_eq!(err.status_code(), Some(500));
315    }
316
317    #[test]
318    fn from_response_503() {
319        let err = ClientError::from_response(503, "unavailable");
320        assert!(matches!(
321            err,
322            ClientError::ServerError {
323                status_code: 503,
324                ..
325            }
326        ));
327    }
328
329    #[test]
330    fn from_response_unexpected_status() {
331        let err = ClientError::from_response(418, "teapot");
332        assert!(matches!(
333            err,
334            ClientError::ServerError {
335                status_code: 418,
336                ..
337            }
338        ));
339        assert!(err.to_string().contains("unexpected status 418"));
340    }
341
342    // ── from_response: JSON body parsing ─────────────────────────────
343
344    #[test]
345    fn from_response_json_body_extracts_message() {
346        let body =
347            r#"{"status":"error","code":"AUTH_FAILED","message":"token expired","details":{}}"#;
348        let err = ClientError::from_response(401, body);
349        match err {
350            ClientError::Unauthorized { message, .. } => assert_eq!(message, "token expired"),
351            other => panic!("expected Unauthorized, got: {other:?}"),
352        }
353    }
354
355    #[test]
356    fn from_response_plain_text_body() {
357        let err = ClientError::from_response(401, "plain text error");
358        match err {
359            ClientError::Unauthorized { message, .. } => assert_eq!(message, "plain text error"),
360            other => panic!("expected Unauthorized, got: {other:?}"),
361        }
362    }
363
364    // ── status_code: None variants ───────────────────────────────────
365
366    #[test]
367    fn status_code_none_for_non_http_errors() {
368        let err = ClientError::InvalidUrl("bad url".to_string());
369        assert_eq!(err.status_code(), None);
370
371        let err = ClientError::Configuration("missing key".to_string());
372        assert_eq!(err.status_code(), None);
373    }
374
375    // ── Display output ───────────────────────────────────────────────
376
377    #[test]
378    fn display_format_includes_status_code() {
379        let err = ClientError::from_response(404, "not here");
380        assert!(err.to_string().contains("[404]"));
381    }
382}