Skip to main content

hl_types/
error.rs

1/// Errors that can occur in Hyperliquid operations.
2#[derive(Debug, thiserror::Error)]
3#[non_exhaustive]
4pub enum HlError {
5    /// An error occurred during EIP-712 signing.
6    #[error("Signing error: {message}")]
7    Signing {
8        /// Human-readable description of the signing failure.
9        message: String,
10        /// Optional underlying error that caused this failure.
11        #[source]
12        source: Option<Box<dyn std::error::Error + Send + Sync>>,
13    },
14    /// An error occurred during serialization or deserialization.
15    #[error("Serialization error: {message}")]
16    Serialization {
17        /// Human-readable description of the serialization failure.
18        message: String,
19        /// Optional underlying error that caused this failure.
20        #[source]
21        source: Option<Box<dyn std::error::Error + Send + Sync>>,
22    },
23    /// An HTTP transport error (connection refused, DNS failure, etc.).
24    #[error("HTTP error: {message}")]
25    Http {
26        /// Human-readable description of the HTTP failure.
27        message: String,
28        /// Optional underlying error that caused this failure.
29        #[source]
30        source: Option<Box<dyn std::error::Error + Send + Sync>>,
31    },
32    /// The request timed out before receiving a response.
33    #[error("Timeout: {message}")]
34    Timeout {
35        /// Human-readable description of the timeout.
36        message: String,
37        /// Optional underlying error that caused this failure.
38        #[source]
39        source: Option<Box<dyn std::error::Error + Send + Sync>>,
40    },
41    /// A WebSocket transport error (connection lost, frame error, etc.).
42    #[error("WebSocket error: {message}")]
43    WebSocket {
44        /// Human-readable description of the WebSocket failure.
45        message: String,
46        /// Optional underlying error that caused this failure.
47        #[source]
48        source: Option<Box<dyn std::error::Error + Send + Sync>>,
49    },
50    /// The API returned a non-2xx HTTP status code.
51    #[error("API error (HTTP {status}): {body}")]
52    Api {
53        /// HTTP status code returned by the API.
54        status: u16,
55        /// Response body text.
56        body: String,
57    },
58    /// The exchange rejected the order.
59    #[error("Order rejected: {reason}")]
60    Rejected {
61        /// Rejection reason provided by the exchange.
62        reason: String,
63    },
64    /// The provided Ethereum address is invalid.
65    #[error("Invalid address: {0}")]
66    InvalidAddress(String),
67    /// The API returned HTTP 429 (rate limited).
68    #[error("Rate limited (429): retry after {retry_after_ms}ms")]
69    RateLimited {
70        /// Suggested wait time in milliseconds before retrying.
71        retry_after_ms: u64,
72        /// Human-readable rate-limit message.
73        message: String,
74    },
75    /// A JSON or data parsing error.
76    #[error("Parse error: {0}")]
77    Parse(String),
78    /// An input validation error (bad parameters, invalid amounts, unknown assets).
79    #[error("Validation error: {0}")]
80    Validation(String),
81    /// A configuration error (invalid settings, missing keys, etc.).
82    #[error("Config error: {0}")]
83    Config(String),
84    /// The WebSocket reconnect loop was cancelled.
85    #[error("WebSocket reconnect cancelled")]
86    WsCancelled,
87    /// The WebSocket reconnect loop exhausted all retry attempts.
88    #[error("WebSocket reconnect failed after {attempts} attempts")]
89    WsReconnectExhausted {
90        /// Number of reconnect attempts made before giving up.
91        attempts: u32,
92    },
93}
94
95impl HlError {
96    /// Create an `Http` error without an underlying source.
97    pub fn http(message: impl Into<String>) -> Self {
98        HlError::Http {
99            message: message.into(),
100            source: None,
101        }
102    }
103
104    /// Create a `Timeout` error without an underlying source.
105    pub fn timeout(message: impl Into<String>) -> Self {
106        HlError::Timeout {
107            message: message.into(),
108            source: None,
109        }
110    }
111
112    /// Create a `Signing` error without an underlying source.
113    pub fn signing(message: impl Into<String>) -> Self {
114        HlError::Signing {
115            message: message.into(),
116            source: None,
117        }
118    }
119
120    /// Create a `Serialization` error without an underlying source.
121    pub fn serialization(message: impl Into<String>) -> Self {
122        HlError::Serialization {
123            message: message.into(),
124            source: None,
125        }
126    }
127
128    /// Create a `WebSocket` error without an underlying source.
129    pub fn websocket(message: impl Into<String>) -> Self {
130        HlError::WebSocket {
131            message: message.into(),
132            source: None,
133        }
134    }
135
136    /// Returns `true` if the error is retryable (network timeout, 5xx, or 429).
137    pub fn is_retryable(&self) -> bool {
138        match self {
139            HlError::Http { .. } => true,
140            HlError::Timeout { .. } => true,
141            HlError::WebSocket { .. } => true,
142            HlError::RateLimited { .. } => true,
143            HlError::Api { status, .. } => {
144                // Retryable if server error (5xx)
145                *status >= 500
146            }
147            _ => false,
148        }
149    }
150
151    /// If this is a `RateLimited` error, returns the suggested wait time in milliseconds.
152    pub fn retry_after_ms(&self) -> Option<u64> {
153        match self {
154            HlError::RateLimited { retry_after_ms, .. } => Some(*retry_after_ms),
155            _ => None,
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn is_retryable_http_error() {
166        assert!(HlError::http("timeout").is_retryable());
167    }
168
169    #[test]
170    fn is_retryable_rate_limited() {
171        assert!(HlError::RateLimited {
172            retry_after_ms: 1000,
173            message: "slow down".into()
174        }
175        .is_retryable());
176    }
177
178    #[test]
179    fn is_retryable_api_5xx() {
180        assert!(HlError::Api {
181            status: 500,
182            body: "internal error".into()
183        }
184        .is_retryable());
185        assert!(HlError::Api {
186            status: 502,
187            body: "bad gateway".into()
188        }
189        .is_retryable());
190        assert!(HlError::Api {
191            status: 503,
192            body: "unavailable".into()
193        }
194        .is_retryable());
195    }
196
197    #[test]
198    fn not_retryable_api_4xx() {
199        assert!(!HlError::Api {
200            status: 400,
201            body: "bad request".into()
202        }
203        .is_retryable());
204        assert!(!HlError::Api {
205            status: 404,
206            body: "not found".into()
207        }
208        .is_retryable());
209        assert!(!HlError::Api {
210            status: 422,
211            body: "unprocessable".into()
212        }
213        .is_retryable());
214    }
215
216    #[test]
217    fn is_retryable_timeout() {
218        assert!(HlError::timeout("request timed out").is_retryable());
219    }
220
221    #[test]
222    fn is_retryable_websocket() {
223        assert!(HlError::websocket("connection failed").is_retryable());
224    }
225
226    #[test]
227    fn not_retryable_rejected() {
228        assert!(!HlError::Rejected {
229            reason: "order rejected".into()
230        }
231        .is_retryable());
232    }
233
234    #[test]
235    fn not_retryable_signing() {
236        assert!(!HlError::signing("key error").is_retryable());
237    }
238
239    #[test]
240    fn not_retryable_parse() {
241        assert!(!HlError::Parse("bad json".into()).is_retryable());
242    }
243
244    #[test]
245    fn not_retryable_serialization() {
246        assert!(!HlError::serialization("serde fail").is_retryable());
247    }
248
249    #[test]
250    fn not_retryable_invalid_address() {
251        assert!(!HlError::InvalidAddress("bad addr".into()).is_retryable());
252    }
253
254    #[test]
255    fn retry_after_ms_rate_limited() {
256        let err = HlError::RateLimited {
257            retry_after_ms: 5000,
258            message: "".into(),
259        };
260        assert_eq!(err.retry_after_ms(), Some(5000));
261    }
262
263    #[test]
264    fn retry_after_ms_none_for_other_errors() {
265        assert_eq!(HlError::http("x").retry_after_ms(), None);
266        assert_eq!(HlError::timeout("x").retry_after_ms(), None);
267        assert_eq!(HlError::websocket("x").retry_after_ms(), None);
268        assert_eq!(HlError::signing("x").retry_after_ms(), None);
269        assert_eq!(HlError::Parse("x".into()).retry_after_ms(), None);
270        assert_eq!(
271            HlError::Api {
272                status: 500,
273                body: "x".into()
274            }
275            .retry_after_ms(),
276            None
277        );
278        assert_eq!(
279            HlError::Rejected { reason: "x".into() }.retry_after_ms(),
280            None
281        );
282    }
283
284    #[test]
285    fn error_display_formatting() {
286        let err = HlError::http("connection refused");
287        assert_eq!(format!("{err}"), "HTTP error: connection refused");
288
289        let err = HlError::Api {
290            status: 404,
291            body: "not found".into(),
292        };
293        assert_eq!(format!("{err}"), "API error (HTTP 404): not found");
294
295        let err = HlError::RateLimited {
296            retry_after_ms: 2000,
297            message: "slow".into(),
298        };
299        assert!(format!("{err}").contains("2000ms"));
300
301        let err = HlError::timeout("request timed out");
302        assert_eq!(format!("{err}"), "Timeout: request timed out");
303
304        let err = HlError::websocket("connection failed");
305        assert_eq!(format!("{err}"), "WebSocket error: connection failed");
306
307        let err = HlError::Rejected {
308            reason: "insufficient margin".into(),
309        };
310        assert_eq!(format!("{err}"), "Order rejected: insufficient margin");
311    }
312
313    #[test]
314    fn http_error_with_source_preserves_chain() {
315        let io_err =
316            std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
317        let err = HlError::Http {
318            message: "request failed".into(),
319            source: Some(Box::new(io_err)),
320        };
321        assert!(
322            std::error::Error::source(&err).is_some(),
323            "source should be present when provided"
324        );
325    }
326
327    #[test]
328    fn http_error_without_source() {
329        let err = HlError::http("no underlying cause");
330        assert!(
331            std::error::Error::source(&err).is_none(),
332            "source should be None for convenience constructor"
333        );
334    }
335
336    #[test]
337    fn serialization_not_retryable() {
338        let err = HlError::serialization("bad json");
339        assert!(
340            !err.is_retryable(),
341            "Serialization errors should not be retryable"
342        );
343    }
344
345    #[test]
346    fn config_error_not_retryable() {
347        let err = HlError::Config("invalid timeout".into());
348        assert!(!err.is_retryable(), "Config errors should not be retryable");
349    }
350
351    #[test]
352    fn config_error_display() {
353        let err = HlError::Config("missing API key".into());
354        assert_eq!(format!("{err}"), "Config error: missing API key");
355    }
356}