Skip to main content

yfinance_rs/core/
error.rs

1use std::{error::Error as StdError, fmt};
2
3use crate::core::redaction::redact_auth_query_params_in_text;
4
5/// A redacted wrapper around HTTP-client errors.
6///
7/// The underlying HTTP client can include full request URLs in its formatted
8/// errors. Store only the redacted display text so `YfError` display/debug
9/// output cannot leak crumb or auth-like query parameters.
10pub struct RedactedHttpError {
11    message: String,
12}
13
14impl RedactedHttpError {
15    pub(crate) fn new(error: &reqwest::Error) -> Self {
16        Self {
17            message: redact_auth_query_params_in_text(&error.to_string()),
18        }
19    }
20}
21
22impl fmt::Display for RedactedHttpError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        f.write_str(&self.message)
25    }
26}
27
28impl fmt::Debug for RedactedHttpError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        fmt::Display::fmt(self, f)
31    }
32}
33
34impl std::error::Error for RedactedHttpError {}
35
36/// An opaque wrapper around WebSocket transport errors.
37pub struct WebsocketError {
38    source: Box<dyn StdError + Send + Sync + 'static>,
39}
40
41impl WebsocketError {
42    #[cfg(feature = "stream")]
43    pub(crate) fn new(source: tokio_tungstenite::tungstenite::Error) -> Self {
44        Self {
45            source: Box::new(source),
46        }
47    }
48
49    fn as_source(&self) -> &(dyn StdError + 'static) {
50        self.source.as_ref()
51    }
52}
53
54impl fmt::Display for WebsocketError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        fmt::Display::fmt(&self.source, f)
57    }
58}
59
60impl fmt::Debug for WebsocketError {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        fmt::Display::fmt(self, f)
63    }
64}
65
66impl StdError for WebsocketError {
67    fn source(&self) -> Option<&(dyn StdError + 'static)> {
68        Some(self.as_source())
69    }
70}
71
72macro_rules! boxed_source_error {
73    (
74        $(#[$meta:meta])*
75        $name:ident
76    ) => {
77        $(#[$meta])*
78        pub struct $name {
79            source: Box<dyn StdError + Send + Sync + 'static>,
80        }
81
82        impl $name {
83            #[cfg(feature = "stream")]
84            fn from_source(source: impl StdError + Send + Sync + 'static) -> Self {
85                Self {
86                    source: Box::new(source),
87                }
88            }
89
90            fn as_source(&self) -> &(dyn StdError + 'static) {
91                self.source.as_ref()
92            }
93        }
94
95        impl fmt::Display for $name {
96            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97                fmt::Display::fmt(&self.source, f)
98            }
99        }
100
101        impl fmt::Debug for $name {
102            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103                fmt::Display::fmt(self, f)
104            }
105        }
106
107        impl StdError for $name {
108            fn source(&self) -> Option<&(dyn StdError + 'static)> {
109                Some(self.as_source())
110            }
111        }
112    };
113}
114
115macro_rules! opaque_source_error {
116    (
117        $(#[$meta:meta])*
118        $name:ident($source:ty)
119    ) => {
120        $(#[$meta])*
121        pub struct $name {
122            source: $source,
123        }
124
125        impl $name {
126            pub(crate) const fn new(source: $source) -> Self {
127                Self { source }
128            }
129
130            fn as_source(&self) -> &(dyn StdError + 'static) {
131                &self.source
132            }
133        }
134
135        impl fmt::Display for $name {
136            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137                fmt::Display::fmt(&self.source, f)
138            }
139        }
140
141        impl fmt::Debug for $name {
142            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143                fmt::Display::fmt(self, f)
144            }
145        }
146
147        impl StdError for $name {
148            fn source(&self) -> Option<&(dyn StdError + 'static)> {
149                Some(self.as_source())
150            }
151        }
152    };
153}
154
155boxed_source_error!(
156    /// An opaque wrapper around Protobuf decoding errors.
157    ProtobufDecodeError
158);
159
160opaque_source_error!(
161    /// An opaque wrapper around JSON serialization and deserialization errors.
162    JsonError(serde_json::Error)
163);
164
165boxed_source_error!(
166    /// An opaque wrapper around Base64 decoding errors.
167    Base64DecodeError
168);
169
170opaque_source_error!(
171    /// An opaque wrapper around URL parsing errors.
172    UrlParseError(url::ParseError)
173);
174
175/// The primary error type for the `yfinance-rs` crate.
176#[non_exhaustive]
177#[derive(Debug)]
178pub enum YfError {
179    /// An error originating from the underlying HTTP client (`reqwest`).
180    Http(RedactedHttpError),
181
182    /// An error related to WebSocket communication.
183    Websocket(WebsocketError),
184
185    /// An error during Protobuf decoding, typically from a WebSocket stream.
186    Protobuf(ProtobufDecodeError),
187
188    /// An error during JSON serialization or deserialization.
189    Json(JsonError),
190
191    /// An error during Base64 decoding.
192    Base64(Base64DecodeError),
193
194    /// An error that occurs when parsing a URL.
195    Url(UrlParseError),
196
197    /// A 404 Not Found returned by Yahoo endpoints.
198    NotFound {
199        /// The URL that returned a 404.
200        url: String,
201    },
202
203    /// A 429 Too Many Requests (rate limit) returned by Yahoo endpoints.
204    RateLimited {
205        /// The URL that returned a 429.
206        url: String,
207    },
208
209    /// A 5xx server error returned by Yahoo endpoints.
210    ServerError {
211        /// The HTTP status code in the 5xx range.
212        status: u16,
213        /// The URL that returned a server error.
214        url: String,
215    },
216
217    /// An error indicating an unexpected, non-successful HTTP status code (non-404/429/5xx).
218    Status {
219        /// The unexpected HTTP status code returned.
220        status: u16,
221        /// The URL that returned the status.
222        url: String,
223    },
224
225    /// An error returned by the Yahoo Finance API within an otherwise successful response.
226    ///
227    /// For example, a `200 OK` response might contain a JSON body with an `error` field.
228    Api(String),
229
230    /// An error related to authentication, such as failing to retrieve a cookie or crumb.
231    Auth(String),
232
233    /// An error that occurs during the web scraping process.
234    Scrape(String),
235
236    /// Indicates that an expected piece of data was missing from the API response.
237    MissingData(String),
238
239    /// Indicates that provider data was present but could not be mapped into the public model.
240    InvalidData(String),
241
242    /// Option contracts were present, but Yahoo did not provide a usable underlying type.
243    OptionUnderlyingTypeUnavailable {
244        /// The requested option-chain symbol.
245        symbol: String,
246        /// The raw Yahoo `quoteType`, when Yahoo supplied one.
247        quote_type: Option<String>,
248    },
249
250    /// Indicates that provider data could not be projected losslessly in strict mode.
251    DataQuality(Box<crate::core::diagnostics::YfWarning>),
252
253    /// An error indicating that the parameters provided by the caller were invalid.
254    InvalidParams(String),
255
256    /// An error indicating that an HTTP request could not be cloned for retry.
257    RequestNotCloneable,
258
259    /// An error originating from `paft` money modeling.
260    Money(paft::money::MoneyError),
261
262    /// An error indicating that the provided date range is invalid (e.g., start date after end date).
263    InvalidDates,
264}
265
266impl YfError {
267    #[cfg(feature = "stream")]
268    pub(crate) fn websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
269        Self::Websocket(WebsocketError::new(error))
270    }
271
272    #[cfg(feature = "stream")]
273    pub(crate) fn protobuf(error: prost::DecodeError) -> Self {
274        Self::Protobuf(ProtobufDecodeError::from_source(error))
275    }
276
277    pub(crate) const fn json(error: serde_json::Error) -> Self {
278        Self::Json(JsonError::new(error))
279    }
280
281    #[cfg(feature = "stream")]
282    pub(crate) fn base64(error: base64::DecodeError) -> Self {
283        Self::Base64(Base64DecodeError::from_source(error))
284    }
285
286    pub(crate) const fn url(error: url::ParseError) -> Self {
287        Self::Url(UrlParseError::new(error))
288    }
289}
290
291impl fmt::Display for YfError {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        match self {
294            Self::Http(error) => write!(f, "HTTP error: {error}"),
295            Self::Websocket(error) => write!(f, "WebSocket error: {error}"),
296            Self::Protobuf(error) => write!(f, "Protobuf decoding error: {error}"),
297            Self::Json(error) => write!(f, "JSON parsing error: {error}"),
298            Self::Base64(error) => write!(f, "Base64 decoding error: {error}"),
299            Self::Url(error) => write!(f, "Invalid URL: {error}"),
300            Self::NotFound { url } => write!(f, "Not found at {url}"),
301            Self::RateLimited { url } => write!(f, "Rate limited at {url}"),
302            Self::ServerError { status, url } => write!(f, "Server error {status} at {url}"),
303            Self::Status { status, url } => {
304                write!(f, "Unexpected response status: {status} at {url}")
305            }
306            Self::Api(message) => write!(f, "Yahoo API error: {message}"),
307            Self::Auth(message) => write!(f, "Authentication error: {message}"),
308            Self::Scrape(message) => write!(f, "Web scraping error: {message}"),
309            Self::MissingData(message) => write!(f, "Missing data in response: {message}"),
310            Self::InvalidData(message) => write!(f, "Invalid data in response: {message}"),
311            Self::OptionUnderlyingTypeUnavailable { symbol, .. } => {
312                write!(
313                    f,
314                    "contracts present, underlying type unavailable for {symbol}"
315                )
316            }
317            Self::DataQuality(warning) => write!(f, "Provider data quality issue: {warning}"),
318            Self::InvalidParams(message) => write!(f, "Invalid parameters: {message}"),
319            Self::RequestNotCloneable => write!(f, "Request cannot be cloned for retry"),
320            Self::Money(error) => write!(f, "Money data error: {error}"),
321            Self::InvalidDates => {
322                f.write_str("Invalid date range: start date must be before end date")
323            }
324        }
325    }
326}
327
328impl StdError for YfError {
329    fn source(&self) -> Option<&(dyn StdError + 'static)> {
330        match self {
331            Self::Websocket(error) => Some(error.as_source()),
332            Self::Protobuf(error) => Some(error.as_source()),
333            Self::Json(error) => Some(error.as_source()),
334            Self::Base64(error) => Some(error.as_source()),
335            Self::Url(error) => Some(error.as_source()),
336            Self::Money(error) => Some(error),
337            _ => None,
338        }
339    }
340}
341
342impl From<reqwest::Error> for YfError {
343    fn from(e: reqwest::Error) -> Self {
344        Self::Http(RedactedHttpError::new(&e))
345    }
346}
347
348impl From<paft::money::MoneyError> for YfError {
349    fn from(error: paft::money::MoneyError) -> Self {
350        Self::Money(error)
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use std::error::Error as _;
357
358    use super::*;
359
360    fn assert_source<T>(error: &YfError)
361    where
362        T: std::error::Error + 'static,
363    {
364        assert!(error.source().expect("error source").is::<T>());
365    }
366
367    #[test]
368    fn opaque_json_and_url_errors_preserve_foreign_sources() {
369        let json = YfError::json(
370            serde_json::from_str::<serde_json::Value>("{").expect_err("invalid JSON"),
371        );
372        assert!(matches!(json, YfError::Json(_)));
373        assert_source::<serde_json::Error>(&json);
374
375        let url = YfError::url(url::Url::parse("://").expect_err("invalid URL"));
376        assert!(matches!(url, YfError::Url(_)));
377        assert_source::<url::ParseError>(&url);
378    }
379
380    #[cfg(feature = "stream")]
381    #[test]
382    fn opaque_stream_errors_preserve_foreign_sources() {
383        use base64::{Engine as _, engine::general_purpose};
384        use prost::DecodeError;
385
386        let websocket = YfError::websocket(tokio_tungstenite::tungstenite::Error::ConnectionClosed);
387        assert!(matches!(websocket, YfError::Websocket(_)));
388        assert_source::<tokio_tungstenite::tungstenite::Error>(&websocket);
389
390        let protobuf = YfError::protobuf(DecodeError::new("invalid protobuf"));
391        assert!(matches!(protobuf, YfError::Protobuf(_)));
392        assert_source::<prost::DecodeError>(&protobuf);
393
394        let base64 = YfError::base64(
395            general_purpose::STANDARD
396                .decode("!")
397                .expect_err("invalid base64"),
398        );
399        assert!(matches!(base64, YfError::Base64(_)));
400        assert_source::<base64::DecodeError>(&base64);
401    }
402}