1use reqwest::StatusCode;
7use serde::Deserialize;
8use std::collections::HashMap;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
13pub enum Error {
14 #[error("Authentication failed: {message}")]
16 Auth {
17 message: String,
18 details: Option<Box<ApiErrorResponse>>,
20 },
21
22 #[error("API error ({status}): {message}")]
24 Api {
25 status: StatusCode,
27 message: String,
29 response: Option<ApiErrorResponse>,
31 },
32
33 #[error("Resource not found: {resource}")]
35 NotFound {
36 resource: String,
38 },
39
40 #[error("Rate limit exceeded")]
42 RateLimited {
43 retry_after: Option<std::time::Duration>,
45 },
46
47 #[error("Network error: {0}")]
49 Network(#[from] reqwest::Error),
50
51 #[error("Failed to parse response: {message}")]
53 Parse {
54 message: String,
56 body: Option<String>,
58 },
59
60 #[cfg(feature = "websocket")]
62 #[error("WebSocket error: {0}")]
63 WebSocket(#[from] WsError),
64
65 #[error("Invalid configuration: {0}")]
67 InvalidConfig(String),
68}
69
70#[cfg(feature = "websocket")]
72#[derive(Debug, Error)]
73pub enum WsError {
74 #[error("Connection failed: {0}")]
76 ConnectionFailed(String),
77
78 #[error("Connection closed: {reason}")]
80 Disconnected { reason: String },
81
82 #[error("Failed to send message: {0}")]
84 SendFailed(String),
85
86 #[error("Invalid message received: {0}")]
88 InvalidMessage(String),
89
90 #[error("Route is reserved for internal use: {0}")]
92 ReservedRoute(String),
93
94 #[error("Not connected to WebSocket server")]
96 NotConnected,
97
98 #[error("WebSocket authentication failed: {0}")]
100 AuthFailed(String),
101}
102
103#[derive(Debug, Clone, Deserialize)]
105pub struct ApiErrorResponse {
106 #[serde(rename = "apiVersion")]
108 pub api_version: String,
109
110 pub error: ApiErrorDetails,
112}
113
114#[derive(Debug, Clone, Deserialize)]
116pub struct ApiErrorDetails {
117 #[serde(default)]
119 pub request: Option<Vec<String>>,
120
121 #[serde(default)]
123 pub inputs: Option<HashMap<String, Vec<String>>>,
124}
125
126impl ApiErrorResponse {
127 pub fn request_errors(&self) -> Vec<&str> {
129 self.error
130 .request
131 .as_ref()
132 .map(|v| v.iter().map(|s| s.as_str()).collect())
133 .unwrap_or_default()
134 }
135
136 pub fn field_errors(&self, field: &str) -> Vec<&str> {
138 self.error
139 .inputs
140 .as_ref()
141 .and_then(|m| m.get(field))
142 .map(|v| v.iter().map(|s| s.as_str()).collect())
143 .unwrap_or_default()
144 }
145
146 pub fn has_validation_errors(&self) -> bool {
148 self.error.inputs.as_ref().is_some_and(|m| !m.is_empty())
149 }
150}
151
152impl Error {
153 pub fn api_details(&self) -> Option<&ApiErrorResponse> {
155 match self {
156 Error::Auth { details, .. } => details.as_ref().map(|b| b.as_ref()),
157 Error::Api { response, .. } => response.as_ref(),
158 _ => None,
159 }
160 }
161
162 pub fn is_retryable(&self) -> bool {
164 matches!(self, Error::RateLimited { .. } | Error::Network(_))
165 }
166
167 pub fn is_auth_error(&self) -> bool {
169 matches!(self, Error::Auth { .. })
170 || matches!(self, Error::Api { status, .. } if *status == StatusCode::UNAUTHORIZED)
171 }
172
173 pub(crate) fn auth(message: impl Into<String>) -> Self {
175 Error::Auth {
176 message: message.into(),
177 details: None,
178 }
179 }
180
181 pub(crate) fn auth_with_details(message: impl Into<String>, details: ApiErrorResponse) -> Self {
183 Error::Auth {
184 message: message.into(),
185 details: Some(Box::new(details)),
186 }
187 }
188
189 pub(crate) fn api(status: StatusCode, message: impl Into<String>) -> Self {
191 Error::Api {
192 status,
193 message: message.into(),
194 response: None,
195 }
196 }
197
198 pub(crate) fn api_with_response(
200 status: StatusCode,
201 message: impl Into<String>,
202 response: ApiErrorResponse,
203 ) -> Self {
204 Error::Api {
205 status,
206 message: message.into(),
207 response: Some(response),
208 }
209 }
210
211 pub(crate) fn not_found(resource: impl Into<String>) -> Self {
213 Error::NotFound {
214 resource: resource.into(),
215 }
216 }
217
218 #[allow(dead_code)]
220 pub(crate) fn parse(message: impl Into<String>) -> Self {
221 Error::Parse {
222 message: message.into(),
223 body: None,
224 }
225 }
226
227 pub(crate) fn parse_with_body(message: impl Into<String>, body: String) -> Self {
229 Error::Parse {
230 message: message.into(),
231 body: Some(body),
232 }
233 }
234}
235
236pub type Result<T> = std::result::Result<T, Error>;
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn test_error_is_retryable() {
245 assert!(Error::RateLimited { retry_after: None }.is_retryable());
246 assert!(!Error::auth("test").is_retryable());
247 assert!(!Error::not_found("item").is_retryable());
248 }
249
250 #[test]
251 fn test_error_is_auth_error() {
252 assert!(Error::auth("test").is_auth_error());
253 assert!(
254 Error::Api {
255 status: StatusCode::UNAUTHORIZED,
256 message: "test".into(),
257 response: None
258 }
259 .is_auth_error()
260 );
261 assert!(!Error::not_found("item").is_auth_error());
262 }
263
264 #[test]
265 fn test_api_error_response_helpers() {
266 let response = ApiErrorResponse {
267 api_version: "2.0".into(),
268 error: ApiErrorDetails {
269 request: Some(vec!["General error".into()]),
270 inputs: Some(HashMap::from([(
271 "email".into(),
272 vec!["Invalid email".into()],
273 )])),
274 },
275 };
276
277 assert_eq!(response.request_errors(), vec!["General error"]);
278 assert_eq!(response.field_errors("email"), vec!["Invalid email"]);
279 assert!(response.field_errors("password").is_empty());
280 assert!(response.has_validation_errors());
281 }
282}