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},
48};
49
50use ahash::AHashMap;
51use nautilus_network::ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota};
52use parking_lot::Mutex;
53use ustr::Ustr;
54
55/// Conservative Lighter REST rate limit for standard accounts.
56///
57/// Lighter documents 60 REST requests per rolling minute for standard accounts. Builder and
58/// premium accounts can authenticate requests to get higher weighted limits.
59pub static LIGHTER_REST_QUOTA: LazyLock<Quota> =
60 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(60).expect("non-zero")));
61
62/// Rate-limit bucket key shared by all REST read endpoints.
63pub const LIGHTER_REST_BUCKET: &str = "lighter:rest";
64
65/// Rate-limit bucket key for the venue transaction bucket.
66///
67/// Lighter meters `sendTx` and `sendTxBatch` (HTTP) and the WebSocket `sendTx`
68/// path in one per-account bucket. Both transports share a
69/// [`LighterTxRateLimiter`] keyed on this so their combined rate stays under the
70/// single venue limit.
71pub const LIGHTER_TX_BUCKET: &str = "lighter:tx";
72
73/// Rate-limit bucket key for non-transaction WebSocket client messages.
74pub const LIGHTER_WS_MESSAGE_BUCKET: &str = "lighter:ws:messages";
75
76/// Lighter's documented WebSocket message rate: 200 per IP per minute.
77pub const LIGHTER_WS_MESSAGE_RATE_PER_MIN: u32 = 200;
78
79/// Rate-limiter burst, at Lighter's documented 50-message inflight cap. The
80/// closed-loop subscribe gate ([`crate::common::consts::SUBSCRIBE_INFLIGHT_MAX`])
81/// is the real inflight bound; this burst only shapes send rate.
82pub const LIGHTER_WS_MESSAGE_BURST: u32 = 50;
83
84/// Lighter WebSocket client-message quota, excluding `sendTx` / `sendTxBatch`.
85///
86/// Paces at the documented 200/min with a 50-message burst; the inflight cap is
87/// enforced separately by the subscribe gate (see the module docs).
88pub static LIGHTER_WS_MESSAGE_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
89 Quota::per_minute(NonZeroU32::new(LIGHTER_WS_MESSAGE_RATE_PER_MIN).expect("non-zero"))
90 .allow_burst(NonZeroU32::new(LIGHTER_WS_MESSAGE_BURST).expect("non-zero"))
91});
92
93/// Pre-interned rate-limit key for non-transaction WebSocket client messages.
94pub static LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY: LazyLock<[Ustr; 1]> =
95 LazyLock::new(|| [Ustr::from(LIGHTER_WS_MESSAGE_BUCKET)]);
96
97/// Per-account transaction rate limiter, shared across the HTTP and WebSocket
98/// `sendTx` paths so their combined rate honours the single venue bucket.
99pub type LighterTxRateLimiter = RateLimiter<Ustr, MonotonicClock>;
100
101/// Shared WebSocket message limiter, keyed by venue WS URL. Both data and
102/// execution clients (and the backend balance poller) draw from one bucket
103/// per URL so their combined send rate honours the venue's per-IP cap.
104pub type LighterWsMessageRateLimiter = Arc<RateLimiter<Ustr, MonotonicClock>>;
105
106// Process-global registry of Lighter WS message limiters, keyed by resolved
107// WS URL. Clients on the same URL (a network's data, execution, and backend
108// poller) share one bucket honouring the venue per-IP cap; distinct URLs
109// (testnet, or a custom endpoint in tests) stay isolated so unrelated traffic
110// never contends for the same tokens.
111static LIGHTER_WS_MESSAGE_LIMITERS: LazyLock<Mutex<AHashMap<String, LighterWsMessageRateLimiter>>> =
112 LazyLock::new(|| Mutex::new(AHashMap::new()));
113
114/// Returns the shared WS message limiter for `url`, creating it on first
115/// access. Subsequent calls with the same `url` return the same `Arc`.
116#[must_use]
117pub fn ws_message_rate_limiter(url: &str) -> LighterWsMessageRateLimiter {
118 LIGHTER_WS_MESSAGE_LIMITERS
119 .lock()
120 .entry(url.to_string())
121 .or_insert_with(|| {
122 Arc::new(RateLimiter::new_with_quota(
123 None,
124 vec![(
125 Ustr::from(LIGHTER_WS_MESSAGE_BUCKET),
126 *LIGHTER_WS_MESSAGE_QUOTA,
127 )],
128 ))
129 })
130 .clone()
131}
132
133/// Resolves a per-minute override to a quota, falling back to the conservative
134/// standard-account quota when unset or zero.
135#[must_use]
136pub fn resolve_quota(per_min: Option<u32>) -> Quota {
137 per_min
138 .and_then(NonZeroU32::new)
139 .map_or(*LIGHTER_REST_QUOTA, Quota::per_minute)
140}
141
142/// Builds the shared transaction limiter from a `sendtx_quota_per_min` override,
143/// keyed on [`LIGHTER_TX_BUCKET`]. Unset or zero falls back to the standard
144/// 60 req/min.
145#[must_use]
146pub fn build_tx_rate_limiter(sendtx_per_min: Option<u32>) -> Arc<LighterTxRateLimiter> {
147 Arc::new(RateLimiter::new_with_quota(
148 None,
149 vec![(Ustr::from(LIGHTER_TX_BUCKET), resolve_quota(sendtx_per_min))],
150 ))
151}
152
153/// Awaits transaction-bucket capacity before a `sendTx` on either transport.
154///
155/// Paces in the caller's task before the frame is enqueued, so neither the HTTP
156/// client nor the WebSocket feed-handler task sleeps mid-loop.
157pub async fn await_tx_quota(limiter: &LighterTxRateLimiter) {
158 limiter
159 .await_keys_ready(Some(&[Ustr::from(LIGHTER_TX_BUCKET)]))
160 .await;
161}
162
163#[cfg(test)]
164mod tests {
165 use rstest::rstest;
166
167 use super::*;
168
169 #[rstest]
170 fn test_resolve_quota_defaults_when_unset_or_zero() {
171 assert_eq!(resolve_quota(None), *LIGHTER_REST_QUOTA);
172 assert_eq!(resolve_quota(Some(0)), *LIGHTER_REST_QUOTA);
173 }
174
175 #[rstest]
176 fn test_resolve_quota_uses_override() {
177 let expected = Quota::per_minute(NonZeroU32::new(24_000).unwrap());
178 assert_eq!(resolve_quota(Some(24_000)), expected);
179 }
180
181 #[rstest]
182 fn test_build_tx_rate_limiter_handles_unset_zero_and_override() {
183 // Builds without panicking for unset/zero (NonZeroU32 guard) and keys on
184 // the tx bucket: a fresh limiter admits the first transaction.
185 let key = Ustr::from(LIGHTER_TX_BUCKET);
186
187 for sendtx in [None, Some(0), Some(4_000)] {
188 let limiter = build_tx_rate_limiter(sendtx);
189 assert!(limiter.check_key(&key).is_ok());
190 }
191 }
192
193 #[rstest]
194 fn test_ws_message_quota_matches_venue_caps() {
195 // Documented caps, not under-paced: the subscribe gate owns the inflight cap
196 assert_eq!(LIGHTER_WS_MESSAGE_RATE_PER_MIN, 200);
197 assert_eq!(LIGHTER_WS_MESSAGE_BURST, 50);
198 let expected = Quota::per_minute(NonZeroU32::new(LIGHTER_WS_MESSAGE_RATE_PER_MIN).unwrap())
199 .allow_burst(NonZeroU32::new(LIGHTER_WS_MESSAGE_BURST).unwrap());
200 assert_eq!(*LIGHTER_WS_MESSAGE_QUOTA, expected);
201 }
202
203 #[rstest]
204 fn test_ws_message_rate_limit_key_matches_bucket() {
205 assert_eq!(
206 LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY.as_slice(),
207 [Ustr::from(LIGHTER_WS_MESSAGE_BUCKET)].as_slice(),
208 );
209 }
210
211 #[rstest]
212 fn test_ws_message_rate_limiter_shared_per_url() {
213 let shared_a = ws_message_rate_limiter("wss://example.invalid/share");
214 let shared_b = ws_message_rate_limiter("wss://example.invalid/share");
215 let isolated = ws_message_rate_limiter("wss://example.invalid/isolated");
216
217 // Same URL: data, execution, and backend poller share one bucket.
218 assert!(Arc::ptr_eq(&shared_a, &shared_b));
219 // Distinct URL: testnet and custom endpoints stay isolated.
220 assert!(!Arc::ptr_eq(&shared_a, &isolated));
221 }
222
223 #[rstest]
224 fn test_ws_message_rate_limiter_enforces_inflight_burst() {
225 let limiter = RateLimiter::new_with_quota(
226 None,
227 vec![(
228 Ustr::from(LIGHTER_WS_MESSAGE_BUCKET),
229 *LIGHTER_WS_MESSAGE_QUOTA,
230 )],
231 );
232 let key = LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY[0];
233
234 for _ in 0..LIGHTER_WS_MESSAGE_BURST {
235 assert!(limiter.check_key(&key).is_ok());
236 }
237 assert!(limiter.check_key(&key).is_err());
238 }
239}