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}