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
157 if max == 0 {
158 return Duration::ZERO;
159 }
160
161 Duration::from_millis((hash % max).max(1))
163}
164
165pub fn info_base_weight(req: &InfoRequest) -> u32 {
167 match req.request_type {
168 HyperliquidInfoRequestType::L2Book
169 | HyperliquidInfoRequestType::AllMids
170 | HyperliquidInfoRequestType::ClearinghouseState
171 | HyperliquidInfoRequestType::OrderStatus
172 | HyperliquidInfoRequestType::SpotClearinghouseState
173 | HyperliquidInfoRequestType::ExchangeStatus => 2,
174 HyperliquidInfoRequestType::UserRole => 60,
175 _ => 20,
176 }
177}
178
179pub fn info_extra_weight(req: &InfoRequest, json: &Value) -> u32 {
182 let items = match json {
183 Value::Array(a) => a.len(),
184 Value::Object(m) => m
185 .values()
186 .filter_map(|v| v.as_array().map(|a| a.len()))
187 .max()
188 .unwrap_or(0),
189 _ => 0,
190 };
191
192 let unit = match req.request_type {
193 HyperliquidInfoRequestType::CandleSnapshot => 60usize,
194 HyperliquidInfoRequestType::RecentTrades
195 | HyperliquidInfoRequestType::HistoricalOrders
196 | HyperliquidInfoRequestType::UserFills
197 | HyperliquidInfoRequestType::UserFillsByTime
198 | HyperliquidInfoRequestType::FundingHistory
199 | HyperliquidInfoRequestType::UserFunding
200 | HyperliquidInfoRequestType::NonUserFundingUpdates
201 | HyperliquidInfoRequestType::TwapHistory
202 | HyperliquidInfoRequestType::UserTwapSliceFills
203 | HyperliquidInfoRequestType::UserTwapSliceFillsByTime
204 | HyperliquidInfoRequestType::DelegatorHistory
205 | HyperliquidInfoRequestType::DelegatorRewards
206 | HyperliquidInfoRequestType::ValidatorStats => 20usize,
207 _ => return 0,
208 };
209 (items / unit) as u32
210}
211
212pub(crate) const fn exchange_weight_for_batch(batch_size: usize) -> u32 {
213 1 + (batch_size as u32 / 40)
214}
215
216pub fn exchange_weight(action: &ExchangeAction) -> u32 {
218 let batch_size = match &action.params {
220 ExchangeActionParams::Order(params) => params.orders.len(),
221 ExchangeActionParams::Cancel(params) => params.cancels.len(),
222 ExchangeActionParams::Modify(_) => {
223 1
225 }
226 ExchangeActionParams::UpdateLeverage(_) | ExchangeActionParams::UpdateIsolatedMargin(_) => {
227 0
228 }
229 };
230 exchange_weight_for_batch(batch_size)
231}
232
233pub fn exec_action_weight(action: &HyperliquidExchangeAction) -> u32 {
235 let batch_size = match action {
236 HyperliquidExchangeAction::Order { orders, .. } => orders.len(),
237 HyperliquidExchangeAction::Cancel { cancels, .. } => cancels.len(),
238 HyperliquidExchangeAction::CancelByCloid { cancels, .. } => cancels.len(),
239 HyperliquidExchangeAction::Modify { .. } => 1,
240 HyperliquidExchangeAction::BatchModify { modifies } => modifies.len(),
241 HyperliquidExchangeAction::UpdateLeverage { .. }
242 | HyperliquidExchangeAction::UpdateIsolatedMargin { .. }
243 | HyperliquidExchangeAction::ScheduleCancel { .. }
244 | HyperliquidExchangeAction::UsdClassTransfer { .. }
245 | HyperliquidExchangeAction::UserOutcome { .. }
246 | HyperliquidExchangeAction::TwapPlace { .. }
247 | HyperliquidExchangeAction::TwapCancel { .. }
248 | HyperliquidExchangeAction::Noop => 0,
249 };
250 exchange_weight_for_batch(batch_size)
251}
252
253#[cfg(test)]
254mod tests {
255 use rstest::rstest;
256 use rust_decimal::Decimal;
257 use strum::IntoEnumIterator;
258
259 use super::{
260 super::models::{
261 Cloid, HyperliquidExchangeAction, HyperliquidExchangeCancelByCloidRequest,
262 HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
263 HyperliquidExchangeLimitParams, HyperliquidExchangeModifyOrderRequest,
264 HyperliquidExchangeOrderKind, HyperliquidExchangePlaceOrderRequest,
265 HyperliquidExchangeTif,
266 },
267 *,
268 };
269 use crate::{
270 common::enums::HyperliquidEnvironment,
271 http::query::{
272 CancelParams, ExchangeAction, ExchangeActionParams, ExchangeActionType, InfoRequest,
273 InfoRequestParams, OrderParams, UpdateLeverageParams,
274 },
275 };
276
277 fn info_request(request_type: HyperliquidInfoRequestType) -> InfoRequest {
278 InfoRequest {
279 request_type,
280 params: InfoRequestParams::None,
281 }
282 }
283
284 #[rstest]
285 fn test_info_base_weights_match_official_table() {
286 let weight_two = [
287 HyperliquidInfoRequestType::L2Book,
288 HyperliquidInfoRequestType::AllMids,
289 HyperliquidInfoRequestType::ClearinghouseState,
290 HyperliquidInfoRequestType::OrderStatus,
291 HyperliquidInfoRequestType::SpotClearinghouseState,
292 HyperliquidInfoRequestType::ExchangeStatus,
293 ];
294
295 for request_type in HyperliquidInfoRequestType::iter() {
296 let expected = if weight_two.contains(&request_type) {
297 2
298 } else if request_type == HyperliquidInfoRequestType::UserRole {
299 60
300 } else {
301 20
302 };
303
304 assert_eq!(
305 info_base_weight(&info_request(request_type)),
306 expected,
307 "unexpected base weight for {request_type:?}",
308 );
309 }
310 }
311
312 #[rstest]
313 #[case(HyperliquidInfoRequestType::RecentTrades, 19, 0)]
314 #[case(HyperliquidInfoRequestType::RecentTrades, 20, 1)]
315 #[case(HyperliquidInfoRequestType::RecentTrades, 39, 1)]
316 #[case(HyperliquidInfoRequestType::RecentTrades, 40, 2)]
317 #[case(HyperliquidInfoRequestType::HistoricalOrders, 20, 1)]
318 #[case(HyperliquidInfoRequestType::UserFills, 20, 1)]
319 #[case(HyperliquidInfoRequestType::UserFillsByTime, 20, 1)]
320 #[case(HyperliquidInfoRequestType::FundingHistory, 20, 1)]
321 #[case(HyperliquidInfoRequestType::UserFunding, 20, 1)]
322 #[case(HyperliquidInfoRequestType::NonUserFundingUpdates, 20, 1)]
323 #[case(HyperliquidInfoRequestType::TwapHistory, 20, 1)]
324 #[case(HyperliquidInfoRequestType::UserTwapSliceFills, 20, 1)]
325 #[case(HyperliquidInfoRequestType::UserTwapSliceFillsByTime, 20, 1)]
326 #[case(HyperliquidInfoRequestType::DelegatorHistory, 20, 1)]
327 #[case(HyperliquidInfoRequestType::DelegatorRewards, 20, 1)]
328 #[case(HyperliquidInfoRequestType::ValidatorStats, 20, 1)]
329 #[case(HyperliquidInfoRequestType::CandleSnapshot, 59, 0)]
330 #[case(HyperliquidInfoRequestType::CandleSnapshot, 60, 1)]
331 #[case(HyperliquidInfoRequestType::CandleSnapshot, 119, 1)]
332 #[case(HyperliquidInfoRequestType::CandleSnapshot, 120, 2)]
333 fn test_info_extra_weights_match_official_table(
334 #[case] request_type: HyperliquidInfoRequestType,
335 #[case] item_count: usize,
336 #[case] expected: u32,
337 ) {
338 let response = Value::Array(vec![Value::Null; item_count]);
339
340 assert_eq!(
341 info_extra_weight(&info_request(request_type), &response),
342 expected,
343 );
344 }
345
346 #[rstest]
347 fn test_info_extra_weight_uses_largest_wrapped_array() {
348 let response = serde_json::json!({
349 "metadata": [1],
350 "fills": vec![Value::Null; 40],
351 });
352
353 assert_eq!(
354 info_extra_weight(
355 &info_request(HyperliquidInfoRequestType::UserFills),
356 &response,
357 ),
358 2,
359 );
360 }
361
362 #[rstest]
363 fn test_rest_limiter_shares_route_scope() {
364 let info = shared_rest_limiter(
365 HyperliquidEnvironment::Testnet,
366 "https://rate-limit-share.example/info",
367 None,
368 );
369 let exchange = shared_rest_limiter(
370 HyperliquidEnvironment::Testnet,
371 "https://rate-limit-share.example/exchange",
372 None,
373 );
374 let proxied = shared_rest_limiter(
375 HyperliquidEnvironment::Testnet,
376 "https://rate-limit-share.example/info",
377 Some("http://proxy.example:8080"),
378 );
379
380 assert!(Arc::ptr_eq(&info, &exchange));
381 assert!(!Arc::ptr_eq(&info, &proxied));
382 }
383
384 fn exec_order() -> HyperliquidExchangePlaceOrderRequest {
385 HyperliquidExchangePlaceOrderRequest {
386 asset: 0,
387 is_buy: true,
388 price: Decimal::new(50000, 0),
389 size: Decimal::new(1, 0),
390 reduce_only: false,
391 kind: HyperliquidExchangeOrderKind::Limit {
392 limit: HyperliquidExchangeLimitParams {
393 tif: HyperliquidExchangeTif::Gtc,
394 },
395 },
396 cloid: Some(Cloid::from_hex("0x00000000000000000000000000000000").unwrap()),
397 }
398 }
399
400 fn exec_modify() -> HyperliquidExchangeModifyOrderRequest {
401 HyperliquidExchangeModifyOrderRequest {
402 oid: 12345.into(),
403 order: exec_order(),
404 }
405 }
406
407 fn exec_cancel_by_cloid() -> HyperliquidExchangeCancelByCloidRequest {
408 HyperliquidExchangeCancelByCloidRequest {
409 asset: 0,
410 cloid: Cloid::from_hex("0x00000000000000000000000000000000").unwrap(),
411 }
412 }
413
414 #[rstest]
415 #[case(1, 1)]
416 #[case(39, 1)]
417 #[case(40, 2)]
418 #[case(79, 2)]
419 #[case(80, 3)]
420 fn test_exchange_weight_order_steps_every_40(
421 #[case] array_len: usize,
422 #[case] expected_weight: u32,
423 ) {
424 let orders: Vec<HyperliquidExchangePlaceOrderRequest> =
425 (0..array_len).map(|_| exec_order()).collect();
426
427 let action = ExchangeAction {
428 action_type: ExchangeActionType::Order,
429 params: ExchangeActionParams::Order(OrderParams {
430 orders,
431 grouping: HyperliquidExchangeGrouping::Na,
432 builder: None,
433 }),
434 };
435 assert_eq!(exchange_weight(&action), expected_weight);
436 }
437
438 #[rstest]
439 #[case(1, 1)]
440 #[case(39, 1)]
441 #[case(40, 2)]
442 #[case(79, 2)]
443 #[case(80, 3)]
444 fn test_exec_action_weight_order_steps_every_40(
445 #[case] array_len: usize,
446 #[case] expected_weight: u32,
447 ) {
448 let action = HyperliquidExchangeAction::Order {
449 orders: (0..array_len).map(|_| exec_order()).collect(),
450 grouping: HyperliquidExchangeGrouping::Na,
451 builder: None,
452 };
453
454 assert_eq!(exec_action_weight(&action), expected_weight);
455 }
456
457 #[rstest]
458 #[case(1, 1)]
459 #[case(39, 1)]
460 #[case(40, 2)]
461 #[case(79, 2)]
462 #[case(80, 3)]
463 fn test_exec_action_weight_cancel_by_oid_steps_every_40(
464 #[case] array_len: usize,
465 #[case] expected_weight: u32,
466 ) {
467 let action = HyperliquidExchangeAction::Cancel {
468 cancels: (0..array_len)
469 .map(|i| HyperliquidExchangeCancelOrderRequest {
470 asset: 0,
471 oid: i as u64,
472 })
473 .collect(),
474 fast: None,
475 };
476
477 assert_eq!(exec_action_weight(&action), expected_weight);
478 }
479
480 #[rstest]
481 #[case(1, 1)]
482 #[case(39, 1)]
483 #[case(40, 2)]
484 #[case(79, 2)]
485 #[case(80, 3)]
486 fn test_exec_action_weight_cancel_by_cloid_steps_every_40(
487 #[case] array_len: usize,
488 #[case] expected_weight: u32,
489 ) {
490 let action = HyperliquidExchangeAction::CancelByCloid {
491 cancels: (0..array_len).map(|_| exec_cancel_by_cloid()).collect(),
492 fast: None,
493 };
494
495 assert_eq!(exec_action_weight(&action), expected_weight);
496 }
497
498 #[rstest]
499 #[case(1, 1)]
500 #[case(39, 1)]
501 #[case(40, 2)]
502 #[case(79, 2)]
503 #[case(80, 3)]
504 fn test_exec_action_weight_batch_modify_steps_every_40(
505 #[case] array_len: usize,
506 #[case] expected_weight: u32,
507 ) {
508 let action = HyperliquidExchangeAction::BatchModify {
509 modifies: (0..array_len).map(|_| exec_modify()).collect(),
510 };
511
512 assert_eq!(exec_action_weight(&action), expected_weight);
513 }
514
515 #[rstest]
516 fn test_exec_action_weight_modify() {
517 let action = HyperliquidExchangeAction::Modify {
518 modify: exec_modify(),
519 };
520
521 assert_eq!(exec_action_weight(&action), 1);
522 }
523
524 #[rstest]
525 fn test_exec_action_weight_non_batch_action() {
526 let action = HyperliquidExchangeAction::UpdateLeverage {
527 asset: 1,
528 is_cross: true,
529 leverage: 10,
530 };
531
532 assert_eq!(exec_action_weight(&action), 1);
533 }
534
535 #[rstest]
536 fn test_exchange_weight_cancel() {
537 let cancels: Vec<HyperliquidExchangeCancelByCloidRequest> =
538 (0..40).map(|_| exec_cancel_by_cloid()).collect();
539
540 let action = ExchangeAction {
541 action_type: ExchangeActionType::Cancel,
542 params: ExchangeActionParams::Cancel(CancelParams {
543 cancels,
544 fast: None,
545 }),
546 };
547 assert_eq!(exchange_weight(&action), 2);
548 }
549
550 #[rstest]
551 fn test_exchange_weight_non_batch_action() {
552 let update_leverage = ExchangeAction {
553 action_type: ExchangeActionType::UpdateLeverage,
554 params: ExchangeActionParams::UpdateLeverage(UpdateLeverageParams {
555 asset: 1,
556 is_cross: true,
557 leverage: 10,
558 }),
559 };
560 assert_eq!(exchange_weight(&update_leverage), 1);
561 }
562
563 #[tokio::test(start_paused = true)]
564 async fn test_limiter_roughly_caps_to_capacity() {
565 let limiter = WeightedLimiter::per_minute(1200);
566
567 for _ in 0..60 {
569 limiter.acquire(20).await; }
571
572 let t0 = tokio::time::Instant::now();
574 limiter.acquire(20).await;
575 let elapsed = t0.elapsed();
576
577 assert_eq!(elapsed, Duration::from_secs(1));
578 }
579
580 #[tokio::test]
581 async fn test_limiter_debit_extra_works() {
582 let limiter = WeightedLimiter::per_minute(100);
583
584 let snapshot = limiter.snapshot().await;
586 assert_eq!(snapshot.capacity, 100);
587 assert_eq!(snapshot.tokens, 100);
588
589 limiter.acquire(30).await;
591 let snapshot = limiter.snapshot().await;
592 assert_eq!(snapshot.tokens, 70);
593
594 limiter.debit_extra(20).await;
596 let snapshot = limiter.snapshot().await;
597 assert_eq!(snapshot.tokens, 50);
598
599 limiter.debit_extra(100).await;
601 let snapshot = limiter.snapshot().await;
602 assert_eq!(snapshot.tokens, 0);
603 }
604
605 #[tokio::test(start_paused = true)]
606 async fn test_limiter_retains_response_weight_debt() {
607 let limiter = WeightedLimiter::per_minute(60);
608 limiter.acquire(50).await;
609 limiter.debit_extra(20).await;
610 let started = tokio::time::Instant::now();
611
612 limiter.acquire(1).await;
613
614 assert_eq!(
615 tokio::time::Instant::now() - started,
616 Duration::from_secs(11)
617 );
618 }
619
620 #[rstest]
621 #[case(0, 100)]
622 #[case(1, 200)]
623 #[case(2, 400)]
624 fn test_backoff_full_jitter_increases(#[case] attempt: u32, #[case] max_expected_ms: u64) {
625 let base = Duration::from_millis(100);
626 let cap = Duration::from_secs(5);
627
628 let delay = backoff_full_jitter(attempt, base, cap);
629
630 assert!(delay.as_millis() >= 1);
631 assert!(delay.as_millis() <= max_expected_ms as u128);
632 }
633
634 #[rstest]
635 fn test_backoff_full_jitter_respects_cap() {
636 let base = Duration::from_millis(100);
637 let cap = Duration::from_secs(5);
638
639 let delay_high = backoff_full_jitter(10, base, cap);
640 assert!(delay_high.as_millis() <= cap.as_millis());
641 }
642
643 #[rstest]
644 #[case::zero_initial(0, 5000, 0)]
645 #[case::zero_cap(100, 0, 0)]
646 #[case::cap_below_initial(100, 1, 1)]
647 fn test_backoff_full_jitter_config_boundaries(
648 #[case] initial_ms: u64,
649 #[case] cap_ms: u64,
650 #[case] expected_ms: u64,
651 ) {
652 let delay = backoff_full_jitter(
653 0,
654 Duration::from_millis(initial_ms),
655 Duration::from_millis(cap_ms),
656 );
657
658 assert_eq!(delay, Duration::from_millis(expected_ms));
659 }
660}