nautilus_hyperliquid/http/
error.rs1use nautilus_network::http::{HttpClientError, ReqwestError, StatusCode};
17use thiserror::Error;
18
19#[derive(Debug, Error)]
21pub enum Error {
22 #[error("transport error: {0}")]
24 Transport(String),
25
26 #[error("serde error: {0}")]
28 Serde(#[from] serde_json::Error),
29
30 #[error("auth error: {0}")]
32 Auth(String),
33
34 #[error("Rate limited on {scope} (weight={weight}) retry_after_ms={retry_after_ms:?}")]
36 RateLimit {
37 scope: &'static str,
38 weight: u32,
39 retry_after_ms: Option<u64>,
40 },
41
42 #[error("nonce window error: {0}")]
44 NonceWindow(String),
45
46 #[error("bad request: {0}")]
48 BadRequest(String),
49
50 #[error("exchange error: {0}")]
52 Exchange(String),
53
54 #[error("timeout")]
56 Timeout,
57
58 #[error("decode error: {0}")]
60 Decode(String),
61
62 #[error("invariant violated: {0}")]
64 Invariant(&'static str),
65
66 #[error("HTTP error {status}: {message}")]
68 Http { status: u16, message: String },
69
70 #[error("URL parse error: {0}")]
72 UrlParse(#[from] url::ParseError),
73
74 #[error("IO error: {0}")]
76 Io(#[from] std::io::Error),
77}
78
79impl Error {
80 pub fn transport(msg: impl Into<String>) -> Self {
82 Self::Transport(msg.into())
83 }
84
85 pub fn auth(msg: impl Into<String>) -> Self {
87 Self::Auth(msg.into())
88 }
89
90 pub fn rate_limit(scope: &'static str, weight: u32, retry_after_ms: Option<u64>) -> Self {
92 Self::RateLimit {
93 scope,
94 weight,
95 retry_after_ms,
96 }
97 }
98
99 pub fn nonce_window(msg: impl Into<String>) -> Self {
101 Self::NonceWindow(msg.into())
102 }
103
104 pub fn bad_request(msg: impl Into<String>) -> Self {
106 Self::BadRequest(msg.into())
107 }
108
109 pub fn exchange(msg: impl Into<String>) -> Self {
111 Self::Exchange(msg.into())
112 }
113
114 pub fn decode(msg: impl Into<String>) -> Self {
116 Self::Decode(msg.into())
117 }
118
119 pub fn http(status: u16, message: impl Into<String>) -> Self {
121 Self::Http {
122 status,
123 message: message.into(),
124 }
125 }
126
127 pub fn from_http_status(status: StatusCode, body: &[u8]) -> Self {
129 let message = String::from_utf8_lossy(body).to_string();
130 match status.as_u16() {
131 401 | 403 => Self::auth(format!("HTTP {}: {}", status.as_u16(), message)),
132 400 => Self::bad_request(format!("HTTP {}: {}", status.as_u16(), message)),
133 429 => Self::rate_limit("unknown", 0, None),
134 500..=599 => Self::exchange(format!("HTTP {}: {}", status.as_u16(), message)),
135 _ => Self::http(status.as_u16(), message),
136 }
137 }
138
139 #[expect(clippy::needless_pass_by_value)]
141 pub fn from_reqwest(error: ReqwestError) -> Self {
142 if error.is_timeout() {
143 Self::Timeout
144 } else if let Some(status) = error.status() {
145 let status_code = status.as_u16();
146 match status_code {
147 401 | 403 => Self::auth(format!("HTTP {status_code}: authentication failed")),
148 400 => Self::bad_request(format!("HTTP {status_code}: bad request")),
149 429 => Self::rate_limit("unknown", 0, None),
150 500..=599 => Self::exchange(format!("HTTP {status_code}: server error")),
151 _ => Self::http(status_code, format!("HTTP error: {error}")),
152 }
153 } else if error.is_connect() || error.is_request() {
154 Self::transport(format!("Request error: {error}"))
155 } else {
156 Self::transport(format!("Unknown reqwest error: {error}"))
157 }
158 }
159
160 #[expect(clippy::needless_pass_by_value)]
162 pub fn from_http_client(error: HttpClientError) -> Self {
163 Self::transport(format!("HTTP client error: {error}"))
164 }
165
166 pub fn is_retryable(&self) -> bool {
168 match self {
169 Self::Transport(_) | Self::Timeout | Self::RateLimit { .. } => true,
170 Self::Http { status, .. } => *status >= 500,
171 _ => false,
172 }
173 }
174
175 pub fn is_rate_limited(&self) -> bool {
177 matches!(self, Self::RateLimit { .. })
178 }
179
180 pub fn is_auth_error(&self) -> bool {
182 matches!(self, Self::Auth(_))
183 }
184
185 pub fn is_transport_error(&self) -> bool {
187 matches!(self, Self::Transport(_) | Self::Timeout | Self::Io(_))
188 }
189
190 pub fn is_unprocessable_entity(&self) -> bool {
195 matches!(self, Self::Http { status: 422, .. })
196 }
197}
198
199pub type Result<T> = std::result::Result<T, Error>;
201
202#[cfg(test)]
203mod tests {
204 use rstest::rstest;
205
206 use super::*;
207
208 #[rstest]
209 fn test_error_constructors() {
210 let transport_err = Error::transport("Connection failed");
211 assert!(matches!(transport_err, Error::Transport(_)));
212 assert_eq!(
213 transport_err.to_string(),
214 "transport error: Connection failed"
215 );
216
217 let auth_err = Error::auth("Invalid signature");
218 assert!(auth_err.is_auth_error());
219
220 let rate_limit_err = Error::rate_limit("test", 30, Some(30000));
221 assert!(rate_limit_err.is_rate_limited());
222 assert!(rate_limit_err.is_retryable());
223
224 let http_err = Error::http(500, "Internal server error");
225 assert!(http_err.is_retryable());
226 }
227
228 #[rstest]
229 fn test_error_display() {
230 let err = Error::RateLimit {
231 scope: "info",
232 weight: 20,
233 retry_after_ms: Some(60000),
234 };
235 assert_eq!(
236 err.to_string(),
237 "Rate limited on info (weight=20) retry_after_ms=Some(60000)"
238 );
239
240 let err = Error::NonceWindow("Nonce too old".to_string());
241 assert_eq!(err.to_string(), "nonce window error: Nonce too old");
242 }
243
244 #[rstest]
245 fn test_is_transport_error() {
246 assert!(Error::transport("conn dropped").is_transport_error());
247 assert!(Error::Timeout.is_transport_error());
248 assert!(
249 Error::Io(std::io::Error::new(
250 std::io::ErrorKind::ConnectionReset,
251 "x"
252 ))
253 .is_transport_error()
254 );
255
256 assert!(!Error::auth("bad signer").is_transport_error());
257 assert!(!Error::bad_request("malformed payload").is_transport_error());
258 assert!(!Error::http(503, "service unavailable").is_transport_error());
259 assert!(!Error::rate_limit("info", 1, None).is_transport_error());
260 assert!(!Error::exchange("HTTP 500").is_transport_error());
261 assert!(!Error::decode("bad json").is_transport_error());
262 assert!(!Error::nonce_window("stale").is_transport_error());
263 }
264
265 #[rstest]
266 fn test_is_unprocessable_entity() {
267 assert!(Error::http(422, "indexer unavailable").is_unprocessable_entity());
268
269 assert!(!Error::http(404, "not found").is_unprocessable_entity());
270 assert!(!Error::http(500, "server error").is_unprocessable_entity());
271 assert!(!Error::bad_request("malformed payload").is_unprocessable_entity());
272 }
273
274 #[rstest]
275 fn test_retryable_errors() {
276 assert!(Error::transport("test").is_retryable());
277 assert!(Error::Timeout.is_retryable());
278 assert!(Error::rate_limit("test", 10, None).is_retryable());
279 assert!(Error::http(500, "server error").is_retryable());
280
281 assert!(!Error::auth("test").is_retryable());
282 assert!(!Error::bad_request("test").is_retryable());
283 assert!(!Error::decode("test").is_retryable());
284 }
285}