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
157    if max == 0 {
158        return Duration::ZERO;
159    }
160
161    // Floor at 1ms to prevent zero-duration backoff
162    Duration::from_millis((hash % max).max(1))
163}
164
165/// Classify Info requests into weight classes based on request type.
166pub 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
179/// Extra weight for heavy Info endpoints: +1 per 20 (most), +1 per 60 for candleSnapshot.
180/// We count the largest array in the response (robust to schema variants).
181pub 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
216/// Exchange: 1 + floor(batch_len / 40)
217pub fn exchange_weight(action: &ExchangeAction) -> u32 {
218    // Extract batch size from typed params
219    let batch_size = match &action.params {
220        ExchangeActionParams::Order(params) => params.orders.len(),
221        ExchangeActionParams::Cancel(params) => params.cancels.len(),
222        ExchangeActionParams::Modify(_) => {
223            // Modify is for a single order
224            1
225        }
226        ExchangeActionParams::UpdateLeverage(_) | ExchangeActionParams::UpdateIsolatedMargin(_) => {
227            0
228        }
229    };
230    exchange_weight_for_batch(batch_size)
231}
232
233/// Exchange weight for the canonical typed execution action model.
234pub 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        // Consume ~1200 in quick succession
568        for _ in 0..60 {
569            limiter.acquire(20).await; // 60 * 20 = 1200
570        }
571
572        // The next acquire should take time for tokens to refill
573        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        // Start with full bucket
585        let snapshot = limiter.snapshot().await;
586        assert_eq!(snapshot.capacity, 100);
587        assert_eq!(snapshot.tokens, 100);
588
589        // Acquire some tokens
590        limiter.acquire(30).await;
591        let snapshot = limiter.snapshot().await;
592        assert_eq!(snapshot.tokens, 70);
593
594        // Debit extra
595        limiter.debit_extra(20).await;
596        let snapshot = limiter.snapshot().await;
597        assert_eq!(snapshot.tokens, 50);
598
599        // Debit more than available. The snapshot stays nonnegative.
600        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}