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    ///
146    /// Kept for a 429 with no allowance body: the status alone does not say
147    /// which budget ran out, so it must not be read as a per-key rejection.
148    #[error("rate limit exceeded")]
149    RateLimitExceeded,
150    /// This API key's non-trading allowance is exhausted
151    /// (`error.public-api.exceeded-api-key-allowance`).
152    ///
153    /// IG meters that allowance per key, so another key of the pool can serve
154    /// the request immediately. This is the only allowance error that rotates.
155    #[error("api key allowance exceeded")]
156    ApiKeyAllowanceExceeded,
157    /// The account's non-trading allowance is exhausted
158    /// (`error.public-api.exceeded-account-allowance`).
159    ///
160    /// Every key of the pool authenticates the same account, so rotating cannot
161    /// help: the whole pool has to wait.
162    #[error("account allowance exceeded")]
163    AccountAllowanceExceeded,
164    /// The account's trading allowance is exhausted
165    /// (`error.public-api.exceeded-account-trading-allowance`).
166    ///
167    /// Trading traffic stays pinned to one key, so this never rotates.
168    #[error("account trading allowance exceeded")]
169    TradingAllowanceExceeded,
170    /// Historical data allowance exhausted (weekly quota of data points)
171    ///
172    /// The `allowance_expiry` field indicates the number of seconds
173    /// until the allowance resets. Retrying before that is pointless.
174    #[error("historical data allowance exceeded, resets in {allowance_expiry} seconds")]
175    HistoricalDataAllowanceExceeded {
176        /// Seconds until the weekly allowance resets
177        allowance_expiry: u64,
178    },
179    /// Error during serialization or deserialization
180    #[error("serialization error: {0}")]
181    SerializationError(String),
182    /// WebSocket communication error
183    #[error("websocket error: {0}")]
184    WebSocketError(String),
185    /// Deserialization error with details
186    #[error("deserialization error: {0}")]
187    Deserialization(String),
188    /// Invalid input error with a description of the constraint violated
189    #[error("invalid input: {0}")]
190    InvalidInput(String),
191    /// Authentication failure carrying a typed [`AuthError`].
192    ///
193    /// Login / refresh / account-switch paths surface their typed
194    /// [`AuthError`] through this variant (e.g. a login response missing a
195    /// required session-token header). Wrapping — rather than flattening —
196    /// preserves both the specific auth variant and its contextual message.
197    #[error("auth error: {0}")]
198    Auth(#[from] AuthError),
199    /// Generic error for cases that don't fit other categories
200    #[error("generic error: {0}")]
201    Generic(String),
202}
203
204impl From<Box<dyn std::error::Error>> for AppError {
205    #[cold]
206    fn from(e: Box<dyn std::error::Error>) -> Self {
207        match e.downcast::<reqwest::Error>() {
208            Ok(req) => AppError::Network(*req),
209            Err(e) => match e.downcast::<serde_json::Error>() {
210                Ok(js) => AppError::Json(*js),
211                Err(e) => match e.downcast::<std::io::Error>() {
212                    Ok(ioe) => AppError::Io(*ioe),
213                    // Preserve the original error text instead of fabricating a
214                    // fake "unexpected http status: 500" for arbitrary errors.
215                    Err(other) => AppError::Generic(other.to_string()),
216                },
217            },
218        }
219    }
220}
221
222impl From<String> for AppError {
223    #[cold]
224    fn from(e: String) -> Self {
225        AppError::Generic(e)
226    }
227}
228
229#[cfg(feature = "streaming")]
230impl From<lightstreamer_rs::Error> for AppError {
231    /// Wraps a Lightstreamer protocol error.
232    ///
233    /// Only the `Display` form is kept: it carries the server's own error code
234    /// and message and never the session credentials, which the client holds
235    /// but never renders.
236    #[cold]
237    fn from(e: lightstreamer_rs::Error) -> Self {
238        AppError::WebSocketError(e.to_string())
239    }
240}
241
242#[cfg(feature = "streaming")]
243impl From<lightstreamer_rs::config::ConfigError> for AppError {
244    /// Wraps a rejected Lightstreamer configuration value (server address,
245    /// item group, field schema). These are caller mistakes, not I/O failures.
246    #[cold]
247    fn from(e: lightstreamer_rs::config::ConfigError) -> Self {
248        AppError::InvalidInput(e.to_string())
249    }
250}