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        }
159
160        // A few high-traffic, category-independent endpoints.
161        overrides.insert(
162            RateLimitKey::new(Path::TradeOrderRealtime),
163            BucketLimit {
164                requests_per_second: 50.0,
165                burst: 50,
166            },
167        );
168        overrides.insert(
169            RateLimitKey::new(Path::TradeOrderHistory),
170            BucketLimit {
171                requests_per_second: 50.0,
172                burst: 50,
173            },
174        );
175        overrides.insert(
176            RateLimitKey::new(Path::AccountWalletBalance),
177            BucketLimit {
178                requests_per_second: 50.0,
179                burst: 50,
180            },
181        );
182        overrides.insert(
183            RateLimitKey::new(Path::AccountFeeRate),
184            BucketLimit {
185                requests_per_second: 5.0,
186                burst: 5,
187            },
188        );
189
190        Self {
191            default: BucketLimit {
192                requests_per_second: 5.0,
193                burst: 5,
194            },
195            overrides,
196        }
197    }
198
199    pub fn with_override(mut self, key: RateLimitKey, limit: BucketLimit) -> Self {
200        self.overrides.insert(key, limit);
201        self
202    }
203}
204
205pub(crate) struct RateLimiter {
206    config: RateLimiterConfig,
207    buckets: Mutex<HashMap<RateLimitKey, Bucket>>,
208}
209
210impl std::fmt::Debug for RateLimiter {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        let g = self.buckets.lock().unwrap();
213        f.debug_struct("RateLimiter")
214            .field("default", &self.config.default)
215            .field("overrides", &self.config.overrides.len())
216            .field("active_buckets", &g.len())
217            .finish()
218    }
219}
220
221struct Bucket {
222    limit: BucketLimit,
223    tokens: f64,
224    last_refill: Instant,
225    server_remaining: Option<u64>,
226    server_reset_at_ms: Option<u64>,
227}
228
229impl Bucket {
230    fn new(limit: BucketLimit) -> Self {
231        Self {
232            tokens: limit.burst as f64,
233            limit,
234            last_refill: Instant::now(),
235            server_remaining: None,
236            server_reset_at_ms: None,
237        }
238    }
239}
240
241impl RateLimiter {
242    pub(crate) fn new(config: RateLimiterConfig) -> Self {
243        Self {
244            config,
245            buckets: Mutex::new(HashMap::new()),
246        }
247    }
248
249    fn limit_for(&self, key: &RateLimitKey) -> BucketLimit {
250        self.config
251            .overrides
252            .get(key)
253            .copied()
254            .unwrap_or(self.config.default)
255    }
256
257    /// Returns `Ok(())` if the request may proceed, consuming `cost` tokens from the bucket
258    /// identified by `key`. Returns `Err(retry_after_ms)` if it should be rejected pre-flight.
259    pub(crate) fn check(&self, key: RateLimitKey, cost: u32) -> Result<(), u64> {
260        let limit = self.limit_for(&key);
261        let mut g = self.buckets.lock().unwrap();
262        let bucket = g.entry(key).or_insert_with(|| Bucket::new(limit));
263
264        // Server-feedback check: reject immediately if the server told us this bucket's
265        // window is exhausted and the reset time hasn't arrived yet.
266        if let (Some(0), Some(reset_at)) = (bucket.server_remaining, bucket.server_reset_at_ms) {
267            let now_ms = unix_ms();
268            if now_ms < reset_at {
269                return Err(reset_at - now_ms);
270            }
271            // Window has expired — clear stale server state.
272            bucket.server_remaining = None;
273            bucket.server_reset_at_ms = None;
274        }
275
276        // Token bucket: refill proportional to elapsed time, then try to consume `cost` tokens.
277        let now = Instant::now();
278        let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
279        bucket.tokens = (bucket.tokens + elapsed * bucket.limit.requests_per_second)
280            .min(bucket.limit.burst as f64);
281        bucket.last_refill = now;
282
283        let cost = cost as f64;
284        if bucket.tokens < cost {
285            let wait_ms =
286                ((cost - bucket.tokens) / bucket.limit.requests_per_second * 1000.0).ceil() as u64;
287            return Err(wait_ms);
288        }
289
290        bucket.tokens -= cost;
291        Ok(())
292    }
293
294    /// Store server-reported rate-limit state from response headers against `key`'s bucket.
295    pub(crate) fn update(
296        &self,
297        key: RateLimitKey,
298        remaining: Option<u64>,
299        reset_at_ms: Option<u64>,
300    ) {
301        if remaining.is_none() && reset_at_ms.is_none() {
302            return;
303        }
304        let limit = self.limit_for(&key);
305        let mut g = self.buckets.lock().unwrap();
306        let bucket = g.entry(key).or_insert_with(|| Bucket::new(limit));
307        if let Some(r) = remaining {
308            bucket.server_remaining = Some(r);
309        }
310        if let Some(t) = reset_at_ms {
311            bucket.server_reset_at_ms = Some(t);
312        }
313    }
314}
315
316fn unix_ms() -> u64 {
317    std::time::SystemTime::now()
318        .duration_since(std::time::UNIX_EPOCH)
319        .unwrap_or_default()
320        .as_millis() as u64
321}