Skip to main content

rustrade_execution/client/hyperliquid/
error.rs

1//! Error mapping for Hyperliquid SDK errors to rustrade-execution error types.
2
3use crate::error::{
4    ApiError, ConnectivityError, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
5};
6use rustrade_instrument::instrument::name::InstrumentNameExchange;
7use smol_str::SmolStr;
8
9/// Why [`HyperliquidClient::connect`](super::HyperliquidClient::connect) failed.
10#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
11#[non_exhaustive]
12pub enum HyperliquidConnectError {
13    /// Hyperliquid could not be reached, or did not answer as it should. Transient: retry with
14    /// backoff.
15    #[error("{0}")]
16    Connectivity(#[from] ConnectivityError),
17
18    /// A DEX in [`HyperliquidConfig::dexes`](super::HyperliquidConfig::dexes) is not one
19    /// Hyperliquid lists on the configured network. Retrying does not help: fix the config.
20    #[error("Hyperliquid lists no perpetual DEX named {0:?} on this network")]
21    UnknownDex(SmolStr),
22
23    /// Hyperliquid's description of a configured DEX could not be read or makes no sense, such
24    /// as a response that does not parse or a collateral token it does not list. Not transient:
25    /// a retry is unlikely to read anything different.
26    #[error("Hyperliquid perpetual DEX metadata: {0}")]
27    Metadata(String),
28}
29
30impl From<UnindexedClientError> for HyperliquidConnectError {
31    /// A connectivity error stays one, and a rate limit becomes one, since both pass; anything
32    /// else means the response could not be used.
33    fn from(error: UnindexedClientError) -> Self {
34        match error {
35            UnindexedClientError::Connectivity(error) => Self::Connectivity(error),
36            UnindexedClientError::Api(UnindexedApiError::RateLimit) => {
37                Self::Connectivity(ConnectivityError::Socket(error.to_string()))
38            }
39            other => Self::Metadata(other.to_string()),
40        }
41    }
42}
43
44/// Maps Hyperliquid SDK errors to `UnindexedClientError`.
45///
46/// The SDK uses its own error type internally, so we pattern-match on error messages
47/// to determine transient vs permanent errors.
48pub fn map_sdk_error(error: hyperliquid_rust_sdk::Error) -> UnindexedClientError {
49    let msg = error.to_string();
50    let msg_lower = msg.to_lowercase();
51
52    // Check for rate limiting first (transient) — avoid full msg allocation when possible
53    if msg_lower.contains("rate limit")
54        || msg_lower.contains("too many requests")
55        || msg_lower.contains("429")
56    {
57        return UnindexedClientError::Api(UnindexedApiError::RateLimit);
58    }
59
60    // Check for connectivity/network errors (transient)
61    // Includes HTTP 5xx server errors which are typically transient
62    if msg_lower.contains("connection")
63        || msg_lower.contains("timeout")
64        || msg_lower.contains("network")
65        || msg_lower.contains("dns")
66        || msg_lower.contains("tls")
67        || msg_lower.contains("ssl")
68        || msg_lower.contains(" 500")
69        || msg_lower.contains(" 502")
70        || msg_lower.contains(" 503")
71        || msg_lower.contains(" 504")
72        || msg_lower.contains("bad gateway")
73        || msg_lower.contains("service unavailable")
74        || msg_lower.contains("gateway timeout")
75    {
76        return UnindexedClientError::Connectivity(ConnectivityError::Socket(msg));
77    }
78
79    // Check for authentication errors (permanent)
80    // Includes "eip712" for SDK's Error::Eip712 variant (EIP-712 signing failures)
81    if msg_lower.contains("signature")
82        || msg_lower.contains("unauthorized")
83        || msg_lower.contains("invalid key")
84        || msg_lower.contains("authentication")
85        || msg_lower.contains("eip712")
86    {
87        return UnindexedClientError::Api(UnindexedApiError::Unauthenticated(msg));
88    }
89
90    // Default to internal error (assumed non-transient)
91    UnindexedClientError::Internal(msg)
92}
93
94/// Maps Hyperliquid order placement errors to `UnindexedOrderError`.
95///
96/// Hyperliquid returns specific error codes/messages for order rejections.
97pub fn map_order_error(
98    error: hyperliquid_rust_sdk::Error,
99    instrument: &InstrumentNameExchange,
100) -> UnindexedOrderError {
101    let msg = error.to_string();
102    let msg_lower = msg.to_lowercase();
103
104    // Instrument not found / invalid
105    if msg_lower.contains("unknown")
106        || msg_lower.contains("not found")
107        || msg_lower.contains("invalid symbol")
108    {
109        return UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(instrument.clone(), msg));
110    }
111
112    // All other rejections (insufficient balance, post-only crossed, price precision, etc.)
113    // Use OrderRejected since we can't cleanly extract the specific error type without
114    // more structured error responses from the SDK.
115    UnindexedOrderError::Rejected(ApiError::OrderRejected(msg))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use hyperliquid_rust_sdk::Error;
122
123    fn make_sdk_error(msg: &str) -> Error {
124        Error::GenericReader(msg.to_string())
125    }
126
127    #[test]
128    fn test_map_sdk_error_connectivity() {
129        let err = map_sdk_error(make_sdk_error("connection refused"));
130        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
131
132        let err = map_sdk_error(make_sdk_error("request timeout occurred"));
133        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
134
135        let err = map_sdk_error(make_sdk_error("DNS lookup failed"));
136        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
137
138        let err = map_sdk_error(make_sdk_error("TLS handshake error"));
139        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
140    }
141
142    #[test]
143    fn test_map_sdk_error_5xx_transient() {
144        // HTTP 5xx errors should be transient (server-side failures)
145        let err = map_sdk_error(make_sdk_error("HTTP 500 Internal Server Error"));
146        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
147
148        let err = map_sdk_error(make_sdk_error("502 Bad Gateway"));
149        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
150
151        let err = map_sdk_error(make_sdk_error("503 Service Unavailable"));
152        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
153
154        let err = map_sdk_error(make_sdk_error("504 Gateway Timeout"));
155        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
156    }
157
158    #[test]
159    fn test_map_sdk_error_rate_limit() {
160        let err = map_sdk_error(make_sdk_error("rate limit exceeded"));
161        assert!(matches!(
162            err,
163            UnindexedClientError::Api(UnindexedApiError::RateLimit)
164        ));
165
166        let err = map_sdk_error(make_sdk_error("too many requests"));
167        assert!(matches!(
168            err,
169            UnindexedClientError::Api(UnindexedApiError::RateLimit)
170        ));
171
172        let err = map_sdk_error(make_sdk_error("HTTP 429"));
173        assert!(matches!(
174            err,
175            UnindexedClientError::Api(UnindexedApiError::RateLimit)
176        ));
177    }
178
179    #[test]
180    fn test_map_sdk_error_auth() {
181        let err = map_sdk_error(make_sdk_error("invalid signature"));
182        assert!(matches!(
183            err,
184            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
185        ));
186
187        let err = map_sdk_error(make_sdk_error("unauthorized access"));
188        assert!(matches!(
189            err,
190            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
191        ));
192
193        // SDK's Error::Eip712 variant formats as "Error from Eip712 struct: ..."
194        let err = map_sdk_error(make_sdk_error("Error from Eip712 struct: invalid key"));
195        assert!(matches!(
196            err,
197            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
198        ));
199    }
200
201    #[test]
202    fn test_map_sdk_error_internal() {
203        let err = map_sdk_error(make_sdk_error("some unknown error"));
204        assert!(matches!(err, UnindexedClientError::Internal(_)));
205    }
206
207    #[test]
208    fn test_map_order_error_instrument_invalid() {
209        let instrument = InstrumentNameExchange::from("INVALID-USDC-PERP");
210
211        let err = map_order_error(make_sdk_error("unknown asset"), &instrument);
212        assert!(matches!(
213            err,
214            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
215        ));
216
217        let err = map_order_error(make_sdk_error("Asset not found"), &instrument);
218        assert!(matches!(
219            err,
220            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
221        ));
222    }
223
224    #[test]
225    fn test_map_order_error_rejected() {
226        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
227
228        let err = map_order_error(make_sdk_error("insufficient margin"), &instrument);
229        assert!(matches!(
230            err,
231            UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
232        ));
233
234        let err = map_order_error(make_sdk_error("price precision too high"), &instrument);
235        assert!(matches!(
236            err,
237            UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
238        ));
239    }
240
241    #[test]
242    fn a_connect_error_is_transient_only_when_the_request_was() {
243        let socket = ConnectivityError::Socket("reset".to_owned());
244        assert_eq!(
245            HyperliquidConnectError::from(UnindexedClientError::Connectivity(socket.clone())),
246            HyperliquidConnectError::Connectivity(socket)
247        );
248        assert!(matches!(
249            HyperliquidConnectError::from(UnindexedClientError::Api(UnindexedApiError::RateLimit)),
250            HyperliquidConnectError::Connectivity(ConnectivityError::Socket(_))
251        ));
252        assert!(matches!(
253            HyperliquidConnectError::from(UnindexedClientError::Internal("bad json".to_owned())),
254            HyperliquidConnectError::Metadata(reason) if reason.contains("bad json")
255        ));
256    }
257}