Skip to main content

df_hyperliquid_core/client/
limit.rs

1//! Request weights against Hyperliquid's rate limit (the docs' "Rate limits
2//! and user limits"), the last minute's spend, and the gate a 429 closes. No
3//! clock and no I/O: every call is handed the time.
4
5use std::collections::VecDeque;
6
7/// Weight a minute per IP, reads and actions together.
8pub const LIMIT: u32 = 1_200;
9
10/// What reads may spend of [`LIMIT`] a minute before they wait. The last fifth
11/// is left to actions, which never wait, and to other clients on the same IP.
12pub const SOFT: u32 = LIMIT / 5 * 4;
13
14/// The window every figure here is over.
15pub const WINDOW_MS: u64 = 60_000;
16
17/// The first cooldown a 429 closes the gate for; each strike doubles it.
18pub const BASE_COOLDOWN_MS: u64 = 2_000;
19
20/// The longest cooldown a strike sets, though a `Retry-After` may ask longer.
21pub const MAX_COOLDOWN_MS: u64 = 60_000;
22
23const MAX_STRIKES: u32 = 6;
24
25/// An `/info` read's weight before counting what it returns.
26#[must_use]
27pub fn base_weight(kind: &str) -> u32 {
28    match kind {
29        "l2Book" | "allMids" | "clearinghouseState" | "orderStatus" | "spotClearinghouseState" | "exchangeStatus" => 2,
30        "userRole" => 60,
31        _ => 20,
32    }
33}
34
35/// For the reads that charge for what they return: how many items add one
36/// unit of weight.
37#[must_use]
38pub fn per_items(kind: &str) -> Option<usize> {
39    match kind {
40        "recentTrades"
41        | "historicalOrders"
42        | "userFills"
43        | "userFillsByTime"
44        | "fundingHistory"
45        | "userFunding"
46        | "nonUserFundingUpdates"
47        | "twapHistory"
48        | "userTwapSliceFills"
49        | "userTwapSliceFillsByTime"
50        | "delegatorHistory"
51        | "delegatorRewards"
52        | "validatorStats" => Some(20),
53        "candleSnapshot" => Some(60),
54        _ => None,
55    }
56}
57
58/// A read of `kind` that returned `items`.
59#[must_use]
60pub fn read_weight(kind: &str, items: usize) -> u32 {
61    let extra = per_items(kind).map_or(0, |per| items / per);
62    base_weight(kind).saturating_add(u32::try_from(extra).unwrap_or(u32::MAX))
63}
64
65/// An action batching `batched` orders, cancels or modifies: 1, plus 1 per 40.
66#[must_use]
67pub fn action_weight(batched: usize) -> u32 {
68    1u32.saturating_add(u32::try_from(batched / 40).unwrap_or(u32::MAX))
69}
70
71/// What went out over the last minute.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Spent {
74    pub limit: u32,
75    pub minute: u32,
76    /// By kind, the heaviest first.
77    pub kinds: Vec<Kind>,
78    /// 429s over the last minute.
79    pub refused: u32,
80    /// How long the gate stays closed; 0 when it is open.
81    pub cooldown_ms: u64,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Kind {
86    pub name: String,
87    pub count: u32,
88    pub weight: u32,
89}
90
91/// The last minute of requests: when each went out, its kind and weight; and
92/// when each refusal came.
93#[derive(Debug, Default)]
94pub struct Meter {
95    sent: VecDeque<(u64, String, u32)>,
96    refused: VecDeque<u64>,
97}
98
99impl Meter {
100    /// Drops what is a whole window old at `now`.
101    fn prune(&mut self, now: u64) {
102        let gone = |at: u64| at.saturating_add(WINDOW_MS) <= now;
103        while self.sent.front().is_some_and(|(at, _, _)| gone(*at)) {
104            self.sent.pop_front();
105        }
106        while self.refused.front().is_some_and(|at| gone(*at)) {
107            self.refused.pop_front();
108        }
109    }
110
111    pub fn record(&mut self, now: u64, kind: &str, weight: u32) {
112        self.prune(now);
113        self.sent.push_back((now, kind.to_owned(), weight));
114    }
115
116    pub fn refuse(&mut self, now: u64) {
117        self.prune(now);
118        self.refused.push_back(now);
119    }
120
121    /// How long a read of `need` waits at `now`, so the last minute stays
122    /// within [`SOFT`]: none while it fits, else until enough has rolled off.
123    pub fn wait_for(&mut self, now: u64, need: u32) -> Option<u64> {
124        self.prune(now);
125        let spent = self.sent.iter().fold(0u32, |sum, (_, _, weight)| sum.saturating_add(*weight));
126        let mut over = spent.saturating_add(need).checked_sub(SOFT).filter(|over| *over > 0)?;
127        for (at, _, weight) in &self.sent {
128            if *weight >= over {
129                return Some(at.saturating_add(WINDOW_MS).saturating_sub(now).max(1));
130            }
131            over -= weight;
132        }
133        Some(WINDOW_MS)
134    }
135
136    /// The last minute at `now`, with no cooldown: the [`Gate`] knows that.
137    pub fn spent(&mut self, now: u64) -> Spent {
138        self.prune(now);
139        let mut kinds: Vec<Kind> = Vec::new();
140        for (_, name, weight) in &self.sent {
141            match kinds.iter_mut().find(|kind| kind.name == *name) {
142                Some(kind) => {
143                    kind.count += 1;
144                    kind.weight = kind.weight.saturating_add(*weight);
145                }
146                None => kinds.push(Kind { name: name.clone(), count: 1, weight: *weight }),
147            }
148        }
149        kinds.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.name.cmp(&b.name)));
150        let minute = kinds.iter().fold(0u32, |sum, kind| sum.saturating_add(kind.weight));
151        Spent {
152            limit: LIMIT,
153            minute,
154            kinds,
155            refused: u32::try_from(self.refused.len()).unwrap_or(u32::MAX),
156            cooldown_ms: 0,
157        }
158    }
159}
160
161/// What a request carries out through the [`Gate`]: how many strikes there
162/// had been when it went out, so a burst sent before a 429 counts once.
163#[derive(Debug, Clone, Copy)]
164pub struct Ticket(u64);
165
166/// Closed by a 429 for every request, reads and actions alike: the venue
167/// limits by IP, so one refusal means the next request would be refused too.
168#[derive(Debug, Default)]
169pub struct Gate {
170    until: u64,
171    /// Consecutive strikes, a burst counting once.
172    strikes: u32,
173    /// Strikes ever.
174    epoch: u64,
175}
176
177impl Gate {
178    /// A ticket out, or how long the gate stays closed.
179    pub fn open(&self, now: u64) -> Result<Ticket, u64> {
180        match self.until > now {
181            true => Err(self.until - now),
182            false => Ok(Ticket(self.epoch)),
183        }
184    }
185
186    /// The request was answered: room again, unless a 429 struck since it
187    /// went out.
188    pub fn passed(&mut self, ticket: Ticket) {
189        if ticket.0 == self.epoch {
190            self.strikes = 0;
191        }
192    }
193
194    /// A 429 came back at `now`. The first of a burst strikes: two seconds,
195    /// doubling to a minute, or a longer `Retry-After`. The rest of the burst
196    /// only moves the gate for a longer `Retry-After`. Gives the wait.
197    pub fn struck(&mut self, ticket: Ticket, now: u64, retry_after_ms: Option<u64>) -> u64 {
198        let wait = match ticket.0 == self.epoch {
199            true => {
200                self.epoch += 1;
201                self.strikes = (self.strikes + 1).min(MAX_STRIKES);
202                let backoff = (BASE_COOLDOWN_MS << (self.strikes - 1)).min(MAX_COOLDOWN_MS);
203                backoff.max(retry_after_ms.unwrap_or(0))
204            }
205            false => retry_after_ms.unwrap_or(0),
206        };
207        self.until = self.until.max(now.saturating_add(wait));
208        self.until.saturating_sub(now)
209    }
210
211    #[must_use]
212    pub const fn cooldown_ms(&self, now: u64) -> u64 {
213        self.until.saturating_sub(now)
214    }
215}
216
217#[cfg(test)]
218#[path = "tests/limit.rs"]
219mod tests;