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