Skip to main content

pjson_rs/security/
rate_limit.rs

1//! Rate limiting system for WebSocket connections to prevent DoS attacks
2
3use dashmap::DashMap;
4use serde::{Deserialize, Serialize};
5use std::{
6    net::IpAddr,
7    sync::{
8        Arc,
9        atomic::{AtomicBool, Ordering},
10    },
11    time::{Duration, Instant},
12};
13use thiserror::Error;
14
15/// Hard upper bound on the number of distinct client IPs [`WebSocketRateLimiter`]
16/// tracks at once, independent of the periodic TTL-based [`WebSocketRateLimiter::cleanup_expired`]
17/// sweep. The sweep only runs every few minutes ([`DEFAULT_CLEANUP_INTERVAL`]) and
18/// cannot by itself prevent an in-window burst of distinct IPs from growing the
19/// map unboundedly between sweeps. Once at capacity, requests from IPs not
20/// already tracked are rejected with [`RateLimitError::CapacityExceeded`]
21/// rather than growing the map further; already-tracked IPs are unaffected.
22///
23/// **Reject-new, not evict-to-admit — a deliberate choice.** At capacity, a
24/// not-yet-tracked IP is turned away rather than evicting an arbitrary
25/// existing entry to make room. Evict-to-admit would let an attacker forge
26/// fresh IPs to repeatedly evict *established* clients' rate-limit state,
27/// letting them bypass their own accumulated request count — the opposite of
28/// what this limiter exists to prevent. Reject-new instead trades that for:
29/// under a sustained attack that fills the table faster than
30/// [`WebSocketRateLimiter::cleanup_expired`] can free idle entries, new
31/// clients are turned away (a `503`, see `RateLimitService::call` in
32/// `infrastructure::http::middleware`) until capacity frees up. This is
33/// considered the safer default — it protects already-established traffic at
34/// the cost of new-client admission under capacity pressure, rather than the
35/// reverse.
36pub const MAX_TRACKED_CLIENTS: usize = 100_000;
37
38/// Default interval between periodic [`WebSocketRateLimiter::cleanup_expired`]
39/// sweeps spawned by [`WebSocketRateLimiter::spawn_cleanup_task`].
40pub const DEFAULT_CLEANUP_INTERVAL: Duration = Duration::from_secs(300);
41
42/// Rate limiting errors
43#[derive(Error, Debug, Clone)]
44pub enum RateLimitError {
45    /// Request count exceeded the per-window limit.
46    #[error("Rate limit exceeded: {limit} requests per {window:?}")]
47    LimitExceeded {
48        /// Configured per-window request limit.
49        limit: u32,
50        /// Configured window duration.
51        window: Duration,
52    },
53
54    /// Per-IP concurrent connection cap was reached.
55    #[error("Connection limit exceeded: {current}/{max} connections")]
56    ConnectionLimitExceeded {
57        /// Current connection count for the IP.
58        current: usize,
59        /// Configured maximum number of connections per IP.
60        max: usize,
61    },
62
63    /// Frame larger than the configured maximum was rejected.
64    #[error("Frame size limit exceeded: {size} bytes > {max} bytes")]
65    FrameSizeExceeded {
66        /// Observed frame size in bytes.
67        size: usize,
68        /// Configured maximum frame size in bytes.
69        max: usize,
70    },
71
72    /// The limiter is already tracking [`MAX_TRACKED_CLIENTS`] distinct
73    /// clients; requests from not-yet-tracked clients are rejected until the
74    /// next cleanup sweep frees capacity.
75    #[error("Rate limiter at capacity: {max} tracked clients")]
76    CapacityExceeded {
77        /// Configured maximum number of tracked clients.
78        max: usize,
79    },
80}
81
82/// Rate limiting configuration
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RateLimitConfig {
85    /// Maximum requests per time window
86    pub max_requests_per_window: u32,
87    /// Time window for rate limiting
88    pub window_duration: Duration,
89    /// Maximum concurrent connections per IP
90    pub max_connections_per_ip: usize,
91    /// Maximum WebSocket frame size
92    pub max_frame_size: usize,
93    /// Maximum message rate (messages per second)
94    pub max_messages_per_second: u32,
95    /// Burst allowance (extra messages above rate)
96    pub burst_allowance: u32,
97    /// Deadline for a single outbound WebSocket sink write before the
98    /// connection is treated as stalled and closed.
99    ///
100    /// Guards against a peer that stops reading wedging the connection's
101    /// task indefinitely. Bounds a single write, not overall throughput —
102    /// see `infrastructure::websocket::WRITE_TIMEOUT`'s doc for the
103    /// tradeoff this implies for large frames sent to slow clients, and
104    /// raise this value if that tradeoff doesn't fit a deployment's
105    /// expected client bandwidth. Defaults to the same 10s value as
106    /// `infrastructure::websocket::WRITE_TIMEOUT`; the two constants are
107    /// independent (different feature gates), so an intentional change to
108    /// one should be mirrored in the other unless a divergence is
109    /// deliberate. [`Self::low_resource`] tightens this to 3s. This value
110    /// governs the server side only — `PjsWebSocketClient` has its own
111    /// independent write-timeout knob (see
112    /// `infrastructure::websocket::PjsWebSocketClient::with_write_timeout`),
113    /// also defaulting to `WRITE_TIMEOUT`.
114    pub write_timeout: Duration,
115}
116
117impl Default for RateLimitConfig {
118    fn default() -> Self {
119        Self {
120            max_requests_per_window: 100,
121            window_duration: Duration::from_secs(60),
122            max_connections_per_ip: 10,
123            max_frame_size: 1024 * 1024, // 1MB
124            max_messages_per_second: 30,
125            burst_allowance: 5,
126            write_timeout: Duration::from_secs(10),
127        }
128    }
129}
130
131impl RateLimitConfig {
132    /// Configuration for high-traffic scenarios
133    pub fn high_traffic() -> Self {
134        Self {
135            max_requests_per_window: 1000,
136            max_connections_per_ip: 50,
137            max_messages_per_second: 100,
138            burst_allowance: 20,
139            ..Default::default()
140        }
141    }
142
143    /// Configuration for low-resource environments
144    pub fn low_resource() -> Self {
145        Self {
146            max_requests_per_window: 20,
147            max_connections_per_ip: 2,
148            max_frame_size: 256 * 1024, // 256KB
149            max_messages_per_second: 5,
150            burst_allowance: 2,
151            // 3s (30% of the 10s default) — within this preset's range of
152            // reductions applied to its other fields (16.7%-40% of
153            // `Default`, though above their ~20-25% median). Freeing a
154            // wedged connection task matters more under resource
155            // constraints than absorbing ordinary network jitter.
156            //
157            // Note: this does not shrink what a single outbound write must
158            // flush in time. `max_frame_size` above bounds inbound frames
159            // only; outbound frame size is governed elsewhere and is
160            // unaffected by this preset. A slow-but-honest client now
161            // needs roughly 3.3x the downlink bandwidth it needed under
162            // the 10s default to avoid being disconnected as "stalled"
163            // (see `infrastructure::websocket::WRITE_TIMEOUT`'s doc for
164            // the full bandwidth-vs-deadline tradeoff) — raise this value
165            // if a low-resource deployment still expects to serve large
166            // frames to bandwidth-constrained clients.
167            write_timeout: Duration::from_secs(3),
168            ..Default::default()
169        }
170    }
171}
172
173/// Rate limit tracking for a specific client
174#[derive(Debug)]
175struct ClientRateLimit {
176    /// Request timestamps within current window
177    requests: Vec<Instant>,
178    /// Current connection count
179    connection_count: usize,
180    /// Token bucket for message rate limiting
181    tokens: f64,
182    /// Last token refill time
183    last_refill: Instant,
184}
185
186impl ClientRateLimit {
187    fn new(burst_allowance: u32) -> Self {
188        let now = Instant::now();
189        Self {
190            requests: Vec::new(),
191            connection_count: 0,
192            tokens: burst_allowance as f64, // Start with burst allowance tokens
193            last_refill: now,
194        }
195    }
196
197    /// Refill tokens based on time passed
198    fn refill_tokens(&mut self, config: &RateLimitConfig) {
199        let now = Instant::now();
200        let time_passed = now.duration_since(self.last_refill).as_secs_f64();
201
202        // Add tokens at configured rate
203        let tokens_to_add = time_passed * config.max_messages_per_second as f64;
204        let max_tokens = (config.max_messages_per_second + config.burst_allowance) as f64;
205
206        self.tokens = (self.tokens + tokens_to_add).min(max_tokens);
207        self.last_refill = now;
208    }
209
210    /// Check if message rate is within limits
211    fn check_message_rate(&mut self, config: &RateLimitConfig) -> Result<(), RateLimitError> {
212        self.refill_tokens(config);
213
214        if self.tokens >= 1.0 {
215            self.tokens -= 1.0;
216            Ok(())
217        } else {
218            Err(RateLimitError::LimitExceeded {
219                limit: config.max_messages_per_second,
220                window: Duration::from_secs(1),
221            })
222        }
223    }
224}
225
226/// Rate limiter for WebSocket connections
227#[derive(Debug)]
228pub struct WebSocketRateLimiter {
229    config: RateLimitConfig,
230    clients: Arc<DashMap<IpAddr, ClientRateLimit>>,
231    /// Guards [`WebSocketRateLimiter::spawn_cleanup_task`] so it spawns at
232    /// most one background task per limiter even if called repeatedly (e.g.
233    /// by several `RateLimitMiddleware`s sharing the same `Arc`).
234    ///
235    /// An `AtomicBool` rather than `std::sync::Once`: `Once` permanently
236    /// consumes its "run" on the first call regardless of what that call
237    /// does, so a first call outside a Tokio runtime would consume it and
238    /// silently prevent every later, in-runtime call from ever spawning.
239    /// This flag is only set `true` once a spawn actually succeeds; a failed
240    /// attempt (no runtime) rolls it back to `false` so a later call can
241    /// retry.
242    cleanup_spawned: AtomicBool,
243}
244
245impl Default for WebSocketRateLimiter {
246    fn default() -> Self {
247        Self::new(RateLimitConfig::default())
248    }
249}
250
251impl WebSocketRateLimiter {
252    /// Create new rate limiter with configuration
253    pub fn new(config: RateLimitConfig) -> Self {
254        Self {
255            config,
256            clients: Arc::new(DashMap::new()),
257            cleanup_spawned: AtomicBool::new(false),
258        }
259    }
260
261    /// Returns the rate-limit configuration this limiter was constructed with.
262    pub fn config(&self) -> &RateLimitConfig {
263        &self.config
264    }
265
266    /// Returns the number of requests still permitted for `ip` within the
267    /// current window: `max_requests_per_window` minus the number of request
268    /// timestamps currently recorded for that client that still fall inside
269    /// `window_duration`.
270    ///
271    /// An IP with no tracked state (never seen, or evicted by
272    /// [`Self::cleanup_expired`]) has its full quota remaining. Read-only —
273    /// unlike [`Self::check_request`], this never prunes `client.requests`
274    /// itself; it counts a window-filtered view without mutating state, using
275    /// the same `checked_sub`/fail-closed guard `check_request` uses (skip
276    /// filtering, i.e. count every tracked timestamp, rather than panicking
277    /// or under-counting on a host whose uptime is shorter than
278    /// `window_duration`). This reflects only the request-count window
279    /// backing `check_request`/`X-RateLimit-*` response headers — it says
280    /// nothing about the independent connection-count
281    /// ([`Self::check_connection`]) or message-rate ([`Self::check_message`])
282    /// limits.
283    pub fn remaining_for(&self, ip: IpAddr) -> u32 {
284        let Some(client) = self.clients.get(&ip) else {
285            return self.config.max_requests_per_window;
286        };
287
288        let now = Instant::now();
289        let window_start = now.checked_sub(self.config.window_duration);
290        let used = client
291            .requests
292            .iter()
293            .filter(|&&t| window_start.is_none_or(|start| t > start))
294            .count();
295
296        self.config
297            .max_requests_per_window
298            .saturating_sub(used as u32)
299    }
300
301    /// Returns the duration until `ip`'s rate-limit window next admits at
302    /// least one more request — i.e. until its oldest currently-counted
303    /// request timestamp ages out of `window_duration`. `Duration::ZERO` if
304    /// `ip` is untracked or has no request currently counted against it
305    /// (quota is already fully available, so there is nothing to wait for).
306    ///
307    /// `client.requests` is push-ordered ascending and `retain` (used by
308    /// [`Self::check_request`]) preserves that order, so the earliest entry
309    /// still inside the window is the next to expire and determines this
310    /// value — this is the real sliding-window reset instant, not an
311    /// approximation. Backs both the `X-RateLimit-Reset` response header and
312    /// the `Retry-After` hint on a `429` rejection.
313    ///
314    /// Fails closed like [`Self::remaining_for`]: on a host whose uptime is
315    /// shorter than `window_duration` (`now.checked_sub` underflows), this
316    /// returns the full `window_duration` rather than treating every
317    /// untrimmed timestamp as already-expired — the latter would report
318    /// `Duration::ZERO` (quota already fully available) for a client that
319    /// is, in reality, still within its window.
320    pub fn reset_after(&self, ip: IpAddr) -> Duration {
321        let Some(client) = self.clients.get(&ip) else {
322            return Duration::ZERO;
323        };
324
325        let now = Instant::now();
326        let Some(window_start) = now.checked_sub(self.config.window_duration) else {
327            return self.config.window_duration;
328        };
329        let earliest_active = client.requests.iter().find(|&&t| t > window_start);
330
331        match earliest_active {
332            Some(&earliest) => self
333                .config
334                .window_duration
335                .saturating_sub(now.saturating_duration_since(earliest)),
336            None => Duration::ZERO,
337        }
338    }
339
340    /// Spawn a background task that periodically calls [`Self::cleanup_expired`].
341    ///
342    /// Idempotent: calling this more than once on the same limiter (e.g. when
343    /// several `RateLimitMiddleware`s wrap the same shared `Arc`) spawns only
344    /// one task. Requires a Tokio runtime; if none is available, logs a
345    /// warning and returns without spawning rather than panicking, since
346    /// bare construction of this limiter (and its wrappers) must remain
347    /// usable from non-async contexts — a later call to this method (e.g.
348    /// once code has entered an async runtime) can still succeed.
349    ///
350    /// The task holds only a `Weak` reference to `self` and exits on its
351    /// own once every strong reference to the limiter is dropped, so it never
352    /// keeps the limiter (or its client map) alive past its last owner.
353    pub fn spawn_cleanup_task(self: &Arc<Self>, period: Duration) {
354        // Claim the right to spawn. If another call already claimed it
355        // (whether it succeeded or is in flight), this call is a no-op.
356        //
357        // Narrow window, not airtight: a concurrent in-runtime caller whose
358        // `swap` lands between a no-runtime caller's `swap(true)` above and
359        // its rollback `store(false)` below observes the claim as already
360        // taken and returns without spawning, even though it could have
361        // succeeded. No current call site constructs a limiter and races
362        // `spawn_cleanup_task` from both a runtime and a non-runtime thread
363        // concurrently, so this is intentionally left as a plain `swap`
364        // rather than a CAS retry loop; revisit if that changes.
365        if self.cleanup_spawned.swap(true, Ordering::AcqRel) {
366            return;
367        }
368
369        let Ok(handle) = tokio::runtime::Handle::try_current() else {
370            // Release the claim: no runtime was available, so nothing was
371            // actually spawned. A later call must be able to retry rather
372            // than finding cleanup permanently disabled.
373            self.cleanup_spawned.store(false, Ordering::Release);
374            tracing::warn!(
375                "WebSocketRateLimiter::spawn_cleanup_task: no Tokio runtime available; \
376                 periodic cleanup not started"
377            );
378            return;
379        };
380
381        let weak = Arc::downgrade(self);
382        handle.spawn(async move {
383            let mut interval = tokio::time::interval(period);
384            loop {
385                interval.tick().await;
386                let Some(limiter) = weak.upgrade() else {
387                    break;
388                };
389                limiter.cleanup_expired();
390                tracing::debug!("WebSocketRateLimiter: cleanup pass completed");
391            }
392        });
393    }
394
395    /// Whether a cleanup task spawn has been successfully claimed (test-only).
396    ///
397    /// Lets tests assert that a call site (e.g. `RateLimitMiddleware::new`/
398    /// `from_limiter`) actually wired up `spawn_cleanup_task` without waiting
399    /// for a real cleanup pass on the production [`DEFAULT_CLEANUP_INTERVAL`].
400    #[cfg(test)]
401    pub(crate) fn is_cleanup_task_spawned(&self) -> bool {
402        self.cleanup_spawned.load(Ordering::Acquire)
403    }
404
405    /// Check if request is allowed (HTTP upgrade to WebSocket)
406    pub fn check_request(&self, ip: IpAddr) -> Result<(), RateLimitError> {
407        if !self.clients.contains_key(&ip) && self.clients.len() >= MAX_TRACKED_CLIENTS {
408            return Err(RateLimitError::CapacityExceeded {
409                max: MAX_TRACKED_CLIENTS,
410            });
411        }
412
413        let now = Instant::now();
414        let burst = self.config.burst_allowance;
415        let mut client = self
416            .clients
417            .entry(ip)
418            .or_insert_with(|| ClientRateLimit::new(burst));
419
420        // `checked_sub` rather than a bare subtraction: on a host whose
421        // uptime is shorter than `window_duration` (observed to matter on
422        // Windows' QPC-backed `Instant`, which is in the CI matrix), the
423        // naive subtraction underflows and panics on the request hot path.
424        //
425        // On underflow, skip trimming this call rather than either
426        // panicking or wiping the client's history: wiping (falling back to
427        // an empty window) would fail *open* for exactly the client this
428        // control exists to stop — one already at or over its limit could
429        // bypass it entirely just by making one more request during this
430        // narrow condition (a freshly booted host, or a deliberately
431        // crashed-and-restarted service). Denying every request outright
432        // instead would fail closed correctly, but for *every* client,
433        // including ones that have never made a request before — no
434        // different from a hard outage for up to `window_duration` after
435        // every process start, on a host that happens to hit this edge
436        // case. Keeping the untrimmed history is the fail-closed choice
437        // that costs neither: an already-over-limit client's stale entries
438        // still count against it (a superset of the correctly windowed
439        // history is at least as likely to already be at/over the limit),
440        // while a client with no prior history is unaffected either way.
441        // The history transiently over-retains stale entries only for the
442        // (self-limiting) duration this condition holds; once real uptime
443        // exceeds `window_duration`, `checked_sub` succeeds again and
444        // trimming resumes, catching up on the backlog in one pass.
445        if let Some(window_start) = now.checked_sub(self.config.window_duration) {
446            client.requests.retain(|&time| time > window_start);
447        }
448
449        // Check request rate limit
450        if client.requests.len() >= self.config.max_requests_per_window as usize {
451            return Err(RateLimitError::LimitExceeded {
452                limit: self.config.max_requests_per_window,
453                window: self.config.window_duration,
454            });
455        }
456
457        // Add current request
458        client.requests.push(now);
459        Ok(())
460    }
461
462    /// Check if new connection is allowed
463    pub fn check_connection(&self, ip: IpAddr) -> Result<(), RateLimitError> {
464        if !self.clients.contains_key(&ip) && self.clients.len() >= MAX_TRACKED_CLIENTS {
465            return Err(RateLimitError::CapacityExceeded {
466                max: MAX_TRACKED_CLIENTS,
467            });
468        }
469
470        let burst = self.config.burst_allowance;
471        let mut client = self
472            .clients
473            .entry(ip)
474            .or_insert_with(|| ClientRateLimit::new(burst));
475
476        if client.connection_count >= self.config.max_connections_per_ip {
477            return Err(RateLimitError::ConnectionLimitExceeded {
478                current: client.connection_count,
479                max: self.config.max_connections_per_ip,
480            });
481        }
482
483        client.connection_count += 1;
484        Ok(())
485    }
486
487    /// Register connection close
488    pub fn close_connection(&self, ip: IpAddr) {
489        if let Some(mut client) = self.clients.get_mut(&ip) {
490            client.connection_count = client.connection_count.saturating_sub(1);
491        }
492    }
493
494    /// Check if WebSocket message is allowed
495    pub fn check_message(&self, ip: IpAddr, frame_size: usize) -> Result<(), RateLimitError> {
496        // Check frame size
497        if frame_size > self.config.max_frame_size {
498            return Err(RateLimitError::FrameSizeExceeded {
499                size: frame_size,
500                max: self.config.max_frame_size,
501            });
502        }
503
504        // Check message rate
505        if let Some(mut client) = self.clients.get_mut(&ip) {
506            client.check_message_rate(&self.config)?;
507        }
508
509        Ok(())
510    }
511
512    /// Get current statistics for monitoring
513    pub fn stats(&self) -> RateLimitStats {
514        let mut stats = RateLimitStats::default();
515
516        for entry in self.clients.iter() {
517            stats.total_clients += 1;
518            stats.total_connections += entry.value().connection_count;
519
520            if entry.value().connection_count > 0 {
521                stats.active_clients += 1;
522            }
523        }
524
525        stats
526    }
527
528    /// Clean up expired entries (call periodically)
529    pub fn cleanup_expired(&self) {
530        let now = Instant::now();
531        // `checked_sub` rather than a bare subtraction: on a host whose
532        // uptime is shorter than `window_duration * 2` (fresh container, or
533        // a large configured window), the naive subtraction underflows
534        // `Instant` and panics, permanently killing whichever loop calls
535        // this. Skip this pass instead — the next sweep, once enough
536        // wall-clock time has elapsed, will succeed.
537        let Some(cutoff) = now.checked_sub(self.config.window_duration * 2) else {
538            return;
539        };
540
541        self.clients.retain(|_, client| {
542            // Remove clients with no recent activity and no connections
543            !(client.connection_count == 0
544                && client.requests.last().is_none_or(|&time| time < cutoff))
545        });
546    }
547}
548
549/// Rate limiting statistics
550#[derive(Debug, Default, Clone)]
551pub struct RateLimitStats {
552    /// Total distinct clients tracked.
553    pub total_clients: usize,
554    /// Clients that have shown activity within the recent window.
555    pub active_clients: usize,
556    /// Sum of currently held connections across all clients.
557    pub total_connections: usize,
558}
559
560/// Rate limiting middleware for tracking client IPs
561///
562/// Deliberately not `Clone`: [`Drop`] decrements a connection counter, so a
563/// clone would silently over-decrement it. Existing callers that need to
564/// share ownership wrap this in `Arc` instead (see
565/// `infrastructure::websocket::server`).
566#[derive(Debug)]
567pub struct RateLimitGuard {
568    rate_limiter: Arc<WebSocketRateLimiter>,
569    client_ip: IpAddr,
570}
571
572impl RateLimitGuard {
573    /// Create new guard for a client connection
574    pub fn new(
575        rate_limiter: Arc<WebSocketRateLimiter>,
576        client_ip: IpAddr,
577    ) -> Result<Self, RateLimitError> {
578        rate_limiter.check_connection(client_ip)?;
579
580        Ok(Self {
581            rate_limiter,
582            client_ip,
583        })
584    }
585
586    /// Check if message is allowed
587    pub fn check_message(&self, frame_size: usize) -> Result<(), RateLimitError> {
588        self.rate_limiter.check_message(self.client_ip, frame_size)
589    }
590}
591
592impl Drop for RateLimitGuard {
593    fn drop(&mut self) {
594        self.rate_limiter.close_connection(self.client_ip);
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use std::net::Ipv4Addr;
602    use std::thread;
603    use std::time::Duration;
604
605    #[test]
606    fn test_rate_limit_requests() {
607        let config = RateLimitConfig {
608            max_requests_per_window: 2,
609            window_duration: Duration::from_millis(100),
610            ..Default::default()
611        };
612
613        let limiter = WebSocketRateLimiter::new(config);
614        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
615
616        // First two requests should succeed
617        assert!(limiter.check_request(ip).is_ok());
618        assert!(limiter.check_request(ip).is_ok());
619
620        // Third request should be rate limited
621        assert!(limiter.check_request(ip).is_err());
622
623        // Wait for window to reset
624        thread::sleep(Duration::from_millis(110));
625
626        // Should work again
627        assert!(limiter.check_request(ip).is_ok());
628    }
629
630    #[test]
631    fn test_connection_limits() {
632        let config = RateLimitConfig {
633            max_connections_per_ip: 2,
634            ..Default::default()
635        };
636
637        let limiter = WebSocketRateLimiter::new(config);
638        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
639
640        // Two connections should succeed
641        assert!(limiter.check_connection(ip).is_ok());
642        assert!(limiter.check_connection(ip).is_ok());
643
644        // Third connection should fail
645        assert!(limiter.check_connection(ip).is_err());
646
647        // Close one connection
648        limiter.close_connection(ip);
649
650        // Should work again
651        assert!(limiter.check_connection(ip).is_ok());
652    }
653
654    #[test]
655    fn test_message_rate_limiting() {
656        let config = RateLimitConfig {
657            max_messages_per_second: 2,
658            burst_allowance: 2, // Allow 2 burst messages
659            ..Default::default()
660        };
661
662        let limiter = WebSocketRateLimiter::new(config.clone());
663        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
664
665        // First connection should create the client entry
666        let client = limiter
667            .clients
668            .entry(ip)
669            .or_insert_with(|| ClientRateLimit::new(config.burst_allowance));
670        // Tokens are already initialized with burst_allowance
671        drop(client);
672
673        // Should allow burst messages
674        assert!(limiter.check_message(ip, 1024).is_ok());
675        assert!(limiter.check_message(ip, 1024).is_ok());
676
677        // Should be rate limited now (no more tokens)
678        assert!(limiter.check_message(ip, 1024).is_err());
679    }
680
681    #[test]
682    fn test_frame_size_limits() {
683        let config = RateLimitConfig {
684            max_frame_size: 1024,
685            ..Default::default()
686        };
687
688        let limiter = WebSocketRateLimiter::new(config);
689        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
690
691        // Small frame should succeed
692        assert!(limiter.check_message(ip, 512).is_ok());
693
694        // Large frame should fail
695        assert!(limiter.check_message(ip, 2048).is_err());
696    }
697
698    #[test]
699    fn test_rate_limit_guard() {
700        let config = RateLimitConfig {
701            max_connections_per_ip: 1,
702            ..Default::default()
703        };
704
705        let limiter = Arc::new(WebSocketRateLimiter::new(config));
706        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
707
708        // Create guard
709        let guard = RateLimitGuard::new(limiter.clone(), ip).unwrap();
710
711        // Second connection should fail
712        assert!(RateLimitGuard::new(limiter.clone(), ip).is_err());
713
714        // Drop guard
715        drop(guard);
716
717        // Should work again
718        assert!(RateLimitGuard::new(limiter, ip).is_ok());
719    }
720
721    #[test]
722    fn test_token_refill_over_time() {
723        let config = RateLimitConfig {
724            max_messages_per_second: 1,
725            burst_allowance: 0,
726            window_duration: Duration::from_millis(100),
727            ..Default::default()
728        };
729
730        let limiter = WebSocketRateLimiter::new(config.clone());
731        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
732
733        // Pre-fill tokens to test refill
734        {
735            let mut client = limiter
736                .clients
737                .entry(ip)
738                .or_insert_with(|| ClientRateLimit::new(config.burst_allowance));
739            client.tokens = 0.5; // Start with partial token
740        }
741
742        // Should fail with insufficient tokens
743        assert!(limiter.check_message(ip, 512).is_err());
744
745        // Wait for token refill (1 second = max_messages_per_second tokens)
746        thread::sleep(Duration::from_millis(1100));
747
748        // Should work again after tokens refill (refilled tokens + remaining time)
749        let result = limiter.check_message(ip, 512);
750        // After 1.1 seconds, should have refilled enough tokens to pass
751        assert!(result.is_ok(), "Expected refilled tokens to allow message");
752    }
753
754    #[test]
755    fn test_cleanup_expired_entries() {
756        let config = RateLimitConfig {
757            window_duration: Duration::from_millis(100),
758            ..Default::default()
759        };
760
761        let limiter = WebSocketRateLimiter::new(config);
762        let ip1 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
763        let ip2 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2));
764
765        // Add some client entries
766        assert!(limiter.check_connection(ip1).is_ok());
767        assert!(limiter.check_connection(ip2).is_ok());
768
769        // Should have 2 clients
770        assert_eq!(limiter.stats().total_clients, 2);
771
772        // Close connection for ip1
773        limiter.close_connection(ip1);
774
775        // Wait beyond the cleanup window
776        thread::sleep(Duration::from_millis(250));
777
778        // Cleanup should remove idle clients
779        limiter.cleanup_expired();
780
781        // After cleanup, ip1 should be removed but ip2 might remain if it has recent activity
782        let stats = limiter.stats();
783        // At minimum, ip1 should be cleaned up if no connections
784        assert!(stats.total_clients <= 2);
785    }
786
787    #[test]
788    fn test_multiple_ips_isolation() {
789        let config = RateLimitConfig {
790            max_requests_per_window: 1,
791            window_duration: Duration::from_millis(100),
792            ..Default::default()
793        };
794
795        let limiter = WebSocketRateLimiter::new(config);
796        let ip1 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
797        let ip2 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2));
798
799        // ip1 should be rate limited after 1 request
800        assert!(limiter.check_request(ip1).is_ok());
801        assert!(limiter.check_request(ip1).is_err());
802
803        // ip2 should NOT be affected by ip1's limit
804        assert!(limiter.check_request(ip2).is_ok());
805        assert!(limiter.check_request(ip2).is_err());
806    }
807
808    #[test]
809    fn test_burst_allowance_boundary() {
810        let config = RateLimitConfig {
811            max_messages_per_second: 1,
812            burst_allowance: 0,
813            ..Default::default()
814        };
815
816        let limiter = WebSocketRateLimiter::new(config.clone());
817        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
818
819        // With 0 burst, even the first message might be throttled
820        // depending on token distribution
821        let mut client = limiter
822            .clients
823            .entry(ip)
824            .or_insert_with(|| ClientRateLimit::new(config.burst_allowance));
825        client.tokens = 0.0;
826        drop(client);
827
828        // Should fail with no tokens
829        assert!(limiter.check_message(ip, 512).is_err());
830    }
831
832    #[test]
833    fn test_rate_limit_config_high_traffic() {
834        let config = RateLimitConfig::high_traffic();
835
836        assert_eq!(config.max_requests_per_window, 1000);
837        assert_eq!(config.max_connections_per_ip, 50);
838        assert_eq!(config.max_messages_per_second, 100);
839        assert_eq!(config.burst_allowance, 20);
840        assert!(config.max_frame_size >= 1024 * 1024);
841    }
842
843    #[test]
844    fn test_rate_limit_config_low_resource() {
845        let config = RateLimitConfig::low_resource();
846
847        assert_eq!(config.max_requests_per_window, 20);
848        assert_eq!(config.max_connections_per_ip, 2);
849        assert_eq!(config.max_messages_per_second, 5);
850        assert_eq!(config.burst_allowance, 2);
851        assert_eq!(config.max_frame_size, 256 * 1024);
852        assert_eq!(config.write_timeout, Duration::from_secs(3));
853        assert!(config.write_timeout < RateLimitConfig::default().write_timeout);
854    }
855
856    #[test]
857    fn test_frame_size_boundary_exact() {
858        let config = RateLimitConfig {
859            max_frame_size: 1024,
860            ..Default::default()
861        };
862
863        let limiter = WebSocketRateLimiter::new(config);
864        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
865
866        // Exactly at limit should succeed
867        assert!(limiter.check_message(ip, 1024).is_ok());
868
869        // Just over limit should fail
870        assert!(limiter.check_message(ip, 1025).is_err());
871
872        // Zero-size frame should succeed (though uncommon)
873        assert!(limiter.check_message(ip, 0).is_ok());
874    }
875
876    #[test]
877    fn test_stats_accuracy() {
878        let config = RateLimitConfig {
879            max_connections_per_ip: 5,
880            ..Default::default()
881        };
882
883        let limiter = WebSocketRateLimiter::new(config);
884        let ip1 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
885        let ip2 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2));
886
887        // Add connections
888        assert!(limiter.check_connection(ip1).is_ok());
889        assert!(limiter.check_connection(ip1).is_ok());
890        assert!(limiter.check_connection(ip2).is_ok());
891
892        let stats = limiter.stats();
893        assert_eq!(stats.total_clients, 2);
894        assert_eq!(stats.total_connections, 3);
895        assert_eq!(stats.active_clients, 2);
896
897        // Close a connection
898        limiter.close_connection(ip1);
899
900        let stats = limiter.stats();
901        assert_eq!(stats.total_connections, 2);
902    }
903
904    #[test]
905    fn test_window_duration_respected() {
906        let config = RateLimitConfig {
907            max_requests_per_window: 1,
908            window_duration: Duration::from_millis(50),
909            ..Default::default()
910        };
911
912        let limiter = WebSocketRateLimiter::new(config);
913        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
914
915        // First request succeeds
916        assert!(limiter.check_request(ip).is_ok());
917
918        // Second request within window fails
919        assert!(limiter.check_request(ip).is_err());
920
921        // Wait for window to pass
922        thread::sleep(Duration::from_millis(60));
923
924        // Request after window passes succeeds
925        assert!(limiter.check_request(ip).is_ok());
926    }
927
928    #[test]
929    fn test_default_limiter() {
930        // Test Default implementation for WebSocketRateLimiter
931        let limiter = WebSocketRateLimiter::default();
932        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
933
934        // Default limiter should allow requests
935        assert!(limiter.check_request(ip).is_ok());
936        assert!(limiter.check_connection(ip).is_ok());
937
938        // Verify default config values are applied
939        let stats = limiter.stats();
940        assert_eq!(stats.total_clients, 1);
941        assert_eq!(stats.total_connections, 1);
942    }
943
944    #[test]
945    fn test_cleanup_expired_removes_inactive_clients() {
946        let config = RateLimitConfig {
947            window_duration: Duration::from_millis(50),
948            ..Default::default()
949        };
950
951        let limiter = WebSocketRateLimiter::new(config);
952        let ip1 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
953        let ip2 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2));
954        let ip3 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 3));
955
956        // Add requests for multiple IPs
957        assert!(limiter.check_request(ip1).is_ok());
958        assert!(limiter.check_request(ip2).is_ok());
959        assert!(limiter.check_connection(ip3).is_ok());
960
961        let initial_stats = limiter.stats();
962        assert_eq!(initial_stats.total_clients, 3);
963
964        // Wait for cleanup window
965        thread::sleep(Duration::from_millis(150));
966
967        // ip3 has no requests, so it should be removed
968        limiter.cleanup_expired();
969
970        let after_cleanup = limiter.stats();
971        // ip3 should be removed (no requests, no connections after cleanup)
972        assert!(after_cleanup.total_clients <= initial_stats.total_clients);
973    }
974
975    #[test]
976    fn test_client_with_zero_connections_and_no_recent_requests_cleaned() {
977        let config = RateLimitConfig {
978            window_duration: Duration::from_millis(100),
979            ..Default::default()
980        };
981
982        let limiter = WebSocketRateLimiter::new(config);
983        let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100));
984
985        // Make a request
986        assert!(limiter.check_request(ip).is_ok());
987
988        // Verify client exists
989        let initial_stats = limiter.stats();
990        assert_eq!(initial_stats.total_clients, 1);
991
992        // Wait beyond cleanup window (2x window_duration)
993        thread::sleep(Duration::from_millis(250));
994
995        // Cleanup should remove the client (no connections and stale requests)
996        limiter.cleanup_expired();
997
998        let final_stats = limiter.stats();
999        // The client should be removed if no active connections
1000        assert_eq!(final_stats.total_clients, 0);
1001    }
1002
1003    #[test]
1004    fn test_cleanup_preserves_active_clients() {
1005        let config = RateLimitConfig {
1006            window_duration: Duration::from_millis(100),
1007            ..Default::default()
1008        };
1009
1010        let limiter = WebSocketRateLimiter::new(config);
1011        let ip1 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
1012        let ip2 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2));
1013
1014        // ip1: has active connection
1015        assert!(limiter.check_connection(ip1).is_ok());
1016
1017        // ip2: has recent request but no connection
1018        assert!(limiter.check_request(ip2).is_ok());
1019
1020        let initial_stats = limiter.stats();
1021        assert_eq!(initial_stats.total_clients, 2);
1022
1023        // Wait some time (but not beyond full cleanup window)
1024        thread::sleep(Duration::from_millis(80));
1025
1026        // Make another request to ip2 to keep it fresh
1027        let _ = limiter.check_request(ip2);
1028
1029        // Cleanup should preserve both clients
1030        limiter.cleanup_expired();
1031
1032        let final_stats = limiter.stats();
1033        // ip1 should be preserved (active connection)
1034        assert!(final_stats.total_clients >= 1);
1035    }
1036
1037    #[test]
1038    fn test_close_connection_on_nonexistent_ip() {
1039        let limiter = WebSocketRateLimiter::default();
1040        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 99));
1041
1042        // Closing connection on non-existent IP should not panic
1043        limiter.close_connection(ip);
1044
1045        // Stats should be empty
1046        let stats = limiter.stats();
1047        assert_eq!(stats.total_clients, 0);
1048    }
1049
1050    #[test]
1051    fn test_check_message_on_nonexistent_client() {
1052        let limiter = WebSocketRateLimiter::default();
1053        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 88));
1054
1055        // Checking message on non-existent IP should be OK for frame size
1056        // but not create the client entry if it doesn't exist in clients map
1057        assert!(limiter.check_message(ip, 512).is_ok());
1058    }
1059
1060    #[test]
1061    fn test_rate_limit_guard_check_message() {
1062        let config = RateLimitConfig {
1063            max_connections_per_ip: 5,
1064            max_frame_size: 1024,
1065            max_messages_per_second: 10,
1066            burst_allowance: 5,
1067            ..Default::default()
1068        };
1069
1070        let limiter = Arc::new(WebSocketRateLimiter::new(config));
1071        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1072
1073        let guard = RateLimitGuard::new(limiter.clone(), ip).unwrap();
1074
1075        assert!(guard.check_message(512).is_ok());
1076        assert!(guard.check_message(512).is_ok());
1077        assert!(guard.check_message(2048).is_err());
1078    }
1079
1080    #[test]
1081    fn test_rate_limit_guard_check_message_rate_limit() {
1082        let config = RateLimitConfig {
1083            max_connections_per_ip: 5,
1084            max_frame_size: 10_000,
1085            max_messages_per_second: 2,
1086            burst_allowance: 2,
1087            ..Default::default()
1088        };
1089
1090        let limiter = Arc::new(WebSocketRateLimiter::new(config));
1091        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2));
1092
1093        let guard = RateLimitGuard::new(limiter.clone(), ip).unwrap();
1094
1095        assert!(guard.check_message(512).is_ok());
1096        assert!(guard.check_message(512).is_ok());
1097        assert!(guard.check_message(512).is_err());
1098    }
1099
1100    #[test]
1101    fn test_capacity_cap_rejects_new_clients_when_full() {
1102        let limiter = WebSocketRateLimiter::default();
1103
1104        for i in 0..MAX_TRACKED_CLIENTS as u32 {
1105            let ip = IpAddr::V4(Ipv4Addr::from(i));
1106            limiter.check_request(ip).unwrap();
1107        }
1108        assert_eq!(limiter.stats().total_clients, MAX_TRACKED_CLIENTS);
1109
1110        // A new, not-yet-tracked IP is rejected once at capacity — this is
1111        // what bounds the map's size *within* a single cleanup sweep window,
1112        // not just across sweeps.
1113        let overflow_ip = IpAddr::V4(Ipv4Addr::from(MAX_TRACKED_CLIENTS as u32));
1114        let result = limiter.check_request(overflow_ip);
1115        assert!(matches!(
1116            result,
1117            Err(RateLimitError::CapacityExceeded { max }) if max == MAX_TRACKED_CLIENTS
1118        ));
1119        assert_eq!(limiter.stats().total_clients, MAX_TRACKED_CLIENTS);
1120
1121        // An already-tracked IP is unaffected by the cap.
1122        let existing_ip = IpAddr::V4(Ipv4Addr::from(0u32));
1123        assert!(limiter.check_request(existing_ip).is_ok());
1124    }
1125
1126    #[test]
1127    fn test_cleanup_expired_never_panics_regardless_of_window_duration() {
1128        // `Instant` intentionally exposes no public constructor for an
1129        // arbitrary point in time, so whether `Instant::now().checked_sub(..)`
1130        // actually underflows for a given `window_duration` depends on the
1131        // OS's monotonic clock epoch, which is unspecified and cannot be
1132        // forced deterministically from a portable unit test (observed to
1133        // matter in practice on Windows' QPC-backed `Instant` near process
1134        // or host start — exercised naturally by the Windows CI legs, not by
1135        // this test). What this test pins down instead is the invariant that
1136        // actually matters regardless of which branch runs on a given host:
1137        // `cleanup_expired` must never panic for any configured
1138        // `window_duration`, including ones designed to underflow, and must
1139        // never evict a client with a request timestamped just now.
1140        for window_secs in [1, 60, 3600, u64::MAX / 8] {
1141            let config = RateLimitConfig {
1142                window_duration: Duration::from_secs(window_secs),
1143                ..Default::default()
1144            };
1145            let limiter = WebSocketRateLimiter::new(config);
1146            let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1147            limiter.check_request(ip).unwrap();
1148
1149            limiter.cleanup_expired(); // Must not panic for any window_secs above.
1150
1151            assert_eq!(
1152                limiter.stats().total_clients,
1153                1,
1154                "a client with a just-now request must survive cleanup regardless \
1155                 of window_secs={window_secs}"
1156            );
1157        }
1158    }
1159
1160    #[test]
1161    fn test_check_request_never_panics_regardless_of_window_duration() {
1162        // Same rationale as the `cleanup_expired` test above, but for the
1163        // `checked_sub` guard on the request hot path in `check_request`,
1164        // and its two read-only siblings `remaining_for`/`reset_after`
1165        // (which share the same guard). Whether `checked_sub` actually
1166        // underflows for a given window_secs is platform-dependent (see
1167        // `test_cleanup_expired_never_panics_...` above) — this test only
1168        // pins down that none of the three ever panics regardless of which
1169        // branch runs, including with a `window_duration` near `u64::MAX`
1170        // seconds (reachable, since `RateLimitConfig` is `Deserialize` with
1171        // an all-`pub` `window_duration: Duration` field).
1172        for window_secs in [1, 60, 3600, u64::MAX / 8] {
1173            let config = RateLimitConfig {
1174                window_duration: Duration::from_secs(window_secs),
1175                ..Default::default()
1176            };
1177            let limiter = WebSocketRateLimiter::new(config);
1178            let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1179
1180            let _ = limiter.check_request(ip); // Must not panic for any window_secs above.
1181            let _ = limiter.remaining_for(ip);
1182            let _ = limiter.reset_after(ip);
1183        }
1184    }
1185
1186    #[test]
1187    fn test_remaining_for_fresh_ip_returns_full_quota() {
1188        let config = RateLimitConfig {
1189            max_requests_per_window: 10,
1190            ..Default::default()
1191        };
1192        let limiter = WebSocketRateLimiter::new(config);
1193        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1194
1195        // Never-seen IP has its full quota remaining.
1196        assert_eq!(limiter.remaining_for(ip), 10);
1197    }
1198
1199    #[test]
1200    fn test_remaining_for_decreases_with_consumed_requests() {
1201        let config = RateLimitConfig {
1202            max_requests_per_window: 5,
1203            window_duration: Duration::from_secs(60),
1204            ..Default::default()
1205        };
1206        let limiter = WebSocketRateLimiter::new(config);
1207        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1208
1209        assert_eq!(limiter.remaining_for(ip), 5);
1210
1211        limiter.check_request(ip).unwrap();
1212        assert_eq!(limiter.remaining_for(ip), 4);
1213
1214        limiter.check_request(ip).unwrap();
1215        limiter.check_request(ip).unwrap();
1216        assert_eq!(limiter.remaining_for(ip), 2);
1217    }
1218
1219    #[test]
1220    fn test_remaining_for_saturates_at_zero_when_quota_exhausted() {
1221        let config = RateLimitConfig {
1222            max_requests_per_window: 2,
1223            window_duration: Duration::from_secs(60),
1224            ..Default::default()
1225        };
1226        let limiter = WebSocketRateLimiter::new(config);
1227        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1228
1229        // Exhaust the quota; the second call succeeds, the third is rejected
1230        // and must not record an extra timestamp.
1231        assert!(limiter.check_request(ip).is_ok());
1232        assert!(limiter.check_request(ip).is_ok());
1233        assert!(limiter.check_request(ip).is_err());
1234
1235        // `saturating_sub` must not underflow even if usage ever exceeded
1236        // the configured limit.
1237        assert_eq!(limiter.remaining_for(ip), 0);
1238    }
1239
1240    #[test]
1241    fn test_remaining_for_isolated_per_ip() {
1242        let config = RateLimitConfig {
1243            max_requests_per_window: 3,
1244            window_duration: Duration::from_secs(60),
1245            ..Default::default()
1246        };
1247        let limiter = WebSocketRateLimiter::new(config);
1248        let ip1 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1249        let ip2 = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2));
1250
1251        limiter.check_request(ip1).unwrap();
1252        limiter.check_request(ip1).unwrap();
1253
1254        assert_eq!(limiter.remaining_for(ip1), 1);
1255        assert_eq!(limiter.remaining_for(ip2), 3);
1256    }
1257
1258    #[test]
1259    fn test_remaining_for_prunes_expired_requests_like_check_request() {
1260        // Deterministic counterexample for the gap `remaining_for` used to
1261        // have: without window pruning, 5 requests at t=0 under a 500ms
1262        // window would still read as 0 remaining at t=1000ms, even though
1263        // `check_request` would freely admit all 5 again by then. Timing
1264        // margins are kept generous (5x an earlier, tighter version) to stay
1265        // well clear of OS timer granularity / nextest parallelism jitter.
1266        let config = RateLimitConfig {
1267            max_requests_per_window: 5,
1268            window_duration: Duration::from_millis(500),
1269            ..Default::default()
1270        };
1271        let limiter = WebSocketRateLimiter::new(config);
1272        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1273
1274        for _ in 0..5 {
1275            limiter.check_request(ip).unwrap();
1276        }
1277        assert_eq!(limiter.remaining_for(ip), 0);
1278
1279        thread::sleep(Duration::from_millis(1000));
1280
1281        assert_eq!(limiter.remaining_for(ip), 5);
1282        assert!(limiter.check_request(ip).is_ok());
1283    }
1284
1285    #[test]
1286    fn test_reset_after_untracked_ip_is_zero() {
1287        let limiter = WebSocketRateLimiter::default();
1288        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1289
1290        assert_eq!(limiter.reset_after(ip), Duration::ZERO);
1291    }
1292
1293    #[test]
1294    fn test_reset_after_reflects_oldest_active_request() {
1295        // Timing margins scaled up (5x an earlier, tighter version) to stay
1296        // well clear of OS timer granularity / nextest parallelism jitter.
1297        let config = RateLimitConfig {
1298            max_requests_per_window: 5,
1299            window_duration: Duration::from_millis(1000),
1300            ..Default::default()
1301        };
1302        let limiter = WebSocketRateLimiter::new(config);
1303        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
1304
1305        limiter.check_request(ip).unwrap();
1306        let just_after = limiter.reset_after(ip);
1307        // Just after the request, almost the entire window remains.
1308        assert!(just_after > Duration::from_millis(750));
1309        assert!(just_after <= Duration::from_millis(1000));
1310
1311        thread::sleep(Duration::from_millis(600));
1312        let later = limiter.reset_after(ip);
1313        // The wait has shrunk by roughly the elapsed sleep.
1314        assert!(later < just_after);
1315        assert!(later <= Duration::from_millis(400));
1316
1317        thread::sleep(Duration::from_millis(1000));
1318        // The oldest (only) request has now aged out of the window.
1319        assert_eq!(limiter.reset_after(ip), Duration::ZERO);
1320    }
1321
1322    #[tokio::test]
1323    async fn test_spawn_cleanup_task_is_idempotent() {
1324        let limiter = Arc::new(WebSocketRateLimiter::new(RateLimitConfig {
1325            window_duration: Duration::from_millis(1),
1326            ..Default::default()
1327        }));
1328
1329        // Calling this more than once must not spawn a second task (and must
1330        // not panic); the loop below only passes if exactly the expected
1331        // single cleanup pass took effect.
1332        limiter.spawn_cleanup_task(Duration::from_millis(10));
1333        limiter.spawn_cleanup_task(Duration::from_millis(10));
1334
1335        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
1336        limiter.check_request(ip).unwrap();
1337
1338        tokio::time::sleep(Duration::from_millis(100)).await;
1339
1340        assert_eq!(limiter.stats().total_clients, 0);
1341    }
1342}