use crate::error::{
ApiError, ConnectivityError, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
};
use rustrade_instrument::instrument::name::InstrumentNameExchange;
use smol_str::SmolStr;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum HyperliquidConnectError {
#[error("{0}")]
Connectivity(#[from] ConnectivityError),
#[error("Hyperliquid lists no perpetual DEX named {0:?} on this network")]
UnknownDex(SmolStr),
#[error("Hyperliquid perpetual DEX metadata: {0}")]
Metadata(String),
}
impl From<UnindexedClientError> for HyperliquidConnectError {
fn from(error: UnindexedClientError) -> Self {
match error {
UnindexedClientError::Connectivity(error) => Self::Connectivity(error),
UnindexedClientError::Api(UnindexedApiError::RateLimit { .. }) => {
Self::Connectivity(ConnectivityError::Socket(error.to_string()))
}
other => Self::Metadata(other.to_string()),
}
}
}
pub fn map_sdk_error(error: hyperliquid_rust_sdk::Error) -> UnindexedClientError {
if let hyperliquid_rust_sdk::Error::ClientRequest {
status_code: 429, ..
} = error
{
return UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
}
let msg = error.to_string();
let msg_lower = msg.to_lowercase();
if msg_lower.contains("rate limit") || msg_lower.contains("too many requests") {
return UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
}
if msg_lower.contains("connection")
|| msg_lower.contains("timeout")
|| msg_lower.contains("network")
|| msg_lower.contains("dns")
|| msg_lower.contains("tls")
|| msg_lower.contains("ssl")
|| msg_lower.contains(" 500")
|| msg_lower.contains(" 502")
|| msg_lower.contains(" 503")
|| msg_lower.contains(" 504")
|| msg_lower.contains("bad gateway")
|| msg_lower.contains("service unavailable")
|| msg_lower.contains("gateway timeout")
{
return UnindexedClientError::Connectivity(ConnectivityError::Socket(msg));
}
if msg_lower.contains("signature")
|| msg_lower.contains("unauthorized")
|| msg_lower.contains("invalid key")
|| msg_lower.contains("authentication")
|| msg_lower.contains("eip712")
{
return UnindexedClientError::Api(UnindexedApiError::Unauthenticated(msg));
}
UnindexedClientError::Internal(msg)
}
pub fn map_order_error(
error: hyperliquid_rust_sdk::Error,
instrument: &InstrumentNameExchange,
) -> UnindexedOrderError {
let hyperliquid_rust_sdk::Error::JsonParse(text) = error else {
return refusal(error.to_string(), instrument);
};
match client_error_status(&text) {
Some(429) => UnindexedOrderError::Rejected(ApiError::rate_limited(None)),
Some(_) => refusal(text, instrument),
None => UnindexedOrderError::Connectivity(ConnectivityError::Socket(text)),
}
}
const CLIENT_ERROR_PREFIX: &str = "Client error: status code: ";
fn client_error_status(text: &str) -> Option<u16> {
let (status, _) = text.strip_prefix(CLIENT_ERROR_PREFIX)?.split_once(',')?;
status
.parse()
.ok()
.filter(|status| (400..500).contains(status))
}
fn refusal(msg: String, instrument: &InstrumentNameExchange) -> UnindexedOrderError {
let msg_lower = msg.to_lowercase();
if msg_lower.contains("unknown")
|| msg_lower.contains("not found")
|| msg_lower.contains("invalid symbol")
{
return UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(instrument.clone(), msg));
}
UnindexedOrderError::Rejected(order_rejection(msg))
}
pub(super) fn order_outcome_unanswered() -> UnindexedOrderError {
UnindexedOrderError::Connectivity(ConnectivityError::Socket(
"Hyperliquid answered the order without its status".to_owned(),
))
}
pub(super) fn order_rejection(reason: String) -> UnindexedApiError {
let lower = reason.to_ascii_lowercase();
let short_of_funds = matches!(
reason.as_str(),
"perpMarginRejected" | "insufficientSpotBalanceRejected"
) || lower.contains("insufficient margin")
|| lower.contains("insufficient spot balance");
if short_of_funds {
ApiError::BalanceInsufficient(None, reason)
} else {
ApiError::OrderRejected(reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
use hyperliquid_rust_sdk::Error;
fn make_sdk_error(msg: &str) -> Error {
Error::GenericReader(msg.to_string())
}
#[test]
fn test_map_sdk_error_connectivity() {
let err = map_sdk_error(make_sdk_error("connection refused"));
assert!(matches!(err, UnindexedClientError::Connectivity(_)));
let err = map_sdk_error(make_sdk_error("request timeout occurred"));
assert!(matches!(err, UnindexedClientError::Connectivity(_)));
let err = map_sdk_error(make_sdk_error("DNS lookup failed"));
assert!(matches!(err, UnindexedClientError::Connectivity(_)));
let err = map_sdk_error(make_sdk_error("TLS handshake error"));
assert!(matches!(err, UnindexedClientError::Connectivity(_)));
}
#[test]
fn test_map_sdk_error_5xx_transient() {
let err = map_sdk_error(make_sdk_error("HTTP 500 Internal Server Error"));
assert!(matches!(err, UnindexedClientError::Connectivity(_)));
let err = map_sdk_error(make_sdk_error("502 Bad Gateway"));
assert!(matches!(err, UnindexedClientError::Connectivity(_)));
let err = map_sdk_error(make_sdk_error("503 Service Unavailable"));
assert!(matches!(err, UnindexedClientError::Connectivity(_)));
let err = map_sdk_error(make_sdk_error("504 Gateway Timeout"));
assert!(matches!(err, UnindexedClientError::Connectivity(_)));
}
#[test]
fn test_map_sdk_error_rate_limit() {
let rate_limited = UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
let err = map_sdk_error(make_sdk_error("rate limit exceeded"));
assert_eq!(err, rate_limited);
let err = map_sdk_error(make_sdk_error("too many requests"));
assert_eq!(err, rate_limited);
let err = map_sdk_error(Error::ClientRequest {
status_code: 429,
error_code: None,
error_message: "slow down".to_string(),
error_data: None,
});
assert_eq!(err, rate_limited);
}
#[test]
fn a_message_merely_holding_429_is_not_a_rate_limit() {
let err = map_sdk_error(Error::ClientRequest {
status_code: 400,
error_code: None,
error_message: "Order 74291 has invalid price 4290".to_string(),
error_data: None,
});
assert!(
!matches!(
err,
UnindexedClientError::Api(UnindexedApiError::RateLimit { .. })
),
"{err:?}"
);
}
#[test]
fn test_map_sdk_error_auth() {
let err = map_sdk_error(make_sdk_error("invalid signature"));
assert!(matches!(
err,
UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
));
let err = map_sdk_error(make_sdk_error("unauthorized access"));
assert!(matches!(
err,
UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
));
let err = map_sdk_error(make_sdk_error("Error from Eip712 struct: invalid key"));
assert!(matches!(
err,
UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
));
}
#[test]
fn test_map_sdk_error_internal() {
let err = map_sdk_error(make_sdk_error("some unknown error"));
assert!(matches!(err, UnindexedClientError::Internal(_)));
}
#[test]
fn test_map_order_error_instrument_invalid() {
let instrument = InstrumentNameExchange::from("INVALID-USDC-PERP");
let err = map_order_error(make_sdk_error("unknown asset"), &instrument);
assert!(matches!(
err,
UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
));
let err = map_order_error(make_sdk_error("Asset not found"), &instrument);
assert!(matches!(
err,
UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
));
}
#[test]
fn a_shortfall_of_funds_is_balance_insufficient_and_anything_else_a_rejection() {
for reason in [
"perpMarginRejected",
"insufficientSpotBalanceRejected",
"Insufficient margin to place order. asset=0",
"Order has insufficient spot balance to trade",
"INSUFFICIENT MARGIN TO PLACE ORDER.",
] {
assert_eq!(
order_rejection(reason.to_owned()),
ApiError::BalanceInsufficient(None, reason.to_owned()),
"{reason}"
);
}
for reason in [
"minTradeNtlRejected",
"reduceOnlyRejected",
"positionIncreaseAtOpenInterestCapRejected",
"oracleRejected",
"perpMaxPositionRejected",
"marketOrderNoLiquidityRejected",
"badAloPxRejected",
"Order must have minimum value of $10.",
] {
assert_eq!(
order_rejection(reason.to_owned()),
ApiError::OrderRejected(reason.to_owned()),
"{reason}"
);
}
}
#[test]
fn test_map_order_error_rejected() {
let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
let err = map_order_error(make_sdk_error("insufficient margin"), &instrument);
assert!(matches!(
err,
UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
));
let err = map_order_error(make_sdk_error("price precision too high"), &instrument);
assert!(matches!(
err,
UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
));
}
fn after_the_send(error: Error) -> Error {
Error::JsonParse(error.to_string())
}
fn client_error(status_code: u16, error_message: &str) -> Error {
after_the_send(Error::ClientRequest {
status_code,
error_code: None,
error_message: error_message.to_owned(),
error_data: None,
})
}
#[test]
fn a_4xx_is_a_refusal_and_a_429_a_rate_limit() {
let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
for status in [400, 422, 499] {
assert!(
matches!(
map_order_error(client_error(status, "Failed to deserialize"), &instrument),
UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))
if reason.starts_with(&format!("{CLIENT_ERROR_PREFIX}{status},"))
),
"{status}"
);
}
assert!(matches!(
map_order_error(client_error(400, "Insufficient margin"), &instrument),
UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
));
assert_eq!(
map_order_error(client_error(429, "too many"), &instrument),
UnindexedOrderError::Rejected(ApiError::rate_limited(None))
);
}
#[test]
fn any_other_failure_after_the_send_has_an_unknown_outcome() {
let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
for text in [
Error::ServerRequest {
status_code: 502,
error_message: "Bad Gateway".to_owned(),
}
.to_string(),
Error::GenericRequest(
"error sending request for url (https://api.hyperliquid.xyz/exchange)".to_owned(),
)
.to_string(),
"expected value at line 1 column 1".to_owned(),
format!("{CLIENT_ERROR_PREFIX}500, error code: None"),
format!("{CLIENT_ERROR_PREFIX}abc, error code: None"),
format!("{CLIENT_ERROR_PREFIX}399, error code: None"),
format!("{CLIENT_ERROR_PREFIX}422 without a comma"),
] {
let mapped = map_order_error(Error::JsonParse(text.clone()), &instrument);
assert_eq!(
mapped,
UnindexedOrderError::Connectivity(ConnectivityError::Socket(text.clone())),
"{text}"
);
assert!(mapped.is_outcome_unknown(), "{text}");
}
}
#[test]
fn a_failure_before_the_send_is_a_refusal() {
let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
assert!(matches!(
map_order_error(Error::AssetNotFound, &instrument),
UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(name, _)) if name == instrument
));
for error in [
Error::Eip712("bad domain".to_owned()),
Error::SignatureFailure("bad key".to_owned()),
Error::FloatStringParse,
Error::NoCloid,
] {
assert!(
matches!(
map_order_error(error.clone(), &instrument),
UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
),
"{error}"
);
}
}
#[test]
fn an_order_answered_without_its_status_has_an_unknown_outcome() {
assert!(order_outcome_unanswered().is_outcome_unknown());
}
#[test]
fn a_connect_error_is_transient_only_when_the_request_was() {
let socket = ConnectivityError::Socket("reset".to_owned());
assert_eq!(
HyperliquidConnectError::from(UnindexedClientError::Connectivity(socket.clone())),
HyperliquidConnectError::Connectivity(socket)
);
assert!(matches!(
HyperliquidConnectError::from(UnindexedClientError::Api(
UnindexedApiError::rate_limited(None)
)),
HyperliquidConnectError::Connectivity(ConnectivityError::Socket(_))
));
assert!(matches!(
HyperliquidConnectError::from(UnindexedClientError::Internal("bad json".to_owned())),
HyperliquidConnectError::Metadata(reason) if reason.contains("bad json")
));
}
}