1use std::{
17 collections::hash_map::DefaultHasher,
18 hash::{Hash, Hasher},
19 sync::{Arc, LazyLock, Weak},
20 time::{Duration, SystemTime, UNIX_EPOCH},
21};
22
23use ahash::AHashMap;
24use parking_lot::Mutex;
25use serde_json::Value;
26
27use crate::{
28 common::{
29 consts::HYPERLIQUID_REST_WEIGHT_PER_MINUTE,
30 enums::{HyperliquidEnvironment, HyperliquidInfoRequestType},
31 rate_limits::HyperliquidRouteScope,
32 },
33 http::{
34 models::HyperliquidExchangeAction,
35 query::{ExchangeAction, ExchangeActionParams, InfoRequest},
36 },
37};
38
39type WeightedLimiterRegistry = Mutex<AHashMap<HyperliquidRouteScope, Weak<WeightedLimiter>>>;
40
41static REST_LIMITERS: LazyLock<WeightedLimiterRegistry> =
42 LazyLock::new(|| Mutex::new(AHashMap::new()));
43
44#[derive(Debug)]
45pub struct WeightedLimiter {
46 capacity: f64, refill_per_sec: f64, state: tokio::sync::Mutex<State>,
49}
50
51#[derive(Debug)]
52struct State {
53 tokens: f64,
54 last_refill: tokio::time::Instant,
55}
56
57impl WeightedLimiter {
58 pub fn per_minute(capacity: u32) -> Self {
59 let cap = capacity as f64;
60 Self {
61 capacity: cap,
62 refill_per_sec: cap / 60.0,
63 state: tokio::sync::Mutex::new(State {
64 tokens: cap,
65 last_refill: tokio::time::Instant::now(),
66 }),
67 }
68 }
69
70 pub async fn acquire(&self, weight: u32) {
72 let need = weight as f64;
73
74 loop {
75 let mut st = self.state.lock().await;
76 Self::refill_locked(&mut st, self.refill_per_sec, self.capacity);
77
78 if st.tokens >= need {
79 st.tokens -= need;
80 return;
81 }
82 let deficit = need - st.tokens;
83 let secs = deficit / self.refill_per_sec;
84 drop(st);
85 tokio::time::sleep(Duration::from_secs_f64(secs.max(0.01))).await;
86 }
87 }
88
89 pub async fn debit_extra(&self, extra: u32) {
91 if extra == 0 {
92 return;
93 }
94 let mut st = self.state.lock().await;
95 Self::refill_locked(&mut st, self.refill_per_sec, self.capacity);
96 st.tokens -= extra as f64;
97 }
98
99 pub async fn snapshot(&self) -> RateLimitSnapshot {
100 let mut st = self.state.lock().await;
101 Self::refill_locked(&mut st, self.refill_per_sec, self.capacity);
102 RateLimitSnapshot {
103 capacity: self.capacity as u32,
104 tokens: st.tokens.max(0.0) as u32,
105 }
106 }
107
108 fn refill_locked(st: &mut State, per_sec: f64, cap: f64) {
109 let now = tokio::time::Instant::now();
110 let dt = now.duration_since(st.last_refill).as_secs_f64();
111 if dt > 0.0 {
112 st.tokens = (st.tokens + dt * per_sec).min(cap);
113 st.last_refill = now;
114 }
115 }
116}
117
118pub(crate) fn shared_rest_limiter(
119 environment: HyperliquidEnvironment,
120 endpoint_url: &str,
121 proxy_url: Option<&str>,
122) -> Arc<WeightedLimiter> {
123 let scope = HyperliquidRouteScope::new(environment, endpoint_url, proxy_url);
124 let mut registry = REST_LIMITERS.lock();
125
126 if let Some(limiter) = registry.get(&scope).and_then(Weak::upgrade) {
127 return limiter;
128 }
129
130 let limiter = Arc::new(WeightedLimiter::per_minute(
131 HYPERLIQUID_REST_WEIGHT_PER_MINUTE,
132 ));
133 registry.insert(scope, Arc::downgrade(&limiter));
134 limiter
135}
136
137#[derive(Debug, Clone, Copy)]
138pub struct RateLimitSnapshot {
139 pub capacity: u32,
140 pub tokens: u32,
141}
142
143pub fn backoff_full_jitter(attempt: u32, base: Duration, cap: Duration) -> Duration {
144 let mut hasher = DefaultHasher::new();
145 attempt.hash(&mut hasher);
146 let nanos = SystemTime::now()
147 .duration_since(UNIX_EPOCH)
148 .unwrap_or_default()
149 .as_nanos();
150 nanos.hash(&mut hasher);
151 let hash = hasher.finish();
152
153 let max = (base.as_millis() as u64)
154 .saturating_mul(1u64 << attempt.min(16))
155 .min(cap.as_millis() as u64)
156 .max(base.as_millis() as u64);
157
158 Duration::from_millis((hash % max).max(1))
160}
161
162pub fn info_base_weight(req: &InfoRequest) -> u32 {
164 match req.request_type {
165 HyperliquidInfoRequestType::L2Book
166 | HyperliquidInfoRequestType::AllMids
167 | HyperliquidInfoRequestType::ClearinghouseState
168 | HyperliquidInfoRequestType::OrderStatus
169 | HyperliquidInfoRequestType::SpotClearinghouseState
170 | HyperliquidInfoRequestType::ExchangeStatus => 2,
171 HyperliquidInfoRequestType::UserRole => 60,
172 _ => 20,
173 }
174}
175
176pub fn info_extra_weight(req: &InfoRequest, json: &Value) -> u32 {
179 let items = match json {
180 Value::Array(a) => a.len(),
181 Value::Object(m) => m
182 .values()
183 .filter_map(|v| v.as_array().map(|a| a.len()))
184 .max()
185 .unwrap_or(0),
186 _ => 0,
187 };
188
189 let unit = match req.request_type {
190 HyperliquidInfoRequestType::CandleSnapshot => 60usize,
191 HyperliquidInfoRequestType::RecentTrades
192 | HyperliquidInfoRequestType::HistoricalOrders
193 | HyperliquidInfoRequestType::UserFills
194 | HyperliquidInfoRequestType::UserFillsByTime
195 | HyperliquidInfoRequestType::FundingHistory
196 | HyperliquidInfoRequestType::UserFunding
197 | HyperliquidInfoRequestType::NonUserFundingUpdates
198 | HyperliquidInfoRequestType::TwapHistory
199 | HyperliquidInfoRequestType::UserTwapSliceFills
200 | HyperliquidInfoRequestType::UserTwapSliceFillsByTime
201 | HyperliquidInfoRequestType::DelegatorHistory
202 | HyperliquidInfoRequestType::DelegatorRewards
203 | HyperliquidInfoRequestType::ValidatorStats => 20usize,
204 _ => return 0,
205 };
206 (items / unit) as u32
207}
208
209pub(crate) const fn exchange_weight_for_batch(batch_size: usize) -> u32 {
210 1 + (batch_size as u32 / 40)
211}
212
213pub fn exchange_weight(action: &ExchangeAction) -> u32 {
215 let batch_size = match &action.params {
217 ExchangeActionParams::Order(params) => params.orders.len(),
218 ExchangeActionParams::Cancel(params) => params.cancels.len(),
219 ExchangeActionParams::Modify(_) => {
220 1
222 }
223 ExchangeActionParams::UpdateLeverage(_) | ExchangeActionParams::UpdateIsolatedMargin(_) => {
224 0
225 }
226 };
227 exchange_weight_for_batch(batch_size)
228}
229
230pub fn exec_action_weight(action: &HyperliquidExchangeAction) -> u32 {
232 let batch_size = match action {
233 HyperliquidExchangeAction::Order { orders, .. } => orders.len(),
234 HyperliquidExchangeAction::Cancel { cancels, .. } => cancels.len(),
235 HyperliquidExchangeAction::CancelByCloid { cancels, .. } => cancels.len(),
236 HyperliquidExchangeAction::Modify { .. } => 1,
237 HyperliquidExchangeAction::BatchModify { modifies } => modifies.len(),
238 HyperliquidExchangeAction::UpdateLeverage { .. }
239 | HyperliquidExchangeAction::UpdateIsolatedMargin { .. }
240 | HyperliquidExchangeAction::ScheduleCancel { .. }
241 | HyperliquidExchangeAction::UsdClassTransfer { .. }
242 | HyperliquidExchangeAction::UserOutcome { .. }
243 | HyperliquidExchangeAction::TwapPlace { .. }
244 | HyperliquidExchangeAction::TwapCancel { .. }
245 | HyperliquidExchangeAction::Noop => 0,
246 };
247 exchange_weight_for_batch(batch_size)
248}
249
250#[cfg(test)]
251mod tests {
252 use rstest::rstest;
253 use rust_decimal::Decimal;
254 use strum::IntoEnumIterator;
255
256 use super::{
257 super::models::{
258 Cloid, HyperliquidExchangeAction, HyperliquidExchangeCancelByCloidRequest,
259 HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
260 HyperliquidExchangeLimitParams, HyperliquidExchangeModifyOrderRequest,
261 HyperliquidExchangeOrderKind, HyperliquidExchangePlaceOrderRequest,
262 HyperliquidExchangeTif,
263 },
264 *,
265 };
266 use crate::{
267 common::enums::HyperliquidEnvironment,
268 http::query::{
269 CancelParams, ExchangeAction, ExchangeActionParams, ExchangeActionType, InfoRequest,
270 InfoRequestParams, OrderParams, UpdateLeverageParams,
271 },
272 };
273
274 fn info_request(request_type: HyperliquidInfoRequestType) -> InfoRequest {
275 InfoRequest {
276 request_type,
277 params: InfoRequestParams::None,
278 }
279 }
280
281 #[rstest]
282 fn test_info_base_weights_match_official_table() {
283 let weight_two = [
284 HyperliquidInfoRequestType::L2Book,
285 HyperliquidInfoRequestType::AllMids,
286 HyperliquidInfoRequestType::ClearinghouseState,
287 HyperliquidInfoRequestType::OrderStatus,
288 HyperliquidInfoRequestType::SpotClearinghouseState,
289 HyperliquidInfoRequestType::ExchangeStatus,
290 ];
291
292 for request_type in HyperliquidInfoRequestType::iter() {
293 let expected = if weight_two.contains(&request_type) {
294 2
295 } else if request_type == HyperliquidInfoRequestType::UserRole {
296 60
297 } else {
298 20
299 };
300
301 assert_eq!(
302 info_base_weight(&info_request(request_type)),
303 expected,
304 "unexpected base weight for {request_type:?}",
305 );
306 }
307 }
308
309 #[rstest]
310 #[case(HyperliquidInfoRequestType::RecentTrades, 19, 0)]
311 #[case(HyperliquidInfoRequestType::RecentTrades, 20, 1)]
312 #[case(HyperliquidInfoRequestType::RecentTrades, 39, 1)]
313 #[case(HyperliquidInfoRequestType::RecentTrades, 40, 2)]
314 #[case(HyperliquidInfoRequestType::HistoricalOrders, 20, 1)]
315 #[case(HyperliquidInfoRequestType::UserFills, 20, 1)]
316 #[case(HyperliquidInfoRequestType::UserFillsByTime, 20, 1)]
317 #[case(HyperliquidInfoRequestType::FundingHistory, 20, 1)]
318 #[case(HyperliquidInfoRequestType::UserFunding, 20, 1)]
319 #[case(HyperliquidInfoRequestType::NonUserFundingUpdates, 20, 1)]
320 #[case(HyperliquidInfoRequestType::TwapHistory, 20, 1)]
321 #[case(HyperliquidInfoRequestType::UserTwapSliceFills, 20, 1)]
322 #[case(HyperliquidInfoRequestType::UserTwapSliceFillsByTime, 20, 1)]
323 #[case(HyperliquidInfoRequestType::DelegatorHistory, 20, 1)]
324 #[case(HyperliquidInfoRequestType::DelegatorRewards, 20, 1)]
325 #[case(HyperliquidInfoRequestType::ValidatorStats, 20, 1)]
326 #[case(HyperliquidInfoRequestType::CandleSnapshot, 59, 0)]
327 #[case(HyperliquidInfoRequestType::CandleSnapshot, 60, 1)]
328 #[case(HyperliquidInfoRequestType::CandleSnapshot, 119, 1)]
329 #[case(HyperliquidInfoRequestType::CandleSnapshot, 120, 2)]
330 fn test_info_extra_weights_match_official_table(
331 #[case] request_type: HyperliquidInfoRequestType,
332 #[case] item_count: usize,
333 #[case] expected: u32,
334 ) {
335 let response = Value::Array(vec![Value::Null; item_count]);
336
337 assert_eq!(
338 info_extra_weight(&info_request(request_type), &response),
339 expected,
340 );
341 }
342
343 #[rstest]
344 fn test_info_extra_weight_uses_largest_wrapped_array() {
345 let response = serde_json::json!({
346 "metadata": [1],
347 "fills": vec![Value::Null; 40],
348 });
349
350 assert_eq!(
351 info_extra_weight(
352 &info_request(HyperliquidInfoRequestType::UserFills),
353 &response,
354 ),
355 2,
356 );
357 }
358
359 #[rstest]
360 fn test_rest_limiter_shares_route_scope() {
361 let info = shared_rest_limiter(
362 HyperliquidEnvironment::Testnet,
363 "https://rate-limit-share.example/info",
364 None,
365 );
366 let exchange = shared_rest_limiter(
367 HyperliquidEnvironment::Testnet,
368 "https://rate-limit-share.example/exchange",
369 None,
370 );
371 let proxied = shared_rest_limiter(
372 HyperliquidEnvironment::Testnet,
373 "https://rate-limit-share.example/info",
374 Some("http://proxy.example:8080"),
375 );
376
377 assert!(Arc::ptr_eq(&info, &exchange));
378 assert!(!Arc::ptr_eq(&info, &proxied));
379 }
380
381 fn exec_order() -> HyperliquidExchangePlaceOrderRequest {
382 HyperliquidExchangePlaceOrderRequest {
383 asset: 0,
384 is_buy: true,
385 price: Decimal::new(50000, 0),
386 size: Decimal::new(1, 0),
387 reduce_only: false,
388 kind: HyperliquidExchangeOrderKind::Limit {
389 limit: HyperliquidExchangeLimitParams {
390 tif: HyperliquidExchangeTif::Gtc,
391 },
392 },
393 cloid: Some(Cloid::from_hex("0x00000000000000000000000000000000").unwrap()),
394 }
395 }
396
397 fn exec_modify() -> HyperliquidExchangeModifyOrderRequest {
398 HyperliquidExchangeModifyOrderRequest {
399 oid: 12345.into(),
400 order: exec_order(),
401 }
402 }
403
404 fn exec_cancel_by_cloid() -> HyperliquidExchangeCancelByCloidRequest {
405 HyperliquidExchangeCancelByCloidRequest {
406 asset: 0,
407 cloid: Cloid::from_hex("0x00000000000000000000000000000000").unwrap(),
408 }
409 }
410
411 #[rstest]
412 #[case(1, 1)]
413 #[case(39, 1)]
414 #[case(40, 2)]
415 #[case(79, 2)]
416 #[case(80, 3)]
417 fn test_exchange_weight_order_steps_every_40(
418 #[case] array_len: usize,
419 #[case] expected_weight: u32,
420 ) {
421 let orders: Vec<HyperliquidExchangePlaceOrderRequest> =
422 (0..array_len).map(|_| exec_order()).collect();
423
424 let action = ExchangeAction {
425 action_type: ExchangeActionType::Order,
426 params: ExchangeActionParams::Order(OrderParams {
427 orders,
428 grouping: HyperliquidExchangeGrouping::Na,
429 builder: None,
430 }),
431 };
432 assert_eq!(exchange_weight(&action), expected_weight);
433 }
434
435 #[rstest]
436 #[case(1, 1)]
437 #[case(39, 1)]
438 #[case(40, 2)]
439 #[case(79, 2)]
440 #[case(80, 3)]
441 fn test_exec_action_weight_order_steps_every_40(
442 #[case] array_len: usize,
443 #[case] expected_weight: u32,
444 ) {
445 let action = HyperliquidExchangeAction::Order {
446 orders: (0..array_len).map(|_| exec_order()).collect(),
447 grouping: HyperliquidExchangeGrouping::Na,
448 builder: None,
449 };
450
451 assert_eq!(exec_action_weight(&action), expected_weight);
452 }
453
454 #[rstest]
455 #[case(1, 1)]
456 #[case(39, 1)]
457 #[case(40, 2)]
458 #[case(79, 2)]
459 #[case(80, 3)]
460 fn test_exec_action_weight_cancel_by_oid_steps_every_40(
461 #[case] array_len: usize,
462 #[case] expected_weight: u32,
463 ) {
464 let action = HyperliquidExchangeAction::Cancel {
465 cancels: (0..array_len)
466 .map(|i| HyperliquidExchangeCancelOrderRequest {
467 asset: 0,
468 oid: i as u64,
469 })
470 .collect(),
471 fast: None,
472 };
473
474 assert_eq!(exec_action_weight(&action), expected_weight);
475 }
476
477 #[rstest]
478 #[case(1, 1)]
479 #[case(39, 1)]
480 #[case(40, 2)]
481 #[case(79, 2)]
482 #[case(80, 3)]
483 fn test_exec_action_weight_cancel_by_cloid_steps_every_40(
484 #[case] array_len: usize,
485 #[case] expected_weight: u32,
486 ) {
487 let action = HyperliquidExchangeAction::CancelByCloid {
488 cancels: (0..array_len).map(|_| exec_cancel_by_cloid()).collect(),
489 fast: None,
490 };
491
492 assert_eq!(exec_action_weight(&action), expected_weight);
493 }
494
495 #[rstest]
496 #[case(1, 1)]
497 #[case(39, 1)]
498 #[case(40, 2)]
499 #[case(79, 2)]
500 #[case(80, 3)]
501 fn test_exec_action_weight_batch_modify_steps_every_40(
502 #[case] array_len: usize,
503 #[case] expected_weight: u32,
504 ) {
505 let action = HyperliquidExchangeAction::BatchModify {
506 modifies: (0..array_len).map(|_| exec_modify()).collect(),
507 };
508
509 assert_eq!(exec_action_weight(&action), expected_weight);
510 }
511
512 #[rstest]
513 fn test_exec_action_weight_modify() {
514 let action = HyperliquidExchangeAction::Modify {
515 modify: exec_modify(),
516 };
517
518 assert_eq!(exec_action_weight(&action), 1);
519 }
520
521 #[rstest]
522 fn test_exec_action_weight_non_batch_action() {
523 let action = HyperliquidExchangeAction::UpdateLeverage {
524 asset: 1,
525 is_cross: true,
526 leverage: 10,
527 };
528
529 assert_eq!(exec_action_weight(&action), 1);
530 }
531
532 #[rstest]
533 fn test_exchange_weight_cancel() {
534 let cancels: Vec<HyperliquidExchangeCancelByCloidRequest> =
535 (0..40).map(|_| exec_cancel_by_cloid()).collect();
536
537 let action = ExchangeAction {
538 action_type: ExchangeActionType::Cancel,
539 params: ExchangeActionParams::Cancel(CancelParams {
540 cancels,
541 fast: None,
542 }),
543 };
544 assert_eq!(exchange_weight(&action), 2);
545 }
546
547 #[rstest]
548 fn test_exchange_weight_non_batch_action() {
549 let update_leverage = ExchangeAction {
550 action_type: ExchangeActionType::UpdateLeverage,
551 params: ExchangeActionParams::UpdateLeverage(UpdateLeverageParams {
552 asset: 1,
553 is_cross: true,
554 leverage: 10,
555 }),
556 };
557 assert_eq!(exchange_weight(&update_leverage), 1);
558 }
559
560 #[tokio::test(start_paused = true)]
561 async fn test_limiter_roughly_caps_to_capacity() {
562 let limiter = WeightedLimiter::per_minute(1200);
563
564 for _ in 0..60 {
566 limiter.acquire(20).await; }
568
569 let t0 = tokio::time::Instant::now();
571 limiter.acquire(20).await;
572 let elapsed = t0.elapsed();
573
574 assert_eq!(elapsed, Duration::from_secs(1));
575 }
576
577 #[tokio::test]
578 async fn test_limiter_debit_extra_works() {
579 let limiter = WeightedLimiter::per_minute(100);
580
581 let snapshot = limiter.snapshot().await;
583 assert_eq!(snapshot.capacity, 100);
584 assert_eq!(snapshot.tokens, 100);
585
586 limiter.acquire(30).await;
588 let snapshot = limiter.snapshot().await;
589 assert_eq!(snapshot.tokens, 70);
590
591 limiter.debit_extra(20).await;
593 let snapshot = limiter.snapshot().await;
594 assert_eq!(snapshot.tokens, 50);
595
596 limiter.debit_extra(100).await;
598 let snapshot = limiter.snapshot().await;
599 assert_eq!(snapshot.tokens, 0);
600 }
601
602 #[tokio::test(start_paused = true)]
603 async fn test_limiter_retains_response_weight_debt() {
604 let limiter = WeightedLimiter::per_minute(60);
605 limiter.acquire(50).await;
606 limiter.debit_extra(20).await;
607 let started = tokio::time::Instant::now();
608
609 limiter.acquire(1).await;
610
611 assert_eq!(
612 tokio::time::Instant::now() - started,
613 Duration::from_secs(11)
614 );
615 }
616
617 #[rstest]
618 #[case(0, 100)]
619 #[case(1, 200)]
620 #[case(2, 400)]
621 fn test_backoff_full_jitter_increases(#[case] attempt: u32, #[case] max_expected_ms: u64) {
622 let base = Duration::from_millis(100);
623 let cap = Duration::from_secs(5);
624
625 let delay = backoff_full_jitter(attempt, base, cap);
626
627 assert!(delay.as_millis() >= 1);
628 assert!(delay.as_millis() <= max_expected_ms as u128);
629 }
630
631 #[rstest]
632 fn test_backoff_full_jitter_respects_cap() {
633 let base = Duration::from_millis(100);
634 let cap = Duration::from_secs(5);
635
636 let delay_high = backoff_full_jitter(10, base, cap);
637 assert!(delay_high.as_millis() <= cap.as_millis());
638 }
639}