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    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
20};
21
22use serde_json::Value;
23
24use crate::{
25    common::enums::HyperliquidInfoRequestType,
26    http::{
27        models::HyperliquidExchangeAction,
28        query::{ExchangeAction, ExchangeActionParams, InfoRequest},
29    },
30};
31
32#[derive(Debug)]
33pub struct WeightedLimiter {
34    capacity: f64,       // tokens per minute (e.g., 1200)
35    refill_per_sec: f64, // capacity / 60
36    state: tokio::sync::Mutex<State>,
37}
38
39#[derive(Debug)]
40struct State {
41    tokens: f64,
42    last_refill: Instant,
43}
44
45impl WeightedLimiter {
46    pub fn per_minute(capacity: u32) -> Self {
47        let cap = capacity as f64;
48        Self {
49            capacity: cap,
50            refill_per_sec: cap / 60.0,
51            state: tokio::sync::Mutex::new(State {
52                tokens: cap,
53                last_refill: Instant::now(),
54            }),
55        }
56    }
57
58    /// Acquire `weight` tokens, sleeping until available.
59    pub async fn acquire(&self, weight: u32) {
60        let need = weight as f64;
61
62        loop {
63            let mut st = self.state.lock().await;
64            Self::refill_locked(&mut st, self.refill_per_sec, self.capacity);
65
66            if st.tokens >= need {
67                st.tokens -= need;
68                return;
69            }
70            let deficit = need - st.tokens;
71            let secs = deficit / self.refill_per_sec;
72            drop(st);
73            tokio::time::sleep(Duration::from_secs_f64(secs.max(0.01))).await;
74        }
75    }
76
77    /// Post-response debit for per-items adders (can temporarily clamp to 0).
78    pub async fn debit_extra(&self, extra: u32) {
79        if extra == 0 {
80            return;
81        }
82        let mut st = self.state.lock().await;
83        Self::refill_locked(&mut st, self.refill_per_sec, self.capacity);
84        st.tokens = (st.tokens - extra as f64).max(0.0);
85    }
86
87    pub async fn snapshot(&self) -> RateLimitSnapshot {
88        let mut st = self.state.lock().await;
89        Self::refill_locked(&mut st, self.refill_per_sec, self.capacity);
90        RateLimitSnapshot {
91            capacity: self.capacity as u32,
92            tokens: st.tokens.max(0.0) as u32,
93        }
94    }
95
96    fn refill_locked(st: &mut State, per_sec: f64, cap: f64) {
97        let dt = Instant::now().duration_since(st.last_refill).as_secs_f64();
98        if dt > 0.0 {
99            st.tokens = (st.tokens + dt * per_sec).min(cap);
100            st.last_refill = Instant::now();
101        }
102    }
103}
104
105#[derive(Debug, Clone, Copy)]
106pub struct RateLimitSnapshot {
107    pub capacity: u32,
108    pub tokens: u32,
109}
110
111pub fn backoff_full_jitter(attempt: u32, base: Duration, cap: Duration) -> Duration {
112    let mut hasher = DefaultHasher::new();
113    attempt.hash(&mut hasher);
114    let nanos = SystemTime::now()
115        .duration_since(UNIX_EPOCH)
116        .unwrap_or_default()
117        .as_nanos();
118    nanos.hash(&mut hasher);
119    let hash = hasher.finish();
120
121    let max = (base.as_millis() as u64)
122        .saturating_mul(1u64 << attempt.min(16))
123        .min(cap.as_millis() as u64)
124        .max(base.as_millis() as u64);
125
126    // Floor at 1ms to prevent zero-duration backoff
127    Duration::from_millis((hash % max).max(1))
128}
129
130/// Classify Info requests into weight classes based on request type.
131pub fn info_base_weight(req: &InfoRequest) -> u32 {
132    match req.request_type {
133        HyperliquidInfoRequestType::L2Book
134        | HyperliquidInfoRequestType::AllMids
135        | HyperliquidInfoRequestType::RecentTrades
136        | HyperliquidInfoRequestType::ClearinghouseState
137        | HyperliquidInfoRequestType::OrderStatus
138        | HyperliquidInfoRequestType::SpotClearinghouseState
139        | HyperliquidInfoRequestType::ExchangeStatus
140        | HyperliquidInfoRequestType::UserFees => 2,
141        HyperliquidInfoRequestType::UserRole => 60,
142        _ => 20,
143    }
144}
145
146/// Extra weight for heavy Info endpoints: +1 per 20 (most), +1 per 60 for candleSnapshot.
147/// We count the largest array in the response (robust to schema variants).
148pub fn info_extra_weight(req: &InfoRequest, json: &Value) -> u32 {
149    let items = match json {
150        Value::Array(a) => a.len(),
151        Value::Object(m) => m
152            .values()
153            .filter_map(|v| v.as_array().map(|a| a.len()))
154            .max()
155            .unwrap_or(0),
156        _ => 0,
157    };
158
159    let unit = match req.request_type {
160        HyperliquidInfoRequestType::CandleSnapshot => 60usize,
161        HyperliquidInfoRequestType::HistoricalOrders
162        | HyperliquidInfoRequestType::UserFills
163        | HyperliquidInfoRequestType::UserFillsByTime
164        | HyperliquidInfoRequestType::FundingHistory
165        | HyperliquidInfoRequestType::UserFunding
166        | HyperliquidInfoRequestType::NonUserFundingUpdates
167        | HyperliquidInfoRequestType::TwapHistory
168        | HyperliquidInfoRequestType::UserTwapSliceFills
169        | HyperliquidInfoRequestType::UserTwapSliceFillsByTime
170        | HyperliquidInfoRequestType::DelegatorHistory
171        | HyperliquidInfoRequestType::DelegatorRewards
172        | HyperliquidInfoRequestType::ValidatorStats => 20usize,
173        _ => return 0,
174    };
175    (items / unit) as u32
176}
177
178/// Exchange: 1 + floor(batch_len / 40)
179pub fn exchange_weight(action: &ExchangeAction) -> u32 {
180    // Extract batch size from typed params
181    let batch_size = match &action.params {
182        ExchangeActionParams::Order(params) => params.orders.len(),
183        ExchangeActionParams::Cancel(params) => params.cancels.len(),
184        ExchangeActionParams::Modify(_) => {
185            // Modify is for a single order
186            1
187        }
188        ExchangeActionParams::UpdateLeverage(_) | ExchangeActionParams::UpdateIsolatedMargin(_) => {
189            0
190        }
191    };
192    1 + (batch_size as u32 / 40)
193}
194
195/// Exchange weight for the canonical typed execution action model.
196pub fn exec_action_weight(action: &HyperliquidExchangeAction) -> u32 {
197    let batch_size = match action {
198        HyperliquidExchangeAction::Order { orders, .. } => orders.len(),
199        HyperliquidExchangeAction::Cancel { cancels, .. } => cancels.len(),
200        HyperliquidExchangeAction::CancelByCloid { cancels, .. } => cancels.len(),
201        HyperliquidExchangeAction::Modify { .. } => 1,
202        HyperliquidExchangeAction::BatchModify { modifies } => modifies.len(),
203        HyperliquidExchangeAction::UpdateLeverage { .. }
204        | HyperliquidExchangeAction::UpdateIsolatedMargin { .. }
205        | HyperliquidExchangeAction::ScheduleCancel { .. }
206        | HyperliquidExchangeAction::UsdClassTransfer { .. }
207        | HyperliquidExchangeAction::UserOutcome { .. }
208        | HyperliquidExchangeAction::TwapPlace { .. }
209        | HyperliquidExchangeAction::TwapCancel { .. }
210        | HyperliquidExchangeAction::Noop => 0,
211    };
212    1 + (batch_size as u32 / 40)
213}
214
215#[cfg(test)]
216mod tests {
217    use rstest::rstest;
218    use rust_decimal::Decimal;
219
220    use super::{
221        super::models::{
222            Cloid, HyperliquidExchangeAction, HyperliquidExchangeCancelByCloidRequest,
223            HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
224            HyperliquidExchangeLimitParams, HyperliquidExchangeModifyOrderRequest,
225            HyperliquidExchangeOrderKind, HyperliquidExchangePlaceOrderRequest,
226            HyperliquidExchangeTif,
227        },
228        *,
229    };
230    use crate::http::query::{
231        CancelParams, ExchangeAction, ExchangeActionParams, ExchangeActionType, OrderParams,
232        UpdateLeverageParams,
233    };
234
235    fn exec_order() -> HyperliquidExchangePlaceOrderRequest {
236        HyperliquidExchangePlaceOrderRequest {
237            asset: 0,
238            is_buy: true,
239            price: Decimal::new(50000, 0),
240            size: Decimal::new(1, 0),
241            reduce_only: false,
242            kind: HyperliquidExchangeOrderKind::Limit {
243                limit: HyperliquidExchangeLimitParams {
244                    tif: HyperliquidExchangeTif::Gtc,
245                },
246            },
247            cloid: Some(Cloid::from_hex("0x00000000000000000000000000000000").unwrap()),
248        }
249    }
250
251    fn exec_modify() -> HyperliquidExchangeModifyOrderRequest {
252        HyperliquidExchangeModifyOrderRequest {
253            oid: 12345.into(),
254            order: exec_order(),
255        }
256    }
257
258    fn exec_cancel_by_cloid() -> HyperliquidExchangeCancelByCloidRequest {
259        HyperliquidExchangeCancelByCloidRequest {
260            asset: 0,
261            cloid: Cloid::from_hex("0x00000000000000000000000000000000").unwrap(),
262        }
263    }
264
265    #[rstest]
266    #[case(1, 1)]
267    #[case(39, 1)]
268    #[case(40, 2)]
269    #[case(79, 2)]
270    #[case(80, 3)]
271    fn test_exchange_weight_order_steps_every_40(
272        #[case] array_len: usize,
273        #[case] expected_weight: u32,
274    ) {
275        let orders: Vec<HyperliquidExchangePlaceOrderRequest> =
276            (0..array_len).map(|_| exec_order()).collect();
277
278        let action = ExchangeAction {
279            action_type: ExchangeActionType::Order,
280            params: ExchangeActionParams::Order(OrderParams {
281                orders,
282                grouping: HyperliquidExchangeGrouping::Na,
283                builder: None,
284            }),
285        };
286        assert_eq!(exchange_weight(&action), expected_weight);
287    }
288
289    #[rstest]
290    #[case(1, 1)]
291    #[case(39, 1)]
292    #[case(40, 2)]
293    #[case(79, 2)]
294    #[case(80, 3)]
295    fn test_exec_action_weight_order_steps_every_40(
296        #[case] array_len: usize,
297        #[case] expected_weight: u32,
298    ) {
299        let action = HyperliquidExchangeAction::Order {
300            orders: (0..array_len).map(|_| exec_order()).collect(),
301            grouping: HyperliquidExchangeGrouping::Na,
302            builder: None,
303        };
304
305        assert_eq!(exec_action_weight(&action), expected_weight);
306    }
307
308    #[rstest]
309    #[case(1, 1)]
310    #[case(39, 1)]
311    #[case(40, 2)]
312    #[case(79, 2)]
313    #[case(80, 3)]
314    fn test_exec_action_weight_cancel_by_oid_steps_every_40(
315        #[case] array_len: usize,
316        #[case] expected_weight: u32,
317    ) {
318        let action = HyperliquidExchangeAction::Cancel {
319            cancels: (0..array_len)
320                .map(|i| HyperliquidExchangeCancelOrderRequest {
321                    asset: 0,
322                    oid: i as u64,
323                })
324                .collect(),
325            fast: None,
326        };
327
328        assert_eq!(exec_action_weight(&action), expected_weight);
329    }
330
331    #[rstest]
332    #[case(1, 1)]
333    #[case(39, 1)]
334    #[case(40, 2)]
335    #[case(79, 2)]
336    #[case(80, 3)]
337    fn test_exec_action_weight_cancel_by_cloid_steps_every_40(
338        #[case] array_len: usize,
339        #[case] expected_weight: u32,
340    ) {
341        let action = HyperliquidExchangeAction::CancelByCloid {
342            cancels: (0..array_len).map(|_| exec_cancel_by_cloid()).collect(),
343            fast: None,
344        };
345
346        assert_eq!(exec_action_weight(&action), expected_weight);
347    }
348
349    #[rstest]
350    #[case(1, 1)]
351    #[case(39, 1)]
352    #[case(40, 2)]
353    #[case(79, 2)]
354    #[case(80, 3)]
355    fn test_exec_action_weight_batch_modify_steps_every_40(
356        #[case] array_len: usize,
357        #[case] expected_weight: u32,
358    ) {
359        let action = HyperliquidExchangeAction::BatchModify {
360            modifies: (0..array_len).map(|_| exec_modify()).collect(),
361        };
362
363        assert_eq!(exec_action_weight(&action), expected_weight);
364    }
365
366    #[rstest]
367    fn test_exec_action_weight_modify() {
368        let action = HyperliquidExchangeAction::Modify {
369            modify: exec_modify(),
370        };
371
372        assert_eq!(exec_action_weight(&action), 1);
373    }
374
375    #[rstest]
376    fn test_exec_action_weight_non_batch_action() {
377        let action = HyperliquidExchangeAction::UpdateLeverage {
378            asset: 1,
379            is_cross: true,
380            leverage: 10,
381        };
382
383        assert_eq!(exec_action_weight(&action), 1);
384    }
385
386    #[rstest]
387    fn test_exchange_weight_cancel() {
388        let cancels: Vec<HyperliquidExchangeCancelByCloidRequest> =
389            (0..40).map(|_| exec_cancel_by_cloid()).collect();
390
391        let action = ExchangeAction {
392            action_type: ExchangeActionType::Cancel,
393            params: ExchangeActionParams::Cancel(CancelParams {
394                cancels,
395                fast: None,
396            }),
397        };
398        assert_eq!(exchange_weight(&action), 2);
399    }
400
401    #[rstest]
402    fn test_exchange_weight_non_batch_action() {
403        let update_leverage = ExchangeAction {
404            action_type: ExchangeActionType::UpdateLeverage,
405            params: ExchangeActionParams::UpdateLeverage(UpdateLeverageParams {
406                asset: 1,
407                is_cross: true,
408                leverage: 10,
409            }),
410        };
411        assert_eq!(exchange_weight(&update_leverage), 1);
412    }
413
414    #[tokio::test]
415    async fn test_limiter_roughly_caps_to_capacity() {
416        let limiter = WeightedLimiter::per_minute(1200);
417
418        // Consume ~1200 in quick succession
419        for _ in 0..60 {
420            limiter.acquire(20).await; // 60 * 20 = 1200
421        }
422
423        // The next acquire should take time for tokens to refill
424        let t0 = std::time::Instant::now();
425        limiter.acquire(20).await;
426        let elapsed = t0.elapsed();
427
428        // Should take at least some time to refill (allow some jitter/timing variance)
429        assert!(
430            elapsed.as_millis() >= 500,
431            "Expected significant delay, was {}ms",
432            elapsed.as_millis()
433        );
434    }
435
436    #[tokio::test]
437    async fn test_limiter_debit_extra_works() {
438        let limiter = WeightedLimiter::per_minute(100);
439
440        // Start with full bucket
441        let snapshot = limiter.snapshot().await;
442        assert_eq!(snapshot.capacity, 100);
443        assert_eq!(snapshot.tokens, 100);
444
445        // Acquire some tokens
446        limiter.acquire(30).await;
447        let snapshot = limiter.snapshot().await;
448        assert_eq!(snapshot.tokens, 70);
449
450        // Debit extra
451        limiter.debit_extra(20).await;
452        let snapshot = limiter.snapshot().await;
453        assert_eq!(snapshot.tokens, 50);
454
455        // Debit more than available (should clamp to 0)
456        limiter.debit_extra(100).await;
457        let snapshot = limiter.snapshot().await;
458        assert_eq!(snapshot.tokens, 0);
459    }
460
461    #[rstest]
462    #[case(0, 100)]
463    #[case(1, 200)]
464    #[case(2, 400)]
465    fn test_backoff_full_jitter_increases(#[case] attempt: u32, #[case] max_expected_ms: u64) {
466        let base = Duration::from_millis(100);
467        let cap = Duration::from_secs(5);
468
469        let delay = backoff_full_jitter(attempt, base, cap);
470
471        assert!(delay.as_millis() >= 1);
472        assert!(delay.as_millis() <= max_expected_ms as u128);
473    }
474
475    #[rstest]
476    fn test_backoff_full_jitter_respects_cap() {
477        let base = Duration::from_millis(100);
478        let cap = Duration::from_secs(5);
479
480        let delay_high = backoff_full_jitter(10, base, cap);
481        assert!(delay_high.as_millis() <= cap.as_millis());
482    }
483}