Skip to main content

ig_client/
error.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 12/5/25
5******************************************************************************/
6use reqwest::StatusCode;
7use std::io;
8
9/// Error type for fetch operations.
10///
11/// # Deprecated
12/// This enum is dead: no library code constructs or returns it. Every fetch,
13/// network, database, and parse failure surfaces through [`AppError`] instead
14/// ([`AppError::Network`], `AppError::Db`, [`AppError::Deserialization`]).
15/// It is kept only for backward compatibility and will be removed in a future
16/// release — migrate to [`AppError`].
17#[deprecated(
18    since = "0.12.0",
19    note = "unused dead enum; use AppError (Network / Db / Deserialization) instead"
20)]
21#[derive(Debug, thiserror::Error)]
22pub enum FetchError {
23    /// Network error from reqwest
24    #[error("network error: {0}")]
25    Reqwest(#[from] reqwest::Error),
26    /// Database error from sqlx
27    #[cfg(feature = "persistence")]
28    #[error("db error: {0}")]
29    Sqlx(#[from] sqlx::Error),
30    /// Error during parsing
31    #[error("parser error: {0}")]
32    Parser(String),
33}
34
35/// Error type for authentication operations
36#[derive(Debug, thiserror::Error)]
37pub enum AuthError {
38    /// Network error from reqwest
39    #[error("network error: {0}")]
40    Network(#[from] reqwest::Error),
41    /// I/O error
42    #[error("io error: {0}")]
43    Io(#[from] io::Error),
44    /// JSON serialization or deserialization error
45    #[error("json error: {0}")]
46    Json(#[from] serde_json::Error),
47    /// Other unspecified error
48    #[error("other error: {0}")]
49    Other(String),
50    /// Invalid credentials error
51    #[error("bad credentials")]
52    BadCredentials,
53    /// Unexpected HTTP status code
54    #[error("unexpected http status: {0}")]
55    Unexpected(StatusCode),
56    /// Rate limit exceeded error
57    #[error("rate limit exceeded")]
58    RateLimitExceeded,
59    /// A login / account-switch response was accepted by IG but omitted a
60    /// required session-token header (CST or X-SECURITY-TOKEN), so no usable
61    /// session could be derived. The payload holds the lowercase header name.
62    ///
63    /// This is a rejected / malformed authentication *response*, not bad caller
64    /// input — it must not be reported as [`AppError::InvalidInput`].
65    #[error("missing {0} header in login response")]
66    MissingSessionToken(String),
67}
68
69impl From<Box<dyn std::error::Error + Send + Sync>> for AuthError {
70    #[cold]
71    fn from(e: Box<dyn std::error::Error + Send + Sync>) -> Self {
72        match e.downcast::<reqwest::Error>() {
73            Ok(req) => AuthError::Network(*req),
74            Err(e) => match e.downcast::<serde_json::Error>() {
75                Ok(js) => AuthError::Json(*js),
76                Err(e) => match e.downcast::<std::io::Error>() {
77                    Ok(ioe) => AuthError::Io(*ioe),
78                    Err(other) => AuthError::Other(other.to_string()),
79                },
80            },
81        }
82    }
83}
84
85impl From<Box<dyn std::error::Error>> for AuthError {
86    #[cold]
87    fn from(e: Box<dyn std::error::Error>) -> Self {
88        match e.downcast::<reqwest::Error>() {
89            Ok(req) => AuthError::Network(*req),
90            Err(e) => match e.downcast::<serde_json::Error>() {
91                Ok(js) => AuthError::Json(*js),
92                Err(e) => match e.downcast::<io::Error>() {
93                    Ok(ioe) => AuthError::Io(*ioe),
94                    Err(other) => AuthError::Other(other.to_string()),
95                },
96            },
97        }
98    }
99}
100
101impl From<AppError> for AuthError {
102    #[cold]
103    fn from(e: AppError) -> Self {
104        match e {
105            AppError::Network(e) => AuthError::Network(e),
106            AppError::Io(e) => AuthError::Io(e),
107            AppError::Json(e) => AuthError::Json(e),
108            AppError::Unexpected(s) => AuthError::Unexpected(s),
109            // Unwrap an already-typed auth error rather than re-stringifying it.
110            AppError::Auth(a) => a,
111            _ => AuthError::Other(e.to_string()),
112        }
113    }
114}
115
116/// General application error type
117#[derive(Debug, thiserror::Error)]
118pub enum AppError {
119    /// Network error from reqwest
120    #[error("network error: {0}")]
121    Network(#[from] reqwest::Error),
122    /// I/O error
123    #[error("io error: {0}")]
124    Io(#[from] io::Error),
125    /// JSON serialization or deserialization error
126    #[error("json error: {0}")]
127    Json(#[from] serde_json::Error),
128    /// Unexpected HTTP status code
129    #[error("unexpected http status: {0}")]
130    Unexpected(StatusCode),
131    /// Database error from sqlx
132    #[cfg(feature = "persistence")]
133    #[error("db error: {0}")]
134    Db(#[from] sqlx::Error),
135    /// Unauthorized access error
136    #[error("unauthorized")]
137    Unauthorized,
138    /// OAuth token expired error (requires token refresh)
139    #[error("oauth token expired")]
140    OAuthTokenExpired,
141    /// Resource not found error
142    #[error("not found")]
143    NotFound,
144    /// API rate limit exceeded
145    #[error("rate limit exceeded")]
146    RateLimitExceeded,
147    /// Historical data allowance exhausted (weekly quota of data points)
148    ///
149    /// The `allowance_expiry` field indicates the number of seconds
150    /// until the allowance resets. Retrying before that is pointless.
151    #[error("historical data allowance exceeded, resets in {allowance_expiry} seconds")]
152    HistoricalDataAllowanceExceeded {
153        /// Seconds until the weekly allowance resets
154        allowance_expiry: u64,
155    },
156    /// Error during serialization or deserialization
157    #[error("serialization error: {0}")]
158    SerializationError(String),
159    /// WebSocket communication error
160    #[error("websocket error: {0}")]
161    WebSocketError(String),
162    /// Deserialization error with details
163    #[error("deserialization error: {0}")]
164    Deserialization(String),
165    /// Invalid input error with a description of the constraint violated
166    #[error("invalid input: {0}")]
167    InvalidInput(String),
168    /// Authentication failure carrying a typed [`AuthError`].
169    ///
170    /// Login / refresh / account-switch paths surface their typed
171    /// [`AuthError`] through this variant (e.g. a login response missing a
172    /// required session-token header). Wrapping — rather than flattening —
173    /// preserves both the specific auth variant and its contextual message.
174    #[error("auth error: {0}")]
175    Auth(#[from] AuthError),
176    /// Generic error for cases that don't fit other categories
177    #[error("generic error: {0}")]
178    Generic(String),
179}
180
181impl From<Box<dyn std::error::Error>> for AppError {
182    #[cold]
183    fn from(e: Box<dyn std::error::Error>) -> Self {
184        match e.downcast::<reqwest::Error>() {
185            Ok(req) => AppError::Network(*req),
186            Err(e) => match e.downcast::<serde_json::Error>() {
187                Ok(js) => AppError::Json(*js),
188                Err(e) => match e.downcast::<std::io::Error>() {
189                    Ok(ioe) => AppError::Io(*ioe),
190                    // Preserve the original error text instead of fabricating a
191                    // fake "unexpected http status: 500" for arbitrary errors.
192                    Err(other) => AppError::Generic(other.to_string()),
193                },
194            },
195        }
196    }
197}
198
199impl From<String> for AppError {
200    #[cold]
201    fn from(e: String) -> Self {
202        AppError::Generic(e)
203    }
204}
205
206#[cfg(feature = "streaming")]
207impl From<lightstreamer_rs::Error> for AppError {
208    /// Wraps a Lightstreamer protocol error.
209    ///
210    /// Only the `Display` form is kept: it carries the server's own error code
211    /// and message and never the session credentials, which the client holds
212    /// but never renders.
213    #[cold]
214    fn from(e: lightstreamer_rs::Error) -> Self {
215        AppError::WebSocketError(e.to_string())
216    }
217}
218
219#[cfg(feature = "streaming")]
220impl From<lightstreamer_rs::config::ConfigError> for AppError {
221    /// Wraps a rejected Lightstreamer configuration value (server address,
222    /// item group, field schema). These are caller mistakes, not I/O failures.
223    #[cold]
224    fn from(e: lightstreamer_rs::config::ConfigError) -> Self {
225        AppError::InvalidInput(e.to_string())
226    }
227}