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(
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 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 UnindexedOrderError::Rejected(order_rejection(msg))
127}
128
129pub(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 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 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}