Skip to main content

tvdata_rs/
error.rs

1use std::time::Duration;
2
3use reqwest::StatusCode;
4use thiserror::Error;
5
6pub type Result<T> = std::result::Result<T, Error>;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ErrorKind {
10    RateLimited,
11    AuthRequired,
12    SymbolNotFound,
13    Transport,
14    Protocol,
15    Unsupported,
16    Api,
17}
18
19#[derive(Debug, Error)]
20pub enum Error {
21    #[error("http request failed: {0}")]
22    Http(#[source] Box<reqwest_middleware::Error>),
23
24    #[error("websocket request failed: {0}")]
25    WebSocket(#[source] Box<tokio_tungstenite::tungstenite::Error>),
26
27    #[error("failed to deserialize tradingview payload: {0}")]
28    Json(#[from] serde_json::Error),
29
30    #[error("failed to format time value: {0}")]
31    TimeFormat(#[from] time::error::Format),
32
33    #[error("invalid endpoint url: {0}")]
34    UrlParse(#[from] url::ParseError),
35
36    #[error("tradingview returned an API error: {0}")]
37    ApiMessage(String),
38
39    #[error("tradingview returned HTTP {status}: {body}")]
40    ApiStatus { status: StatusCode, body: String },
41
42    #[error("search query cannot be empty")]
43    EmptySearchQuery,
44
45    #[error("scan page limit must be greater than zero")]
46    InvalidPageLimit,
47
48    #[error("history request returned no bars for {symbol}")]
49    HistoryEmpty { symbol: String },
50
51    #[error("scan returned no rows for {symbol}")]
52    SymbolNotFound { symbol: String },
53
54    #[error("scan validation is unavailable: {reason}")]
55    ScanValidationUnavailable { reason: String },
56
57    #[error("scan query uses fields unsupported for {route}: {fields:?}")]
58    UnsupportedScanFields { route: String, fields: Vec<String> },
59
60    #[error("quote session returned no data for {symbol}")]
61    QuoteEmpty { symbol: String },
62
63    #[error("quote session returned status {status} for {symbol}")]
64    QuoteSymbolFailed { symbol: String, status: String },
65
66    #[error("history batch concurrency must be greater than zero")]
67    InvalidBatchConcurrency,
68
69    #[error("history pagination exceeded safe limit for {symbol} after {rounds} rounds")]
70    HistoryPaginationLimitExceeded { symbol: String, rounds: usize },
71
72    #[error("history download failed for {symbol}: {source}")]
73    HistoryDownloadFailed {
74        symbol: String,
75        #[source]
76        source: Box<Error>,
77    },
78
79    #[error("retry min interval {min:?} cannot exceed max interval {max:?}")]
80    InvalidRetryBounds { min: Duration, max: Duration },
81
82    #[error("request budget field {field} must be greater than zero")]
83    InvalidRequestBudget { field: &'static str },
84
85    #[error("snapshot batch config field {field} must be greater than zero")]
86    InvalidSnapshotBatchConfig { field: &'static str },
87
88    #[error("invalid websocket frame: {0}")]
89    Protocol(&'static str),
90}
91
92impl From<reqwest_middleware::Error> for Error {
93    fn from(value: reqwest_middleware::Error) -> Self {
94        Self::Http(Box::new(value))
95    }
96}
97
98impl From<reqwest::Error> for Error {
99    fn from(value: reqwest::Error) -> Self {
100        let error: reqwest_middleware::Error = value.into();
101        Self::Http(Box::new(error))
102    }
103}
104
105impl From<tokio_tungstenite::tungstenite::Error> for Error {
106    fn from(value: tokio_tungstenite::tungstenite::Error) -> Self {
107        Self::WebSocket(Box::new(value))
108    }
109}
110
111impl Error {
112    pub fn kind(&self) -> ErrorKind {
113        match self {
114            Self::Http(_) | Self::WebSocket(_) => ErrorKind::Transport,
115            Self::Json(_)
116            | Self::TimeFormat(_)
117            | Self::UrlParse(_)
118            | Self::InvalidPageLimit
119            | Self::InvalidBatchConcurrency
120            | Self::InvalidRetryBounds { .. }
121            | Self::InvalidRequestBudget { .. }
122            | Self::InvalidSnapshotBatchConfig { .. }
123            | Self::Protocol(_) => ErrorKind::Protocol,
124            Self::EmptySearchQuery | Self::ApiMessage(_) => ErrorKind::Api,
125            Self::ApiStatus { status, .. } if *status == StatusCode::TOO_MANY_REQUESTS => {
126                ErrorKind::RateLimited
127            }
128            Self::ApiStatus { status, .. }
129                if *status == StatusCode::UNAUTHORIZED || *status == StatusCode::FORBIDDEN =>
130            {
131                ErrorKind::AuthRequired
132            }
133            Self::ApiStatus { .. } => ErrorKind::Api,
134            Self::HistoryEmpty { .. }
135            | Self::SymbolNotFound { .. }
136            | Self::QuoteEmpty { .. }
137            | Self::QuoteSymbolFailed { .. } => ErrorKind::SymbolNotFound,
138            Self::ScanValidationUnavailable { .. } | Self::UnsupportedScanFields { .. } => {
139                ErrorKind::Unsupported
140            }
141            Self::HistoryPaginationLimitExceeded { .. } => ErrorKind::Protocol,
142            Self::HistoryDownloadFailed { source, .. } => source.kind(),
143        }
144    }
145
146    pub fn is_retryable(&self) -> bool {
147        match self.kind() {
148            ErrorKind::RateLimited | ErrorKind::Transport => true,
149            ErrorKind::AuthRequired
150            | ErrorKind::SymbolNotFound
151            | ErrorKind::Protocol
152            | ErrorKind::Unsupported => false,
153            ErrorKind::Api => matches!(
154                self,
155                Self::ApiStatus { status, .. } if status.is_server_error()
156            ),
157        }
158    }
159
160    pub fn is_auth_error(&self) -> bool {
161        self.kind() == ErrorKind::AuthRequired
162    }
163
164    pub fn is_rate_limited(&self) -> bool {
165        self.kind() == ErrorKind::RateLimited
166    }
167
168    pub fn is_symbol_error(&self) -> bool {
169        self.kind() == ErrorKind::SymbolNotFound
170    }
171
172    pub fn is_transport_error(&self) -> bool {
173        self.kind() == ErrorKind::Transport
174    }
175
176    pub fn is_protocol_error(&self) -> bool {
177        self.kind() == ErrorKind::Protocol
178    }
179
180    pub fn is_unsupported_error(&self) -> bool {
181        self.kind() == ErrorKind::Unsupported
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn helper_methods_follow_error_kind_classification() {
191        let auth = Error::ApiStatus {
192            status: StatusCode::UNAUTHORIZED,
193            body: String::from("unauthorized"),
194        };
195        assert!(auth.is_auth_error());
196        assert!(!auth.is_retryable());
197
198        let rate_limited = Error::ApiStatus {
199            status: StatusCode::TOO_MANY_REQUESTS,
200            body: String::from("rate limited"),
201        };
202        assert!(rate_limited.is_rate_limited());
203        assert!(rate_limited.is_retryable());
204
205        let symbol = Error::SymbolNotFound {
206            symbol: String::from("NASDAQ:AAPL"),
207        };
208        assert!(symbol.is_symbol_error());
209        assert!(!symbol.is_retryable());
210
211        let transport = Error::ApiStatus {
212            status: StatusCode::BAD_GATEWAY,
213            body: String::from("upstream failed"),
214        };
215        assert_eq!(transport.kind(), ErrorKind::Api);
216        assert!(!transport.is_transport_error());
217        assert!(transport.is_retryable());
218
219        let protocol = Error::Protocol("bad frame");
220        assert!(protocol.is_protocol_error());
221        assert!(!protocol.is_retryable());
222
223        let unsupported = Error::UnsupportedScanFields {
224            route: String::from("america/scan"),
225            fields: vec![String::from("bad_field")],
226        };
227        assert!(unsupported.is_unsupported_error());
228        assert!(!unsupported.is_retryable());
229    }
230
231    #[test]
232    fn wrapped_history_download_failures_preserve_helper_behavior() {
233        let wrapped = Error::HistoryDownloadFailed {
234            symbol: String::from("NASDAQ:AAPL"),
235            source: Box::new(Error::ApiStatus {
236                status: StatusCode::TOO_MANY_REQUESTS,
237                body: String::from("rate limited"),
238            }),
239        };
240
241        assert!(wrapped.is_rate_limited());
242        assert!(wrapped.is_retryable());
243    }
244}