Skip to main content

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}