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.
48///
49/// A rate limit carries no `retry_after`: the SDK keeps no response headers, and Hyperliquid
50/// documents no wait.
51pub fn map_sdk_error(error: hyperliquid_rust_sdk::Error) -> UnindexedClientError {
52    // An HTTP 429 is a rate limit whatever its body says.
53    if let hyperliquid_rust_sdk::Error::ClientRequest {
54        status_code: 429, ..
55    } = error
56    {
57        return UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
58    }
59
60    let msg = error.to_string();
61    let msg_lower = msg.to_lowercase();
62
63    // Check for rate limiting first (transient). Matched on the words, not a bare "429", which
64    // an order id or a price in the message can hold.
65    if msg_lower.contains("rate limit") || msg_lower.contains("too many requests") {
66        return UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
67    }
68
69    // Check for connectivity/network errors (transient)
70    // Includes HTTP 5xx server errors which are typically transient
71    if msg_lower.contains("connection")
72        || msg_lower.contains("timeout")
73        || msg_lower.contains("network")
74        || msg_lower.contains("dns")
75        || msg_lower.contains("tls")
76        || msg_lower.contains("ssl")
77        || msg_lower.contains(" 500")
78        || msg_lower.contains(" 502")
79        || msg_lower.contains(" 503")
80        || msg_lower.contains(" 504")
81        || msg_lower.contains("bad gateway")
82        || msg_lower.contains("service unavailable")
83        || msg_lower.contains("gateway timeout")
84    {
85        return UnindexedClientError::Connectivity(ConnectivityError::Socket(msg));
86    }
87
88    // Check for authentication errors (permanent)
89    // Includes "eip712" for SDK's Error::Eip712 variant (EIP-712 signing failures)
90    if msg_lower.contains("signature")
91        || msg_lower.contains("unauthorized")
92        || msg_lower.contains("invalid key")
93        || msg_lower.contains("authentication")
94        || msg_lower.contains("eip712")
95    {
96        return UnindexedClientError::Api(UnindexedApiError::Unauthenticated(msg));
97    }
98
99    // Default to internal error (assumed non-transient)
100    UnindexedClientError::Internal(msg)
101}
102
103/// Maps an SDK error from placing or cancelling an order to `UnindexedOrderError`.
104///
105/// An unknown instrument is [`ApiError::InstrumentInvalid`]; anything else is classified by its
106/// text, as a refusal in a placement response is, so a shortfall of funds is
107/// [`ApiError::BalanceInsufficient`].
108/// A cancel never fails for want of funds, so it shares the classifier harmlessly.
109pub fn map_order_error(
110    error: hyperliquid_rust_sdk::Error,
111    instrument: &InstrumentNameExchange,
112) -> UnindexedOrderError {
113    let msg = error.to_string();
114    let msg_lower = msg.to_lowercase();
115
116    // Instrument not found / invalid
117    if msg_lower.contains("unknown")
118        || msg_lower.contains("not found")
119        || msg_lower.contains("invalid symbol")
120    {
121        return UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(instrument.clone(), msg));
122    }
123
124    // Anything else is classified by its text like a refusal in a response: a shortfall of
125    // funds is BalanceInsufficient, the rest OrderRejected.
126    UnindexedOrderError::Rejected(order_rejection(msg))
127}
128
129/// Classify why Hyperliquid refused an order: the error of a placement response or of the SDK
130/// call that sent it, or a status ending in `Rejected` from `orderUpdates` or `orderStatus`.
131///
132/// The account's funds running short is [`ApiError::BalanceInsufficient`], with no asset, since
133/// Hyperliquid does not name the one that ran short:
134/// - a perp order's "Insufficient margin to place order." and the `perpMarginRejected` status;
135/// - a spot order's "Order has insufficient spot balance to trade" and the
136///   `insufficientSpotBalanceRejected` status.
137///
138/// Anything else (minimum notional, reduce-only, open-interest caps, oracle, margin-tier limit,
139/// ...) is [`ApiError::OrderRejected`]. Either way the venue's text is kept.
140pub(super) fn order_rejection(reason: String) -> UnindexedApiError {
141    let lower = reason.to_ascii_lowercase();
142    let short_of_funds = matches!(
143        reason.as_str(),
144        "perpMarginRejected" | "insufficientSpotBalanceRejected"
145    ) || lower.contains("insufficient margin")
146        || lower.contains("insufficient spot balance");
147    if short_of_funds {
148        ApiError::BalanceInsufficient(None, reason)
149    } else {
150        ApiError::OrderRejected(reason)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use hyperliquid_rust_sdk::Error;
158
159    fn make_sdk_error(msg: &str) -> Error {
160        Error::GenericReader(msg.to_string())
161    }
162
163    #[test]
164    fn test_map_sdk_error_connectivity() {
165        let err = map_sdk_error(make_sdk_error("connection refused"));
166        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
167
168        let err = map_sdk_error(make_sdk_error("request timeout occurred"));
169        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
170
171        let err = map_sdk_error(make_sdk_error("DNS lookup failed"));
172        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
173
174        let err = map_sdk_error(make_sdk_error("TLS handshake error"));
175        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
176    }
177
178    #[test]
179    fn test_map_sdk_error_5xx_transient() {
180        // HTTP 5xx errors should be transient (server-side failures)
181        let err = map_sdk_error(make_sdk_error("HTTP 500 Internal Server Error"));
182        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
183
184        let err = map_sdk_error(make_sdk_error("502 Bad Gateway"));
185        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
186
187        let err = map_sdk_error(make_sdk_error("503 Service Unavailable"));
188        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
189
190        let err = map_sdk_error(make_sdk_error("504 Gateway Timeout"));
191        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
192    }
193
194    #[test]
195    fn test_map_sdk_error_rate_limit() {
196        let rate_limited = UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
197
198        let err = map_sdk_error(make_sdk_error("rate limit exceeded"));
199        assert_eq!(err, rate_limited);
200
201        let err = map_sdk_error(make_sdk_error("too many requests"));
202        assert_eq!(err, rate_limited);
203
204        let err = map_sdk_error(Error::ClientRequest {
205            status_code: 429,
206            error_code: None,
207            error_message: "slow down".to_string(),
208            error_data: None,
209        });
210        assert_eq!(err, rate_limited);
211    }
212
213    #[test]
214    fn a_message_merely_holding_429_is_not_a_rate_limit() {
215        let err = map_sdk_error(Error::ClientRequest {
216            status_code: 400,
217            error_code: None,
218            error_message: "Order 74291 has invalid price 4290".to_string(),
219            error_data: None,
220        });
221        assert!(
222            !matches!(
223                err,
224                UnindexedClientError::Api(UnindexedApiError::RateLimit { .. })
225            ),
226            "{err:?}"
227        );
228    }
229
230    #[test]
231    fn test_map_sdk_error_auth() {
232        let err = map_sdk_error(make_sdk_error("invalid signature"));
233        assert!(matches!(
234            err,
235            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
236        ));
237
238        let err = map_sdk_error(make_sdk_error("unauthorized access"));
239        assert!(matches!(
240            err,
241            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
242        ));
243
244        // SDK's Error::Eip712 variant formats as "Error from Eip712 struct: ..."
245        let err = map_sdk_error(make_sdk_error("Error from Eip712 struct: invalid key"));
246        assert!(matches!(
247            err,
248            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
249        ));
250    }
251
252    #[test]
253    fn test_map_sdk_error_internal() {
254        let err = map_sdk_error(make_sdk_error("some unknown error"));
255        assert!(matches!(err, UnindexedClientError::Internal(_)));
256    }
257
258    #[test]
259    fn test_map_order_error_instrument_invalid() {
260        let instrument = InstrumentNameExchange::from("INVALID-USDC-PERP");
261
262        let err = map_order_error(make_sdk_error("unknown asset"), &instrument);
263        assert!(matches!(
264            err,
265            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
266        ));
267
268        let err = map_order_error(make_sdk_error("Asset not found"), &instrument);
269        assert!(matches!(
270            err,
271            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
272        ));
273    }
274
275    #[test]
276    fn a_shortfall_of_funds_is_balance_insufficient_and_anything_else_a_rejection() {
277        for reason in [
278            "perpMarginRejected",
279            "insufficientSpotBalanceRejected",
280            "Insufficient margin to place order. asset=0",
281            "Order has insufficient spot balance to trade",
282            "INSUFFICIENT MARGIN TO PLACE ORDER.",
283        ] {
284            assert_eq!(
285                order_rejection(reason.to_owned()),
286                ApiError::BalanceInsufficient(None, reason.to_owned()),
287                "{reason}"
288            );
289        }
290        for reason in [
291            "minTradeNtlRejected",
292            "reduceOnlyRejected",
293            "positionIncreaseAtOpenInterestCapRejected",
294            "oracleRejected",
295            "perpMaxPositionRejected",
296            "marketOrderNoLiquidityRejected",
297            "badAloPxRejected",
298            "Order must have minimum value of $10.",
299        ] {
300            assert_eq!(
301                order_rejection(reason.to_owned()),
302                ApiError::OrderRejected(reason.to_owned()),
303                "{reason}"
304            );
305        }
306    }
307
308    #[test]
309    fn test_map_order_error_rejected() {
310        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
311
312        let err = map_order_error(make_sdk_error("insufficient margin"), &instrument);
313        assert!(matches!(
314            err,
315            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
316        ));
317
318        let err = map_order_error(make_sdk_error("price precision too high"), &instrument);
319        assert!(matches!(
320            err,
321            UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
322        ));
323    }
324
325    #[test]
326    fn a_connect_error_is_transient_only_when_the_request_was() {
327        let socket = ConnectivityError::Socket("reset".to_owned());
328        assert_eq!(
329            HyperliquidConnectError::from(UnindexedClientError::Connectivity(socket.clone())),
330            HyperliquidConnectError::Connectivity(socket)
331        );
332        assert!(matches!(
333            HyperliquidConnectError::from(UnindexedClientError::Api(
334                UnindexedApiError::rate_limited(None)
335            )),
336            HyperliquidConnectError::Connectivity(ConnectivityError::Socket(_))
337        ));
338        assert!(matches!(
339            HyperliquidConnectError::from(UnindexedClientError::Internal("bad json".to_owned())),
340            HyperliquidConnectError::Metadata(reason) if reason.contains("bad json")
341        ));
342    }
343}