Skip to main content

bybit/http/
rate_limiter.rs

1use std::{collections::HashMap, sync::Mutex, time::Instant};
2
3use crate::{Path, enums::Category};
4
5/// Sustained throughput and burst capacity for one rate-limit bucket.
6#[derive(Debug, Clone, Copy)]
7pub struct BucketLimit {
8    /// Sustained throughput in requests per second.
9    pub requests_per_second: f64,
10    /// Maximum burst capacity (also the initial token count for a bucket).
11    ///
12    /// Must be at least as large as the biggest single call's cost you expect to make against
13    /// this bucket, or that call can never succeed. In particular, a bucket used for a batch
14    /// order endpoint needs `burst >= ` the largest batch size you'll send (Bybit currently
15    /// caps batches at 20 orders for linear, 10 for others).
16    pub burst: u32,
17}
18
19/// Identifies one of Bybit's independent per-UID rate-limit buckets.
20///
21/// Bybit tracks limits per UID *per endpoint*
22/// (<https://bybit-exchange.github.io/docs/v5/rate-limit>: "Each endpoint has its own
23/// independent limit"), and for the order endpoints (create / amend / cancel / cancel-all,
24/// single and batch) the limit additionally varies by `category`. `category: None` means the
25/// endpoint has one limit regardless of category.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct RateLimitKey {
28    pub path: Path,
29    pub category: Option<Category>,
30}
31
32impl RateLimitKey {
33    pub const fn new(path: Path) -> Self {
34        Self {
35            path,
36            category: None,
37        }
38    }
39
40    pub const fn with_category(path: Path, category: Category) -> Self {
41        Self {
42            path,
43            category: Some(category),
44        }
45    }
46}
47
48/// Configuration for the optional per-endpoint rate limiter.
49///
50/// Pass this via [`Config::rate_limiter`](crate::http::Config::rate_limiter) to have the
51/// client pre-flight-check a local bucket before every request and reject calls that would
52/// obviously hit the exchange's limits.
53///
54/// Bybit tracks a separate limit per endpoint (and, for order endpoints, per category) rather
55/// than one account-wide limit, and batch order endpoints consume one unit of quota *per order
56/// in the batch*, not one per request
57/// (<https://bybit-exchange.github.io/docs/v5/rate-limit>). This limiter mirrors that: each
58/// [`RateLimitKey`] gets its own independent token bucket and its own server-feedback state,
59/// and every call site reports how many units it actually costs.
60///
61/// Two checks are combined per bucket:
62///
63/// 1. **Token bucket** — tokens refill at the bucket's `requests_per_second`. An initial burst
64///    of `burst` tokens is available. When the bucket doesn't have enough tokens for the
65///    request's cost, the request is rejected immediately with [`Error::RateLimited`].
66///
67/// 2. **Server feedback** — after every response, Bybit's `X-Bapi-Limit-Status` /
68///    `X-Bapi-Limit-Reset-Timestamp` headers are recorded against that response's bucket. If
69///    the server-reported remaining count reaches **zero**, the next request against that same
70///    bucket is rejected with [`Error::RateLimited`] until the reset window passes, regardless
71///    of local token state.
72///
73/// Bybit's limits vary by account VIP/Pro tier and change over time, so this crate does not
74/// hardcode them: populate `overrides` yourself from
75/// <https://bybit-exchange.github.io/docs/v5/rate-limit>, or start from
76/// [`RateLimiterConfig::bybit_base_tier_defaults`] and adjust it for your tier.
77///
78/// [`Error::RateLimited`]: crate::Error::RateLimited
79#[derive(Debug, Clone)]
80pub struct RateLimiterConfig {
81    /// Limit applied to any [`RateLimitKey`] not present in `overrides`.
82    pub default: BucketLimit,
83    /// Per-endpoint (optionally per-category) overrides.
84    pub overrides: HashMap<RateLimitKey, BucketLimit>,
85}
86
87impl RateLimiterConfig {
88    /// A single limit shared by every endpoint, with no per-endpoint overrides.
89    ///
90    /// Equivalent to this crate's previous, endpoint-unaware rate limiter. Prefer
91    /// [`RateLimiterConfig::bybit_base_tier_defaults`] for a more accurate starting point.
92    pub fn uniform(requests_per_second: f64, burst: u32) -> Self {
93        Self {
94            default: BucketLimit {
95                requests_per_second,
96                burst,
97            },
98            overrides: HashMap::new(),
99        }
100    }
101
102    /// Starting-point overrides for the base (non-VIP) tier's most commonly used endpoints,
103    /// taken from <https://bybit-exchange.github.io/docs/v5/rate-limit>. Not exhaustive — it
104    /// only covers the Trade endpoints (where the limit varies sharply by category and, for
105    /// batch endpoints, is easy to blow through without per-order weighting) plus a couple of
106    /// high-traffic Account ones. Add your own overrides for anything else, and adjust these
107    /// if your account has a VIP/Pro tier.
108    ///
109    /// `default` is set to a conservative 5 req/s, 5 burst for everything not listed here.
110    pub fn bybit_base_tier_defaults() -> Self {
111        let mut overrides = HashMap::new();
112
113        let mut set = |path: Path, category: Category, rps: f64, burst: u32| {
114            overrides.insert(
115                RateLimitKey::with_category(path, category),
116                BucketLimit {
117                    requests_per_second: rps,
118                    burst,
119                },
120            );
121        };
122
123        // Create Order.
124        set(Path::TradeOrderCreate, Category::Linear, 20.0, 20);
125        set(Path::TradeOrderCreate, Category::Inverse, 10.0, 10);
126        set(Path::TradeOrderCreate, Category::Spot, 10.0, 10);
127        set(Path::TradeOrderCreate, Category::Option, 10.0, 10);
128
129        // Amend Order.
130        set(Path::TradeOrderAmend, Category::Linear, 10.0, 10);
131        set(Path::TradeOrderAmend, Category::Inverse, 10.0, 10);
132        set(Path::TradeOrderAmend, Category::Spot, 10.0, 10);
133        set(Path::TradeOrderAmend, Category::Option, 10.0, 10);
134
135        // Cancel Order.
136        set(Path::TradeOrderCancel, Category::Linear, 20.0, 20);
137        set(Path::TradeOrderCancel, Category::Inverse, 10.0, 10);
138        set(Path::TradeOrderCancel, Category::Spot, 10.0, 10);
139        set(Path::TradeOrderCancel, Category::Option, 10.0, 10);
140
141        // Cancel All Orders.
142        set(Path::TradeOrderCancelAll, Category::Linear, 20.0, 20);
143        set(Path::TradeOrderCancelAll, Category::Inverse, 1.0, 1);
144        set(Path::TradeOrderCancelAll, Category::Spot, 10.0, 10);
145        set(Path::TradeOrderCancelAll, Category::Option, 10.0, 10);
146
147        // Batch endpoints: independent limit from the single-order ones above, and the burst
148        // must cover the largest batch you'll send (Bybit currently caps batches at 20 orders
149        // for linear, 10 for others), since one call consumes `cost = orders.len()`.
150        for path in [
151            Path::TradeOrderCreateBatch,
152            Path::TradeOrderAmendBatch,
153            Path::TradeOrderCancelBatch,
154        ] {
155            set(path, Category::Linear, 20.0, 20);
156            set(path, Category::Inverse, 10.0, 10);
157            set(path, Category::Spot, 10.0, 10);
158            set(path, Category::Option, 10.0, 10);
159        }
160
161        // A few high-traffic, category-independent endpoints.
162        overrides.insert(
163            RateLimitKey::new(Path::TradeOrderRealtime),
164            BucketLimit {
165                requests_per_second: 50.0,
166                burst: 50,
167            },
168        );
169        overrides.insert(
170            RateLimitKey::new(Path::TradeOrderHistory),
171            BucketLimit {
172                requests_per_second: 50.0,
173                burst: 50,
174            },
175        );
176        overrides.insert(
177            RateLimitKey::new(Path::AccountWalletBalance),
178            BucketLimit {
179                requests_per_second: 50.0,
180                burst: 50,
181            },
182        );
183        overrides.insert(
184            RateLimitKey::new(Path::AccountFeeRate),
185            BucketLimit {
186                requests_per_second: 5.0,
187                burst: 5,
188            },
189        );
190
191        Self {
192            default: BucketLimit {
193                requests_per_second: 5.0,
194                burst: 5,
195            },
196            overrides,
197        }
198    }
199
200    pub fn with_override(mut self, key: RateLimitKey, limit: BucketLimit) -> Self {
201        self.overrides.insert(key, limit);
202        self
203    }
204}
205
206/// Why [`RateLimiter::check`] rejected a request.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub(crate) enum Rejection {
209    /// Not enough capacity right now; retry after this many milliseconds.
210    RetryAfter(u64),
211    /// The request can never pass this bucket: its cost exceeds the bucket's
212    /// burst, or the bucket does not refill (`requests_per_second <= 0`).
213    Unsatisfiable { cost: u32, burst: u32 },
214}
215
216pub(crate) struct RateLimiter {
217    config: RateLimiterConfig,
218    buckets: Mutex<HashMap<RateLimitKey, Bucket>>,
219}
220
221impl std::fmt::Debug for RateLimiter {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        let g = self.buckets.lock().unwrap();
224        f.debug_struct("RateLimiter")
225            .field("default", &self.config.default)
226            .field("overrides", &self.config.overrides.len())
227            .field("active_buckets", &g.len())
228            .finish()
229    }
230}
231
232struct Bucket {
233    limit: BucketLimit,
234    tokens: f64,
235    last_refill: Instant,
236    server_remaining: Option<u64>,
237    server_reset_at_ms: Option<u64>,
238}
239
240impl Bucket {
241    fn new(limit: BucketLimit) -> Self {
242        Self {
243            tokens: limit.burst as f64,
244            limit,
245            last_refill: Instant::now(),
246            server_remaining: None,
247            server_reset_at_ms: None,
248        }
249    }
250}
251
252impl RateLimiter {
253    pub(crate) fn new(config: RateLimiterConfig) -> Self {
254        Self {
255            config,
256            buckets: Mutex::new(HashMap::new()),
257        }
258    }
259
260    fn limit_for(&self, key: &RateLimitKey) -> BucketLimit {
261        self.config
262            .overrides
263            .get(key)
264            .copied()
265            .unwrap_or(self.config.default)
266    }
267
268    /// Returns `Ok(())` if the request may proceed, consuming `cost` tokens from the bucket
269    /// identified by `key`. Returns `Err` if it should be rejected pre-flight.
270    pub(crate) fn check(&self, key: RateLimitKey, cost: u32) -> Result<(), Rejection> {
271        let limit = self.limit_for(&key);
272        if cost > limit.burst {
273            return Err(Rejection::Unsatisfiable {
274                cost,
275                burst: limit.burst,
276            });
277        }
278        let mut g = self.buckets.lock().unwrap();
279        let bucket = g.entry(key).or_insert_with(|| Bucket::new(limit));
280
281        // Server-feedback check: reject immediately if the server told us this bucket's
282        // window is exhausted and the reset time hasn't arrived yet.
283        if let (Some(0), Some(reset_at)) = (bucket.server_remaining, bucket.server_reset_at_ms) {
284            let now_ms = unix_ms();
285            if now_ms < reset_at {
286                return Err(Rejection::RetryAfter(reset_at - now_ms));
287            }
288            // Window has expired — clear stale server state.
289            bucket.server_remaining = None;
290            bucket.server_reset_at_ms = None;
291        }
292
293        // Token bucket: refill proportional to elapsed time, then try to consume `cost` tokens.
294        // A non-positive or non-finite rate means the bucket never refills.
295        let rps = bucket.limit.requests_per_second;
296        let refills = rps.is_finite() && rps > 0.0;
297        let now = Instant::now();
298        if refills {
299            let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
300            bucket.tokens = (bucket.tokens + elapsed * rps).min(bucket.limit.burst as f64);
301        }
302        bucket.last_refill = now;
303
304        let needed = cost as f64;
305        if bucket.tokens < needed {
306            if !refills {
307                return Err(Rejection::Unsatisfiable {
308                    cost,
309                    burst: bucket.limit.burst,
310                });
311            }
312            let wait_ms = ((needed - bucket.tokens) / rps * 1000.0).ceil() as u64;
313            return Err(Rejection::RetryAfter(wait_ms));
314        }
315
316        bucket.tokens -= needed;
317        Ok(())
318    }
319
320    /// Store server-reported rate-limit state from response headers against `key`'s bucket.
321    pub(crate) fn update(
322        &self,
323        key: RateLimitKey,
324        remaining: Option<u64>,
325        reset_at_ms: Option<u64>,
326    ) {
327        if remaining.is_none() && reset_at_ms.is_none() {
328            return;
329        }
330        let limit = self.limit_for(&key);
331        let mut g = self.buckets.lock().unwrap();
332        let bucket = g.entry(key).or_insert_with(|| Bucket::new(limit));
333        if let Some(r) = remaining {
334            bucket.server_remaining = Some(r);
335        }
336        if let Some(t) = reset_at_ms {
337            bucket.server_reset_at_ms = Some(t);
338        }
339    }
340}
341
342fn unix_ms() -> u64 {
343    std::time::SystemTime::now()
344        .duration_since(std::time::UNIX_EPOCH)
345        .unwrap_or_default()
346        .as_millis() as u64
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    const KEY: RateLimitKey = RateLimitKey::new(Path::MarketTickers);
354
355    fn limiter(requests_per_second: f64, burst: u32) -> RateLimiter {
356        RateLimiter::new(RateLimiterConfig::uniform(requests_per_second, burst))
357    }
358
359    #[test]
360    fn burst_is_available_then_requests_are_rejected_with_retry_after() {
361        let rl = limiter(10.0, 2);
362
363        assert_eq!(rl.check(KEY, 1), Ok(()));
364        assert_eq!(rl.check(KEY, 1), Ok(()));
365        assert!(matches!(
366            rl.check(KEY, 1),
367            Err(Rejection::RetryAfter(ms)) if ms > 0 && ms <= 100
368        ));
369    }
370
371    #[test]
372    fn tokens_refill_over_time() {
373        let rl = limiter(1000.0, 1);
374        assert_eq!(rl.check(KEY, 1), Ok(()));
375
376        std::thread::sleep(std::time::Duration::from_millis(5));
377
378        assert_eq!(rl.check(KEY, 1), Ok(()));
379    }
380
381    #[test]
382    fn cost_above_burst_is_unsatisfiable() {
383        let rl = limiter(10.0, 5);
384
385        assert_eq!(
386            rl.check(KEY, 10),
387            Err(Rejection::Unsatisfiable { cost: 10, burst: 5 })
388        );
389    }
390
391    #[test]
392    fn non_refilling_bucket_is_unsatisfiable_once_empty() {
393        let rl = limiter(0.0, 1);
394
395        assert_eq!(rl.check(KEY, 1), Ok(()));
396        assert_eq!(
397            rl.check(KEY, 1),
398            Err(Rejection::Unsatisfiable { cost: 1, burst: 1 })
399        );
400    }
401
402    #[test]
403    fn server_reported_exhaustion_blocks_until_reset() {
404        let rl = limiter(10.0, 10);
405        rl.update(KEY, Some(0), Some(unix_ms() + 60_000));
406
407        assert!(matches!(rl.check(KEY, 1), Err(Rejection::RetryAfter(_))));
408    }
409
410    #[test]
411    fn base_tier_defaults_cover_option_batches() {
412        let config = RateLimiterConfig::bybit_base_tier_defaults();
413        let key = RateLimitKey::with_category(Path::TradeOrderCreateBatch, Category::Option);
414
415        assert!(config.overrides[&key].burst >= 10);
416    }
417}