wf_market/
error.rs

1//! Error types for the wf-market library.
2//!
3//! This module provides a unified error type that covers all possible
4//! failure modes when interacting with the warframe.market API.
5
6use reqwest::StatusCode;
7use serde::Deserialize;
8use std::collections::HashMap;
9use thiserror::Error;
10
11/// The main error type for all wf-market operations.
12#[derive(Debug, Error)]
13pub enum Error {
14    /// Authentication failed (invalid credentials, expired token, etc.)
15    #[error("Authentication failed: {message}")]
16    Auth {
17        message: String,
18        /// Detailed error response from the API, if available
19        details: Option<Box<ApiErrorResponse>>,
20    },
21
22    /// API returned an error response
23    #[error("API error ({status}): {message}")]
24    Api {
25        /// HTTP status code
26        status: StatusCode,
27        /// Human-readable error message
28        message: String,
29        /// Full error response from WFM API for debugging
30        response: Option<ApiErrorResponse>,
31    },
32
33    /// Requested resource was not found
34    #[error("Resource not found: {resource}")]
35    NotFound {
36        /// Description of the resource that wasn't found
37        resource: String,
38    },
39
40    /// Rate limit exceeded
41    #[error("Rate limit exceeded")]
42    RateLimited {
43        /// Duration to wait before retrying, if provided by server
44        retry_after: Option<std::time::Duration>,
45    },
46
47    /// Network/connection error
48    #[error("Network error: {0}")]
49    Network(#[from] reqwest::Error),
50
51    /// Failed to parse API response
52    #[error("Failed to parse response: {message}")]
53    Parse {
54        /// Description of what failed to parse
55        message: String,
56        /// Raw response body for debugging
57        body: Option<String>,
58    },
59
60    /// WebSocket error (only available with `websocket` feature)
61    #[cfg(feature = "websocket")]
62    #[error("WebSocket error: {0}")]
63    WebSocket(#[from] WsError),
64
65    /// Invalid configuration or usage
66    #[error("Invalid configuration: {0}")]
67    InvalidConfig(String),
68}
69
70/// WebSocket-specific errors.
71#[cfg(feature = "websocket")]
72#[derive(Debug, Error)]
73pub enum WsError {
74    /// Failed to connect to WebSocket server
75    #[error("Connection failed: {0}")]
76    ConnectionFailed(String),
77
78    /// Connection was closed unexpectedly
79    #[error("Connection closed: {reason}")]
80    Disconnected { reason: String },
81
82    /// Failed to send message
83    #[error("Failed to send message: {0}")]
84    SendFailed(String),
85
86    /// Received invalid message from server
87    #[error("Invalid message received: {0}")]
88    InvalidMessage(String),
89
90    /// Attempted to use reserved route
91    #[error("Route is reserved for internal use: {0}")]
92    ReservedRoute(String),
93
94    /// Not connected to server
95    #[error("Not connected to WebSocket server")]
96    NotConnected,
97
98    /// Authentication failed on WebSocket
99    #[error("WebSocket authentication failed: {0}")]
100    AuthFailed(String),
101}
102
103/// Structured error response from the warframe.market API.
104#[derive(Debug, Clone, Deserialize)]
105pub struct ApiErrorResponse {
106    /// API version that generated the error
107    #[serde(rename = "apiVersion")]
108    pub api_version: String,
109
110    /// Error details
111    pub error: ApiErrorDetails,
112}
113
114/// Detailed error information from the API.
115#[derive(Debug, Clone, Deserialize)]
116pub struct ApiErrorDetails {
117    /// General request-level errors
118    #[serde(default)]
119    pub request: Option<Vec<String>>,
120
121    /// Field-specific validation errors (field name -> error messages)
122    #[serde(default)]
123    pub inputs: Option<HashMap<String, Vec<String>>>,
124}
125
126impl ApiErrorResponse {
127    /// Get all request-level error messages.
128    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    /// Get error messages for a specific input field.
137    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    /// Check if there are any validation errors.
147    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    /// Get detailed API error information if available.
154    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    /// Check if this error is potentially retryable.
163    pub fn is_retryable(&self) -> bool {
164        matches!(self, Error::RateLimited { .. } | Error::Network(_))
165    }
166
167    /// Check if this is an authentication error.
168    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    /// Create an authentication error.
174    pub(crate) fn auth(message: impl Into<String>) -> Self {
175        Error::Auth {
176            message: message.into(),
177            details: None,
178        }
179    }
180
181    /// Create an authentication error with details.
182    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    /// Create an API error.
190    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    /// Create an API error with response details.
199    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    /// Create a not found error.
212    pub(crate) fn not_found(resource: impl Into<String>) -> Self {
213        Error::NotFound {
214            resource: resource.into(),
215        }
216    }
217
218    /// Create a parse error.
219    #[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    /// Create a parse error with the raw body.
228    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
236/// A specialized Result type for wf-market operations.
237pub 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}