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`, by whether
104/// Hyperliquid may have acted on the request.
105///
106/// The SDK reports whatever goes wrong once the request is signed as one variant,
107/// [`Error::JsonParse`](hyperliquid_rust_sdk::Error::JsonParse), holding the underlying error's
108/// text: a failure to send the request or to read the answer, an HTTP 4xx or 5xx, and an answer
109/// that does not parse. So its text decides:
110/// - **An HTTP 4xx is a refusal.** The request was turned away: Hyperliquid answers an
111///   `/exchange` request it acts on with 200, refusals included. This rests on the venue's
112///   behaviour; a proxy in between that forwarded the request and then answered 4xx would be
113///   misread. A 429 is [`ApiError::RateLimit`]; any other 4xx is classified as below.
114/// - **Anything else in `JsonParse` is [`OrderError::Connectivity`]**: the request may have
115///   reached the venue, so its outcome is unknown, and the engine looks an open up by its client
116///   order id. `JsonParse` also covers encoding and building the request before the send, which
117///   the requests this client builds do not fail; if one did, the lookups would find the order
118///   never placed.
119///
120/// Every other variant fails before the request is sent (an asset the SDK does not list,
121/// signing, a number it cannot encode, ...), so it is a refusal too.
122///
123/// A refusal naming an unknown instrument is [`ApiError::InstrumentInvalid`]. Any other is
124/// classified by its text, as a refusal in a placement response is, so a shortfall of funds is
125/// [`ApiError::BalanceInsufficient`]. A cancel never fails for want of funds, so it shares the
126/// classifier harmlessly.
127///
128/// [`OrderError::Connectivity`]: crate::error::OrderError::Connectivity
129pub fn map_order_error(
130    error: hyperliquid_rust_sdk::Error,
131    instrument: &InstrumentNameExchange,
132) -> UnindexedOrderError {
133    let hyperliquid_rust_sdk::Error::JsonParse(text) = error else {
134        return refusal(error.to_string(), instrument);
135    };
136    match client_error_status(&text) {
137        Some(429) => UnindexedOrderError::Rejected(ApiError::rate_limited(None)),
138        Some(_) => refusal(text, instrument),
139        None => UnindexedOrderError::Connectivity(ConnectivityError::Socket(text)),
140    }
141}
142
143/// What the SDK's text for an HTTP 4xx answer (`Error::ClientRequest`'s `Display`) starts with,
144/// followed by the status code and a comma.
145///
146/// The SDK's text is the contract here: if it changed, a 4xx would read as an unknown outcome.
147/// The tests build their input from the SDK's own `Display`, so such a change fails them.
148const CLIENT_ERROR_PREFIX: &str = "Client error: status code: ";
149
150/// The HTTP status of a 4xx answer, read from the text the SDK's `/exchange` post wraps in
151/// `JsonParse`. `None` for any other text.
152fn client_error_status(text: &str) -> Option<u16> {
153    let (status, _) = text.strip_prefix(CLIENT_ERROR_PREFIX)?.split_once(',')?;
154    status
155        .parse()
156        .ok()
157        .filter(|status| (400..500).contains(status))
158}
159
160/// A refusal of an order request, classified by its text `msg`.
161fn refusal(msg: String, instrument: &InstrumentNameExchange) -> UnindexedOrderError {
162    let msg_lower = msg.to_lowercase();
163
164    // Instrument not found / invalid
165    if msg_lower.contains("unknown")
166        || msg_lower.contains("not found")
167        || msg_lower.contains("invalid symbol")
168    {
169        return UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(instrument.clone(), msg));
170    }
171
172    // Anything else is classified by its text like a refusal in a response: a shortfall of
173    // funds is BalanceInsufficient, the rest OrderRejected.
174    UnindexedOrderError::Rejected(order_rejection(msg))
175}
176
177/// An order answered `ok` with a bare `success` status or with none: Hyperliquid took the
178/// request but did not say what became of the order, which may be live. Its outcome is unknown,
179/// as [`map_order_error`] reads a failure after the send.
180pub(super) fn order_outcome_unanswered() -> UnindexedOrderError {
181    UnindexedOrderError::Connectivity(ConnectivityError::Socket(
182        "Hyperliquid answered the order without its status".to_owned(),
183    ))
184}
185
186/// Classify why Hyperliquid refused an order: the error of a placement response or of the SDK
187/// call that sent it, or a status ending in `Rejected` from `orderUpdates` or `orderStatus`.
188///
189/// The account's funds running short is [`ApiError::BalanceInsufficient`], with no asset, since
190/// Hyperliquid does not name the one that ran short:
191/// - a perp order's "Insufficient margin to place order." and the `perpMarginRejected` status;
192/// - a spot order's "Order has insufficient spot balance to trade" and the
193///   `insufficientSpotBalanceRejected` status.
194///
195/// Anything else (minimum notional, reduce-only, open-interest caps, oracle, margin-tier limit,
196/// ...) is [`ApiError::OrderRejected`]. Either way the venue's text is kept.
197pub(super) fn order_rejection(reason: String) -> UnindexedApiError {
198    let lower = reason.to_ascii_lowercase();
199    let short_of_funds = matches!(
200        reason.as_str(),
201        "perpMarginRejected" | "insufficientSpotBalanceRejected"
202    ) || lower.contains("insufficient margin")
203        || lower.contains("insufficient spot balance");
204    if short_of_funds {
205        ApiError::BalanceInsufficient(None, reason)
206    } else {
207        ApiError::OrderRejected(reason)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use hyperliquid_rust_sdk::Error;
215
216    fn make_sdk_error(msg: &str) -> Error {
217        Error::GenericReader(msg.to_string())
218    }
219
220    #[test]
221    fn test_map_sdk_error_connectivity() {
222        let err = map_sdk_error(make_sdk_error("connection refused"));
223        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
224
225        let err = map_sdk_error(make_sdk_error("request timeout occurred"));
226        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
227
228        let err = map_sdk_error(make_sdk_error("DNS lookup failed"));
229        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
230
231        let err = map_sdk_error(make_sdk_error("TLS handshake error"));
232        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
233    }
234
235    #[test]
236    fn test_map_sdk_error_5xx_transient() {
237        // HTTP 5xx errors should be transient (server-side failures)
238        let err = map_sdk_error(make_sdk_error("HTTP 500 Internal Server Error"));
239        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
240
241        let err = map_sdk_error(make_sdk_error("502 Bad Gateway"));
242        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
243
244        let err = map_sdk_error(make_sdk_error("503 Service Unavailable"));
245        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
246
247        let err = map_sdk_error(make_sdk_error("504 Gateway Timeout"));
248        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
249    }
250
251    #[test]
252    fn test_map_sdk_error_rate_limit() {
253        let rate_limited = UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
254
255        let err = map_sdk_error(make_sdk_error("rate limit exceeded"));
256        assert_eq!(err, rate_limited);
257
258        let err = map_sdk_error(make_sdk_error("too many requests"));
259        assert_eq!(err, rate_limited);
260
261        let err = map_sdk_error(Error::ClientRequest {
262            status_code: 429,
263            error_code: None,
264            error_message: "slow down".to_string(),
265            error_data: None,
266        });
267        assert_eq!(err, rate_limited);
268    }
269
270    #[test]
271    fn a_message_merely_holding_429_is_not_a_rate_limit() {
272        let err = map_sdk_error(Error::ClientRequest {
273            status_code: 400,
274            error_code: None,
275            error_message: "Order 74291 has invalid price 4290".to_string(),
276            error_data: None,
277        });
278        assert!(
279            !matches!(
280                err,
281                UnindexedClientError::Api(UnindexedApiError::RateLimit { .. })
282            ),
283            "{err:?}"
284        );
285    }
286
287    #[test]
288    fn test_map_sdk_error_auth() {
289        let err = map_sdk_error(make_sdk_error("invalid signature"));
290        assert!(matches!(
291            err,
292            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
293        ));
294
295        let err = map_sdk_error(make_sdk_error("unauthorized access"));
296        assert!(matches!(
297            err,
298            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
299        ));
300
301        // SDK's Error::Eip712 variant formats as "Error from Eip712 struct: ..."
302        let err = map_sdk_error(make_sdk_error("Error from Eip712 struct: invalid key"));
303        assert!(matches!(
304            err,
305            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
306        ));
307    }
308
309    #[test]
310    fn test_map_sdk_error_internal() {
311        let err = map_sdk_error(make_sdk_error("some unknown error"));
312        assert!(matches!(err, UnindexedClientError::Internal(_)));
313    }
314
315    #[test]
316    fn test_map_order_error_instrument_invalid() {
317        let instrument = InstrumentNameExchange::from("INVALID-USDC-PERP");
318
319        let err = map_order_error(make_sdk_error("unknown asset"), &instrument);
320        assert!(matches!(
321            err,
322            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
323        ));
324
325        let err = map_order_error(make_sdk_error("Asset not found"), &instrument);
326        assert!(matches!(
327            err,
328            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
329        ));
330    }
331
332    #[test]
333    fn a_shortfall_of_funds_is_balance_insufficient_and_anything_else_a_rejection() {
334        for reason in [
335            "perpMarginRejected",
336            "insufficientSpotBalanceRejected",
337            "Insufficient margin to place order. asset=0",
338            "Order has insufficient spot balance to trade",
339            "INSUFFICIENT MARGIN TO PLACE ORDER.",
340        ] {
341            assert_eq!(
342                order_rejection(reason.to_owned()),
343                ApiError::BalanceInsufficient(None, reason.to_owned()),
344                "{reason}"
345            );
346        }
347        for reason in [
348            "minTradeNtlRejected",
349            "reduceOnlyRejected",
350            "positionIncreaseAtOpenInterestCapRejected",
351            "oracleRejected",
352            "perpMaxPositionRejected",
353            "marketOrderNoLiquidityRejected",
354            "badAloPxRejected",
355            "Order must have minimum value of $10.",
356        ] {
357            assert_eq!(
358                order_rejection(reason.to_owned()),
359                ApiError::OrderRejected(reason.to_owned()),
360                "{reason}"
361            );
362        }
363    }
364
365    #[test]
366    fn test_map_order_error_rejected() {
367        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
368
369        let err = map_order_error(make_sdk_error("insufficient margin"), &instrument);
370        assert!(matches!(
371            err,
372            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
373        ));
374
375        let err = map_order_error(make_sdk_error("price precision too high"), &instrument);
376        assert!(matches!(
377            err,
378            UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
379        ));
380    }
381
382    /// The `/exchange` post wraps the text of what failed in `JsonParse`, as the SDK writes it.
383    fn after_the_send(error: Error) -> Error {
384        Error::JsonParse(error.to_string())
385    }
386
387    fn client_error(status_code: u16, error_message: &str) -> Error {
388        after_the_send(Error::ClientRequest {
389            status_code,
390            error_code: None,
391            error_message: error_message.to_owned(),
392            error_data: None,
393        })
394    }
395
396    /// An HTTP 4xx was refused before Hyperliquid acted on it, so it ends the order, classified
397    /// as any refusal is. Read from the SDK's own text for one, so a change to it fails here.
398    #[test]
399    fn a_4xx_is_a_refusal_and_a_429_a_rate_limit() {
400        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
401        for status in [400, 422, 499] {
402            assert!(
403                matches!(
404                    map_order_error(client_error(status, "Failed to deserialize"), &instrument),
405                    UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))
406                        if reason.starts_with(&format!("{CLIENT_ERROR_PREFIX}{status},"))
407                ),
408                "{status}"
409            );
410        }
411        assert!(matches!(
412            map_order_error(client_error(400, "Insufficient margin"), &instrument),
413            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
414        ));
415        assert_eq!(
416            map_order_error(client_error(429, "too many"), &instrument),
417            UnindexedOrderError::Rejected(ApiError::rate_limited(None))
418        );
419    }
420
421    /// Anything after the send other than a 4xx may have reached the venue: a 5xx, a failure to
422    /// send or to read, an answer that does not parse, and text that only looks like a 4xx.
423    #[test]
424    fn any_other_failure_after_the_send_has_an_unknown_outcome() {
425        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
426        for text in [
427            Error::ServerRequest {
428                status_code: 502,
429                error_message: "Bad Gateway".to_owned(),
430            }
431            .to_string(),
432            Error::GenericRequest(
433                "error sending request for url (https://api.hyperliquid.xyz/exchange)".to_owned(),
434            )
435            .to_string(),
436            "expected value at line 1 column 1".to_owned(),
437            format!("{CLIENT_ERROR_PREFIX}500, error code: None"),
438            format!("{CLIENT_ERROR_PREFIX}abc, error code: None"),
439            format!("{CLIENT_ERROR_PREFIX}399, error code: None"),
440            format!("{CLIENT_ERROR_PREFIX}422 without a comma"),
441        ] {
442            let mapped = map_order_error(Error::JsonParse(text.clone()), &instrument);
443            assert_eq!(
444                mapped,
445                UnindexedOrderError::Connectivity(ConnectivityError::Socket(text.clone())),
446                "{text}"
447            );
448            assert!(mapped.is_outcome_unknown(), "{text}");
449        }
450    }
451
452    /// Every variant other than `JsonParse` comes from before the send, so it ends the order.
453    #[test]
454    fn a_failure_before_the_send_is_a_refusal() {
455        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
456        assert!(matches!(
457            map_order_error(Error::AssetNotFound, &instrument),
458            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(name, _)) if name == instrument
459        ));
460        for error in [
461            Error::Eip712("bad domain".to_owned()),
462            Error::SignatureFailure("bad key".to_owned()),
463            Error::FloatStringParse,
464            Error::NoCloid,
465        ] {
466            assert!(
467                matches!(
468                    map_order_error(error.clone(), &instrument),
469                    UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
470                ),
471                "{error}"
472            );
473        }
474    }
475
476    #[test]
477    fn an_order_answered_without_its_status_has_an_unknown_outcome() {
478        assert!(order_outcome_unanswered().is_outcome_unknown());
479    }
480
481    #[test]
482    fn a_connect_error_is_transient_only_when_the_request_was() {
483        let socket = ConnectivityError::Socket("reset".to_owned());
484        assert_eq!(
485            HyperliquidConnectError::from(UnindexedClientError::Connectivity(socket.clone())),
486            HyperliquidConnectError::Connectivity(socket)
487        );
488        assert!(matches!(
489            HyperliquidConnectError::from(UnindexedClientError::Api(
490                UnindexedApiError::rate_limited(None)
491            )),
492            HyperliquidConnectError::Connectivity(ConnectivityError::Socket(_))
493        ));
494        assert!(matches!(
495            HyperliquidConnectError::from(UnindexedClientError::Internal("bad json".to_owned())),
496            HyperliquidConnectError::Metadata(reason) if reason.contains("bad json")
497        ));
498    }
499}