rustrade_execution/client/hyperliquid/
error.rs1use crate::error::{
4 ApiError, ConnectivityError, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
5};
6use rustrade_instrument::instrument::name::InstrumentNameExchange;
7use smol_str::SmolStr;
8
9#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
11#[non_exhaustive]
12pub enum HyperliquidConnectError {
13 #[error("{0}")]
16 Connectivity(#[from] ConnectivityError),
17
18 #[error("Hyperliquid lists no perpetual DEX named {0:?} on this network")]
21 UnknownDex(SmolStr),
22
23 #[error("Hyperliquid perpetual DEX metadata: {0}")]
27 Metadata(String),
28}
29
30impl From<UnindexedClientError> for HyperliquidConnectError {
31 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
44pub fn map_sdk_error(error: hyperliquid_rust_sdk::Error) -> UnindexedClientError {
52 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 if msg_lower.contains("rate limit") || msg_lower.contains("too many requests") {
66 return UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
67 }
68
69 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 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 UnindexedClientError::Internal(msg)
101}
102
103pub 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
143const CLIENT_ERROR_PREFIX: &str = "Client error: status code: ";
149
150fn 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
160fn refusal(msg: String, instrument: &InstrumentNameExchange) -> UnindexedOrderError {
162 let msg_lower = msg.to_lowercase();
163
164 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 UnindexedOrderError::Rejected(order_rejection(msg))
175}
176
177pub(super) fn order_outcome_unanswered() -> UnindexedOrderError {
181 UnindexedOrderError::Connectivity(ConnectivityError::Socket(
182 "Hyperliquid answered the order without its status".to_owned(),
183 ))
184}
185
186pub(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 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 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 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 #[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 #[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 #[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}