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}