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}