nautilus_lighter/common/rate_limit.rs
1// -------------------------------------------------------------------------------------------------
2// Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3// https://nautechsystems.io
4//
5// Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6// You may not use this file except in compliance with the License.
7// You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Rate-limit keys, quotas, and limiters for the Lighter adapter.
17//!
18//! Lighter meters requests against both the caller IP and the account L1 address.
19//!
20//! REST reads draw on a per-client read quota. Transactions (`sendTx` /
21//! `sendTxBatch`) are metered in one venue bucket per account regardless of
22//! transport; single orders go over the WebSocket and batches over HTTP, so both
23//! share one [`LighterTxRateLimiter`] to keep their combined rate under the single
24//! venue transaction limit.
25//!
26//! # WebSocket client messages
27//!
28//! Non-transaction WS frames (subscribe, unsubscribe, resubscribe) face two
29//! independent per-IP caps: 200 messages per minute and 50 unacknowledged
30//! (inflight) messages. The adapter honours each with a separate mechanism:
31//!
32//! - Rate: one [`ws_message_rate_limiter`] per venue URL, shared by the data and
33//! execution clients so their combined send rate counts against a single
34//! bucket. It paces at the documented 200/min with a matching 50-message burst.
35//! - Inflight: a rate limiter cannot bound inflight, because the unacknowledged
36//! count tracks venue acknowledgement latency (multi-second and fat-tailed),
37//! not emission rate. The feed handler instead gates subscribe dispatch on a
38//! closed-loop count of unacknowledged subscribes
39//! ([`crate::common::consts::SUBSCRIBE_INFLIGHT_MAX`]), releasing a slot on each
40//! ack. Without it, a subscribe storm at startup or reconnect drives inflight
41//! past 50 and the venue returns `30009` / `30010`.
42//!
43//! `sendTx` is metered in the transaction bucket, not the WS message bucket.
44
45use std::{
46 num::NonZeroU32,
47 sync::{Arc, LazyLock, Mutex},
48};
49
50use ahash::AHashMap;
51use nautilus_network::ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota};
52use ustr::Ustr;
53
54/// Conservative Lighter REST rate limit for standard accounts.
55///
56/// Lighter documents 60 REST requests per rolling minute for standard accounts. Builder and
57/// premium accounts can authenticate requests to get higher weighted limits.
58pub static LIGHTER_REST_QUOTA: LazyLock<Quota> =
59 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(60).expect("non-zero")));
60
61/// Rate-limit bucket key shared by all REST read endpoints.
62pub const LIGHTER_REST_BUCKET: &str = "lighter:rest";
63
64/// Rate-limit bucket key for the venue transaction bucket.
65///
66/// Lighter meters `sendTx` and `sendTxBatch` (HTTP) and the WebSocket `sendTx`
67/// path in one per-account bucket. Both transports share a
68/// [`LighterTxRateLimiter`] keyed on this so their combined rate stays under the
69/// single venue limit.
70pub const LIGHTER_TX_BUCKET: &str = "lighter:tx";
71
72/// Rate-limit bucket key for non-transaction WebSocket client messages.
73pub const LIGHTER_WS_MESSAGE_BUCKET: &str = "lighter:ws:messages";
74
75/// Lighter's documented WebSocket message rate: 200 per IP per minute.
76pub const LIGHTER_WS_MESSAGE_RATE_PER_MIN: u32 = 200;
77
78/// Rate-limiter burst, at Lighter's documented 50-message inflight cap. The
79/// closed-loop subscribe gate ([`crate::common::consts::SUBSCRIBE_INFLIGHT_MAX`])
80/// is the real inflight bound; this burst only shapes send rate.
81pub const LIGHTER_WS_MESSAGE_BURST: u32 = 50;
82
83/// Lighter WebSocket client-message quota, excluding `sendTx` / `sendTxBatch`.
84///
85/// Paces at the documented 200/min with a 50-message burst; the inflight cap is
86/// enforced separately by the subscribe gate (see the module docs).
87pub static LIGHTER_WS_MESSAGE_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
88 Quota::per_minute(NonZeroU32::new(LIGHTER_WS_MESSAGE_RATE_PER_MIN).expect("non-zero"))
89 .allow_burst(NonZeroU32::new(LIGHTER_WS_MESSAGE_BURST).expect("non-zero"))
90});
91
92/// Pre-interned rate-limit key for non-transaction WebSocket client messages.
93pub static LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY: LazyLock<[Ustr; 1]> =
94 LazyLock::new(|| [Ustr::from(LIGHTER_WS_MESSAGE_BUCKET)]);
95
96/// Per-account transaction rate limiter, shared across the HTTP and WebSocket
97/// `sendTx` paths so their combined rate honours the single venue bucket.
98pub type LighterTxRateLimiter = RateLimiter<Ustr, MonotonicClock>;
99
100/// Shared WebSocket message limiter, keyed by venue WS URL. Both data and
101/// execution clients (and the backend balance poller) draw from one bucket
102/// per URL so their combined send rate honours the venue's per-IP cap.
103pub type LighterWsMessageRateLimiter = Arc<RateLimiter<Ustr, MonotonicClock>>;
104
105// Process-global registry of Lighter WS message limiters, keyed by resolved
106// WS URL. Clients on the same URL (a network's data, execution, and backend
107// poller) share one bucket honouring the venue per-IP cap; distinct URLs
108// (testnet, or a custom endpoint in tests) stay isolated so unrelated traffic
109// never contends for the same tokens.
110static LIGHTER_WS_MESSAGE_LIMITERS: LazyLock<Mutex<AHashMap<String, LighterWsMessageRateLimiter>>> =
111 LazyLock::new(|| Mutex::new(AHashMap::new()));
112
113/// Returns the shared WS message limiter for `url`, creating it on first
114/// access. Subsequent calls with the same `url` return the same `Arc`.
115///
116/// # Panics
117///
118/// Panics if the registry mutex is poisoned by a prior panic while holding the lock.
119#[must_use]
120pub fn ws_message_rate_limiter(url: &str) -> LighterWsMessageRateLimiter {
121 LIGHTER_WS_MESSAGE_LIMITERS
122 .lock()
123 .expect("Lighter WS message rate limiter registry mutex poisoned")
124 .entry(url.to_string())
125 .or_insert_with(|| {
126 Arc::new(RateLimiter::new_with_quota(
127 None,
128 vec![(
129 Ustr::from(LIGHTER_WS_MESSAGE_BUCKET),
130 *LIGHTER_WS_MESSAGE_QUOTA,
131 )],
132 ))
133 })
134 .clone()
135}
136
137/// Resolves a per-minute override to a quota, falling back to the conservative
138/// standard-account quota when unset or zero.
139#[must_use]
140pub fn resolve_quota(per_min: Option<u32>) -> Quota {
141 per_min
142 .and_then(NonZeroU32::new)
143 .map_or(*LIGHTER_REST_QUOTA, Quota::per_minute)
144}
145
146/// Builds the shared transaction limiter from a `sendtx_quota_per_min` override,
147/// keyed on [`LIGHTER_TX_BUCKET`]. Unset or zero falls back to the standard
148/// 60 req/min.
149#[must_use]
150pub fn build_tx_rate_limiter(sendtx_per_min: Option<u32>) -> Arc<LighterTxRateLimiter> {
151 Arc::new(RateLimiter::new_with_quota(
152 None,
153 vec![(Ustr::from(LIGHTER_TX_BUCKET), resolve_quota(sendtx_per_min))],
154 ))
155}
156
157/// Awaits transaction-bucket capacity before a `sendTx` on either transport.
158///
159/// Paces in the caller's task before the frame is enqueued, so neither the HTTP
160/// client nor the WebSocket feed-handler task sleeps mid-loop.
161pub async fn await_tx_quota(limiter: &LighterTxRateLimiter) {
162 limiter
163 .await_keys_ready(Some(&[Ustr::from(LIGHTER_TX_BUCKET)]))
164 .await;
165}
166
167#[cfg(test)]
168mod tests {
169 use rstest::rstest;
170
171 use super::*;
172
173 #[rstest]
174 fn test_resolve_quota_defaults_when_unset_or_zero() {
175 assert_eq!(resolve_quota(None), *LIGHTER_REST_QUOTA);
176 assert_eq!(resolve_quota(Some(0)), *LIGHTER_REST_QUOTA);
177 }
178
179 #[rstest]
180 fn test_resolve_quota_uses_override() {
181 let expected = Quota::per_minute(NonZeroU32::new(24_000).unwrap());
182 assert_eq!(resolve_quota(Some(24_000)), expected);
183 }
184
185 #[rstest]
186 fn test_build_tx_rate_limiter_handles_unset_zero_and_override() {
187 // Builds without panicking for unset/zero (NonZeroU32 guard) and keys on
188 // the tx bucket: a fresh limiter admits the first transaction.
189 let key = Ustr::from(LIGHTER_TX_BUCKET);
190
191 for sendtx in [None, Some(0), Some(4_000)] {
192 let limiter = build_tx_rate_limiter(sendtx);
193 assert!(limiter.check_key(&key).is_ok());
194 }
195 }
196
197 #[rstest]
198 fn test_ws_message_quota_matches_venue_caps() {
199 // Documented caps, not under-paced: the subscribe gate owns the inflight cap
200 assert_eq!(LIGHTER_WS_MESSAGE_RATE_PER_MIN, 200);
201 assert_eq!(LIGHTER_WS_MESSAGE_BURST, 50);
202 let expected = Quota::per_minute(NonZeroU32::new(LIGHTER_WS_MESSAGE_RATE_PER_MIN).unwrap())
203 .allow_burst(NonZeroU32::new(LIGHTER_WS_MESSAGE_BURST).unwrap());
204 assert_eq!(*LIGHTER_WS_MESSAGE_QUOTA, expected);
205 }
206
207 #[rstest]
208 fn test_ws_message_rate_limit_key_matches_bucket() {
209 assert_eq!(
210 LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY.as_slice(),
211 [Ustr::from(LIGHTER_WS_MESSAGE_BUCKET)].as_slice(),
212 );
213 }
214
215 #[rstest]
216 fn test_ws_message_rate_limiter_shared_per_url() {
217 let shared_a = ws_message_rate_limiter("wss://example.invalid/share");
218 let shared_b = ws_message_rate_limiter("wss://example.invalid/share");
219 let isolated = ws_message_rate_limiter("wss://example.invalid/isolated");
220
221 // Same URL: data, execution, and backend poller share one bucket.
222 assert!(Arc::ptr_eq(&shared_a, &shared_b));
223 // Distinct URL: testnet and custom endpoints stay isolated.
224 assert!(!Arc::ptr_eq(&shared_a, &isolated));
225 }
226
227 #[rstest]
228 fn test_ws_message_rate_limiter_enforces_inflight_burst() {
229 let limiter = RateLimiter::new_with_quota(
230 None,
231 vec![(
232 Ustr::from(LIGHTER_WS_MESSAGE_BUCKET),
233 *LIGHTER_WS_MESSAGE_QUOTA,
234 )],
235 );
236 let key = LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY[0];
237
238 for _ in 0..LIGHTER_WS_MESSAGE_BURST {
239 assert!(limiter.check_key(&key).is_ok());
240 }
241 assert!(limiter.check_key(&key).is_err());
242 }
243}