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}