Skip to main content

nautilus_hyperliquid/http/
error.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use nautilus_network::http::{HttpClientError, StatusCode};
17use thiserror::Error;
18
19/// Error type for Hyperliquid operations
20#[derive(Debug, Error)]
21pub enum Error {
22    /// Transport layer errors (network, connection issues)
23    #[error("transport error: {0}")]
24    Transport(String),
25
26    /// JSON serialization/deserialization errors
27    #[error("serde error: {0}")]
28    Serde(#[from] serde_json::Error),
29
30    /// Authentication errors (signature mismatch, wrong wallet)
31    #[error("auth error: {0}")]
32    Auth(String),
33
34    /// Rate limiting errors with optional retry information
35    #[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    /// Nonce window violations (nonces must be within time window and unique)
43    #[error("nonce window error: {0}")]
44    NonceWindow(String),
45
46    /// Bad request errors (client-side invalid payload)
47    #[error("bad request: {0}")]
48    BadRequest(String),
49
50    /// Exchange-specific errors from Hyperliquid server
51    #[error("exchange error: {0}")]
52    Exchange(String),
53
54    /// Request timeout
55    #[error("timeout")]
56    Timeout,
57
58    /// Message decoding/parsing errors
59    #[error("decode error: {0}")]
60    Decode(String),
61
62    /// Invariant violation (impossible state)
63    #[error("invariant violated: {0}")]
64    Invariant(&'static str),
65
66    /// HTTP errors with status code
67    #[error("HTTP error {status}: {message}")]
68    Http { status: u16, message: String },
69
70    /// URL parsing errors
71    #[error("URL parse error: {0}")]
72    UrlParse(#[from] url::ParseError),
73
74    /// Standard IO errors
75    #[error("IO error: {0}")]
76    Io(#[from] std::io::Error),
77}
78
79impl Error {
80    /// Create a transport error
81    pub fn transport(msg: impl Into<String>) -> Self {
82        Self::Transport(msg.into())
83    }
84
85    /// Create an auth error
86    pub fn auth(msg: impl Into<String>) -> Self {
87        Self::Auth(msg.into())
88    }
89
90    /// Create a rate limit error
91    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    /// Create a nonce window error
100    pub fn nonce_window(msg: impl Into<String>) -> Self {
101        Self::NonceWindow(msg.into())
102    }
103
104    /// Create a bad request error
105    pub fn bad_request(msg: impl Into<String>) -> Self {
106        Self::BadRequest(msg.into())
107    }
108
109    /// Create an exchange error
110    pub fn exchange(msg: impl Into<String>) -> Self {
111        Self::Exchange(msg.into())
112    }
113
114    /// Create a decode error
115    pub fn decode(msg: impl Into<String>) -> Self {
116        Self::Decode(msg.into())
117    }
118
119    /// Create an HTTP error
120    pub fn http(status: u16, message: impl Into<String>) -> Self {
121        Self::Http {
122            status,
123            message: message.into(),
124        }
125    }
126
127    /// Create an error from HTTP status code and body
128    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    /// Map HTTP client errors to appropriate error types
140    pub fn from_http_client(error: HttpClientError) -> Self {
141        match error {
142            HttpClientError::TimeoutError(_) => Self::Timeout,
143            e => Self::transport(format!("HTTP client error: {e}")),
144        }
145    }
146
147    /// Check if error is retryable
148    pub fn is_retryable(&self) -> bool {
149        match self {
150            Self::Transport(_) | Self::Timeout | Self::RateLimit { .. } => true,
151            Self::Http { status, .. } => *status == 408 || (500..=599).contains(status),
152            _ => false,
153        }
154    }
155
156    /// Check if error is due to rate limiting
157    pub fn is_rate_limited(&self) -> bool {
158        matches!(self, Self::RateLimit { .. })
159    }
160
161    /// Check if error is due to authentication issues
162    pub fn is_auth_error(&self) -> bool {
163        matches!(self, Self::Auth(_))
164    }
165
166    /// Check if the error is a transport-layer failure with undefined venue outcome.
167    pub fn is_transport_error(&self) -> bool {
168        matches!(self, Self::Transport(_) | Self::Timeout | Self::Io(_))
169    }
170
171    /// Check if the error is an HTTP 422 Unprocessable Entity response.
172    ///
173    /// Hyperliquid returns 422 from info endpoints that depend on the indexer
174    /// (such as `recentTrades`) when served by a node without it.
175    pub fn is_unprocessable_entity(&self) -> bool {
176        matches!(self, Self::Http { status: 422, .. })
177    }
178}
179
180/// Result type alias for Hyperliquid operations
181pub type Result<T> = std::result::Result<T, Error>;
182
183#[cfg(test)]
184mod tests {
185    use rstest::rstest;
186
187    use super::*;
188
189    #[rstest]
190    fn test_error_constructors() {
191        let transport_err = Error::transport("Connection failed");
192        assert!(matches!(transport_err, Error::Transport(_)));
193        assert_eq!(
194            transport_err.to_string(),
195            "transport error: Connection failed"
196        );
197
198        let auth_err = Error::auth("Invalid signature");
199        assert!(auth_err.is_auth_error());
200
201        let rate_limit_err = Error::rate_limit("test", 30, Some(30000));
202        assert!(rate_limit_err.is_rate_limited());
203        assert!(rate_limit_err.is_retryable());
204
205        let http_err = Error::http(500, "Internal server error");
206        assert!(http_err.is_retryable());
207    }
208
209    #[rstest]
210    fn test_error_display() {
211        let err = Error::RateLimit {
212            scope: "info",
213            weight: 20,
214            retry_after_ms: Some(60000),
215        };
216        assert_eq!(
217            err.to_string(),
218            "Rate limited on info (weight=20) retry_after_ms=Some(60000)"
219        );
220
221        let err = Error::NonceWindow("Nonce too old".to_string());
222        assert_eq!(err.to_string(), "nonce window error: Nonce too old");
223    }
224
225    #[rstest]
226    fn test_is_transport_error() {
227        assert!(Error::transport("conn dropped").is_transport_error());
228        assert!(Error::Timeout.is_transport_error());
229        assert!(
230            Error::Io(std::io::Error::new(
231                std::io::ErrorKind::ConnectionReset,
232                "x"
233            ))
234            .is_transport_error()
235        );
236
237        assert!(!Error::auth("bad signer").is_transport_error());
238        assert!(!Error::bad_request("malformed payload").is_transport_error());
239        assert!(!Error::http(503, "service unavailable").is_transport_error());
240        assert!(!Error::rate_limit("info", 1, None).is_transport_error());
241        assert!(!Error::exchange("HTTP 500").is_transport_error());
242        assert!(!Error::decode("bad json").is_transport_error());
243        assert!(!Error::nonce_window("stale").is_transport_error());
244    }
245
246    #[rstest]
247    fn test_is_unprocessable_entity() {
248        assert!(Error::http(422, "indexer unavailable").is_unprocessable_entity());
249
250        assert!(!Error::http(404, "not found").is_unprocessable_entity());
251        assert!(!Error::http(500, "server error").is_unprocessable_entity());
252        assert!(!Error::bad_request("malformed payload").is_unprocessable_entity());
253    }
254
255    #[rstest]
256    fn test_retryable_errors() {
257        assert!(Error::transport("test").is_retryable());
258        assert!(Error::Timeout.is_retryable());
259        assert!(Error::rate_limit("test", 10, None).is_retryable());
260        assert!(Error::http(500, "server error").is_retryable());
261
262        assert!(!Error::auth("test").is_retryable());
263        assert!(!Error::bad_request("test").is_retryable());
264        assert!(!Error::decode("test").is_retryable());
265    }
266}