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("invalid websocket frame: {0}")]
86    Protocol(&'static str),
87}
88
89impl From<reqwest_middleware::Error> for Error {
90    fn from(value: reqwest_middleware::Error) -> Self {
91        Self::Http(Box::new(value))
92    }
93}
94
95impl From<reqwest::Error> for Error {
96    fn from(value: reqwest::Error) -> Self {
97        let error: reqwest_middleware::Error = value.into();
98        Self::Http(Box::new(error))
99    }
100}
101
102impl From<tokio_tungstenite::tungstenite::Error> for Error {
103    fn from(value: tokio_tungstenite::tungstenite::Error) -> Self {
104        Self::WebSocket(Box::new(value))
105    }
106}
107
108impl Error {
109    pub fn kind(&self) -> ErrorKind {
110        match self {
111            Self::Http(_) | Self::WebSocket(_) => ErrorKind::Transport,
112            Self::Json(_)
113            | Self::TimeFormat(_)
114            | Self::UrlParse(_)
115            | Self::InvalidPageLimit
116            | Self::InvalidBatchConcurrency
117            | Self::InvalidRetryBounds { .. }
118            | Self::InvalidRequestBudget { .. }
119            | Self::Protocol(_) => ErrorKind::Protocol,
120            Self::EmptySearchQuery | Self::ApiMessage(_) => ErrorKind::Api,
121            Self::ApiStatus { status, .. } if *status == StatusCode::TOO_MANY_REQUESTS => {
122                ErrorKind::RateLimited
123            }
124            Self::ApiStatus { status, .. }
125                if *status == StatusCode::UNAUTHORIZED || *status == StatusCode::FORBIDDEN =>
126            {
127                ErrorKind::AuthRequired
128            }
129            Self::ApiStatus { .. } => ErrorKind::Api,
130            Self::HistoryEmpty { .. }
131            | Self::SymbolNotFound { .. }
132            | Self::QuoteEmpty { .. }
133            | Self::QuoteSymbolFailed { .. } => ErrorKind::SymbolNotFound,
134            Self::ScanValidationUnavailable { .. } | Self::UnsupportedScanFields { .. } => {
135                ErrorKind::Unsupported
136            }
137            Self::HistoryPaginationLimitExceeded { .. } => ErrorKind::Protocol,
138            Self::HistoryDownloadFailed { source, .. } => source.kind(),
139        }
140    }
141
142    pub fn is_retryable(&self) -> bool {
143        match self.kind() {
144            ErrorKind::RateLimited | ErrorKind::Transport => true,
145            ErrorKind::AuthRequired
146            | ErrorKind::SymbolNotFound
147            | ErrorKind::Protocol
148            | ErrorKind::Unsupported => false,
149            ErrorKind::Api => matches!(
150                self,
151                Self::ApiStatus { status, .. } if status.is_server_error()
152            ),
153        }
154    }
155
156    pub fn is_auth_error(&self) -> bool {
157        self.kind() == ErrorKind::AuthRequired
158    }
159
160    pub fn is_rate_limited(&self) -> bool {
161        self.kind() == ErrorKind::RateLimited
162    }
163
164    pub fn is_symbol_error(&self) -> bool {
165        self.kind() == ErrorKind::SymbolNotFound
166    }
167
168    pub fn is_transport_error(&self) -> bool {
169        self.kind() == ErrorKind::Transport
170    }
171
172    pub fn is_protocol_error(&self) -> bool {
173        self.kind() == ErrorKind::Protocol
174    }
175
176    pub fn is_unsupported_error(&self) -> bool {
177        self.kind() == ErrorKind::Unsupported
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn helper_methods_follow_error_kind_classification() {
187        let auth = Error::ApiStatus {
188            status: StatusCode::UNAUTHORIZED,
189            body: String::from("unauthorized"),
190        };
191        assert!(auth.is_auth_error());
192        assert!(!auth.is_retryable());
193
194        let rate_limited = Error::ApiStatus {
195            status: StatusCode::TOO_MANY_REQUESTS,
196            body: String::from("rate limited"),
197        };
198        assert!(rate_limited.is_rate_limited());
199        assert!(rate_limited.is_retryable());
200
201        let symbol = Error::SymbolNotFound {
202            symbol: String::from("NASDAQ:AAPL"),
203        };
204        assert!(symbol.is_symbol_error());
205        assert!(!symbol.is_retryable());
206
207        let transport = Error::ApiStatus {
208            status: StatusCode::BAD_GATEWAY,
209            body: String::from("upstream failed"),
210        };
211        assert_eq!(transport.kind(), ErrorKind::Api);
212        assert!(!transport.is_transport_error());
213        assert!(transport.is_retryable());
214
215        let protocol = Error::Protocol("bad frame");
216        assert!(protocol.is_protocol_error());
217        assert!(!protocol.is_retryable());
218
219        let unsupported = Error::UnsupportedScanFields {
220            route: String::from("america/scan"),
221            fields: vec![String::from("bad_field")],
222        };
223        assert!(unsupported.is_unsupported_error());
224        assert!(!unsupported.is_retryable());
225    }
226
227    #[test]
228    fn wrapped_history_download_failures_preserve_helper_behavior() {
229        let wrapped = Error::HistoryDownloadFailed {
230            symbol: String::from("NASDAQ:AAPL"),
231            source: Box::new(Error::ApiStatus {
232                status: StatusCode::TOO_MANY_REQUESTS,
233                body: String::from("rate limited"),
234            }),
235        };
236
237        assert!(wrapped.is_rate_limited());
238        assert!(wrapped.is_retryable());
239    }
240}