Skip to main content

nautilus_hyperliquid/http/
rate_limits.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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,       // tokens per minute (e.g., 1200)
47    refill_per_sec: f64, // capacity / 60
48    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    /// Acquire `weight` tokens, sleeping until available.
71    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    /// Post-response debit for per-item adders.
90    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    // Floor at 1ms to prevent zero-duration backoff
159    Duration::from_millis((hash % max).max(1))
160}
161
162/// Classify Info requests into weight classes based on request type.
163pub 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
176/// Extra weight for heavy Info endpoints: +1 per 20 (most), +1 per 60 for candleSnapshot.
177/// We count the largest array in the response (robust to schema variants).
178pub 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
213/// Exchange: 1 + floor(batch_len / 40)
214pub fn exchange_weight(action: &ExchangeAction) -> u32 {
215    // Extract batch size from typed params
216    let batch_size = match &action.params {
217        ExchangeActionParams::Order(params) => params.orders.len(),
218        ExchangeActionParams::Cancel(params) => params.cancels.len(),
219        ExchangeActionParams::Modify(_) => {
220            // Modify is for a single order
221            1
222        }
223        ExchangeActionParams::UpdateLeverage(_) | ExchangeActionParams::UpdateIsolatedMargin(_) => {
224            0
225        }
226    };
227    exchange_weight_for_batch(batch_size)
228}
229
230/// Exchange weight for the canonical typed execution action model.
231pub 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        // Consume ~1200 in quick succession
565        for _ in 0..60 {
566            limiter.acquire(20).await; // 60 * 20 = 1200
567        }
568
569        // The next acquire should take time for tokens to refill
570        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        // Start with full bucket
582        let snapshot = limiter.snapshot().await;
583        assert_eq!(snapshot.capacity, 100);
584        assert_eq!(snapshot.tokens, 100);
585
586        // Acquire some tokens
587        limiter.acquire(30).await;
588        let snapshot = limiter.snapshot().await;
589        assert_eq!(snapshot.tokens, 70);
590
591        // Debit extra
592        limiter.debit_extra(20).await;
593        let snapshot = limiter.snapshot().await;
594        assert_eq!(snapshot.tokens, 50);
595
596        // Debit more than available. The snapshot stays nonnegative.
597        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}