Skip to main content

celers_core/
rate_limit_distributed.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_sign_loss,
4    clippy::cast_precision_loss,
5    clippy::cast_possible_wrap
6)]
7//! Distributed rate limiting across workers.
8//!
9//! This module provides a *pluggable backend* abstraction for enforcing rate
10//! limits cluster-wide rather than per-worker. The key idea is a shared
11//! token / counter store, keyed by limiter name, that performs the
12//! token-bucket and sliding-window arithmetic **atomically** so that
13//! concurrent workers cannot collectively exceed the configured rate.
14//!
15//! It is designed to complement (not replace) the in-process limiters in
16//! [`crate::rate_limit`]:
17//!
18//! - [`DistributedRateLimitBackend`] is the storage trait. The in-memory
19//!   implementation here uses a [`tokio::sync::Mutex`]-guarded map so that the
20//!   whole acquire/refill cycle for a key is a single critical section, which
21//!   makes it fully testable in-process. A Redis backend can implement the
22//!   same trait by translating each method into an atomic Lua script / `MULTI`
23//!   transaction (the scripts already shipped in [`crate::rate_limit`] map
24//!   directly onto these operations).
25//! - [`DistributedRateLimiter`] is a thin, ergonomic handle that pairs a
26//!   backend with a limiter key and a [`RateLimitConfig`]. It reuses the
27//!   existing [`RateLimitConfig`] type and the same token-bucket /
28//!   sliding-window *algorithms* defined in [`crate::rate_limit`].
29//!
30//! # Example
31//!
32//! ```rust
33//! use celers_core::rate_limit::RateLimitConfig;
34//! use celers_core::rate_limit_distributed::{
35//!     DistributedRateLimiter, InMemoryDistributedBackend,
36//! };
37//! use std::sync::Arc;
38//!
39//! # async fn example() -> celers_core::Result<()> {
40//! // A token bucket allowing a burst of 5, refilling at 10/sec, shared cluster-wide.
41//! let backend = Arc::new(InMemoryDistributedBackend::new());
42//! let config = RateLimitConfig::new(10.0).with_burst(5);
43//! let limiter = DistributedRateLimiter::new(backend, "send_email", config);
44//!
45//! // First 5 acquisitions succeed (burst), the 6th is denied.
46//! for _ in 0..5 {
47//!     assert!(limiter.try_acquire().await?);
48//! }
49//! assert!(!limiter.try_acquire().await?);
50//! # Ok(())
51//! # }
52//! ```
53
54use crate::rate_limit::RateLimitConfig;
55use async_trait::async_trait;
56use std::collections::HashMap;
57use std::sync::Arc;
58use std::time::Duration;
59use tokio::sync::Mutex;
60use tokio::time::Instant;
61
62/// The rate-limiting algorithm a backend must apply for a key.
63///
64/// This mirrors the algorithm selection in [`RateLimitConfig`] but is expressed
65/// as an explicit, `Copy` value so backends do not need to depend on the full
66/// configuration struct when performing atomic arithmetic.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum DistributedAlgorithm {
69    /// Token bucket: tokens refill continuously at `rate` up to `burst`.
70    TokenBucket,
71    /// Sliding window: at most `rate * window` events within `window` seconds.
72    SlidingWindow,
73}
74
75/// Immutable parameters describing how a key should be rate limited.
76///
77/// Derived from a [`RateLimitConfig`] via [`RateLimitParams::from_config`].
78/// Backends use these to perform the refill / windowing math atomically.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct RateLimitParams {
81    /// Algorithm to apply.
82    pub algorithm: DistributedAlgorithm,
83    /// Refill rate in tokens (or events) per second.
84    pub rate: f64,
85    /// Maximum burst capacity (token bucket) — tokens never exceed this.
86    pub burst: f64,
87    /// Window size in seconds (sliding window).
88    pub window_secs: u64,
89}
90
91impl RateLimitParams {
92    /// Build parameters from a [`RateLimitConfig`], reusing its algorithm choice
93    /// and effective burst computation so behaviour matches the in-process
94    /// limiters exactly.
95    #[must_use]
96    pub fn from_config(config: &RateLimitConfig) -> Self {
97        if config.sliding_window {
98            Self {
99                algorithm: DistributedAlgorithm::SlidingWindow,
100                rate: config.rate,
101                burst: f64::from(config.effective_burst()),
102                window_secs: config.window_size.max(1),
103            }
104        } else {
105            Self {
106                algorithm: DistributedAlgorithm::TokenBucket,
107                rate: config.rate,
108                burst: f64::from(config.effective_burst()),
109                window_secs: config.window_size.max(1),
110            }
111        }
112    }
113
114    /// Maximum number of events permitted within the sliding window.
115    #[inline]
116    #[must_use]
117    pub fn max_window_events(&self) -> u64 {
118        (self.rate * self.window_secs as f64).ceil().max(0.0) as u64
119    }
120}
121
122/// Outcome of an atomic acquire against a distributed backend.
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct AcquireOutcome {
125    /// Whether the requested cost was granted.
126    pub allowed: bool,
127    /// Approximate permits remaining after this operation.
128    pub remaining: f64,
129    /// If denied, the estimated wait until the cost could succeed.
130    pub retry_after: Duration,
131}
132
133impl AcquireOutcome {
134    /// Construct an "allowed" outcome.
135    #[inline]
136    #[must_use]
137    pub fn allowed(remaining: f64) -> Self {
138        Self {
139            allowed: true,
140            remaining,
141            retry_after: Duration::ZERO,
142        }
143    }
144
145    /// Construct a "denied" outcome with an estimated retry delay.
146    #[inline]
147    #[must_use]
148    pub fn denied(remaining: f64, retry_after: Duration) -> Self {
149        Self {
150            allowed: false,
151            remaining,
152            retry_after,
153        }
154    }
155}
156
157/// Shared token / counter store for distributed rate limiting.
158///
159/// Implementations must guarantee that [`acquire`](DistributedRateLimitBackend::acquire)
160/// is **atomic** per `key`: the read, refill, and conditional decrement must
161/// happen without interleaving from other callers acting on the same key.
162/// This is what makes cluster-wide enforcement correct under concurrency.
163///
164/// A Redis-backed implementation can satisfy this by executing the equivalent
165/// Lua scripts (see [`crate::rate_limit::DistributedTokenBucketSpec`] and
166/// [`crate::rate_limit::DistributedSlidingWindowSpec`]) which Redis runs
167/// atomically.
168#[async_trait]
169pub trait DistributedRateLimitBackend: Send + Sync {
170    /// Atomically attempt to acquire `cost` permits for `key`.
171    ///
172    /// The backend applies refill / windowing using `params` before deciding.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the underlying store is unavailable.
177    async fn acquire(
178        &self,
179        key: &str,
180        cost: f64,
181        params: RateLimitParams,
182    ) -> crate::Result<AcquireOutcome>;
183
184    /// Report the currently available permits for `key` (after refill).
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if the underlying store is unavailable.
189    async fn available(&self, key: &str, params: RateLimitParams) -> crate::Result<f64>;
190
191    /// Estimate the time until at least `cost` permits are available for `key`.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the underlying store is unavailable.
196    async fn time_until_available(
197        &self,
198        key: &str,
199        cost: f64,
200        params: RateLimitParams,
201    ) -> crate::Result<Duration>;
202
203    /// Clear all stored state for `key`.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if the underlying store is unavailable.
208    async fn reset(&self, key: &str) -> crate::Result<()>;
209
210    /// Backend name, for diagnostics.
211    fn backend_name(&self) -> &str;
212}
213
214/// Internal per-key state for the in-memory backend.
215#[derive(Debug, Clone)]
216enum KeyState {
217    /// Token bucket state: current tokens and last refill instant.
218    Bucket { tokens: f64, last_refill: Instant },
219    /// Sliding window state: timestamps of recent acquisitions.
220    Window { stamps: Vec<Instant> },
221}
222
223/// In-process distributed rate-limit backend.
224///
225/// Uses a single [`tokio::sync::Mutex`] guarding a `HashMap` so that the entire
226/// acquire/refill cycle for any key is one atomic critical section. This makes
227/// it correct for concurrent in-process workers and a faithful local stand-in
228/// for a Redis backend during tests.
229///
230/// Although it shares one mutex across all keys (simple and contention-safe for
231/// tests and single-process deployments), the public contract only promises
232/// per-key atomicity, so a future sharded implementation remains compatible.
233#[derive(Debug, Default)]
234pub struct InMemoryDistributedBackend {
235    state: Mutex<HashMap<String, KeyState>>,
236}
237
238impl InMemoryDistributedBackend {
239    /// Create a new empty in-memory backend.
240    #[must_use]
241    pub fn new() -> Self {
242        Self {
243            state: Mutex::new(HashMap::new()),
244        }
245    }
246
247    /// Number of keys currently tracked (primarily for tests/diagnostics).
248    pub async fn key_count(&self) -> usize {
249        self.state.lock().await.len()
250    }
251
252    /// Refill a token-bucket value in place, returning the updated tokens.
253    #[inline]
254    fn refill_bucket(
255        tokens: f64,
256        last_refill: Instant,
257        now: Instant,
258        params: RateLimitParams,
259    ) -> f64 {
260        let elapsed = now.saturating_duration_since(last_refill).as_secs_f64();
261        (tokens + elapsed * params.rate).min(params.burst)
262    }
263
264    /// Drop window timestamps older than the configured window.
265    #[inline]
266    fn prune_window(stamps: &mut Vec<Instant>, now: Instant, params: RateLimitParams) {
267        let window = Duration::from_secs(params.window_secs);
268        if let Some(cutoff) = now.checked_sub(window) {
269            stamps.retain(|&t| t > cutoff);
270        }
271    }
272}
273
274#[async_trait]
275impl DistributedRateLimitBackend for InMemoryDistributedBackend {
276    async fn acquire(
277        &self,
278        key: &str,
279        cost: f64,
280        params: RateLimitParams,
281    ) -> crate::Result<AcquireOutcome> {
282        if cost < 0.0 {
283            return Err(crate::CelersError::Configuration(
284                "rate limit acquire cost must be non-negative".to_string(),
285            ));
286        }
287        let now = Instant::now();
288        let mut guard = self.state.lock().await;
289
290        match params.algorithm {
291            DistributedAlgorithm::TokenBucket => {
292                let entry = guard
293                    .entry(key.to_string())
294                    .or_insert_with(|| KeyState::Bucket {
295                        tokens: params.burst,
296                        last_refill: now,
297                    });
298                // If a key was previously used with a different algorithm, reset it.
299                if !matches!(entry, KeyState::Bucket { .. }) {
300                    *entry = KeyState::Bucket {
301                        tokens: params.burst,
302                        last_refill: now,
303                    };
304                }
305                let KeyState::Bucket {
306                    tokens,
307                    last_refill,
308                } = entry
309                else {
310                    unreachable!("entry coerced to Bucket above")
311                };
312                *tokens = Self::refill_bucket(*tokens, *last_refill, now, params);
313                *last_refill = now;
314                if *tokens + f64::EPSILON >= cost {
315                    *tokens -= cost;
316                    Ok(AcquireOutcome::allowed(*tokens))
317                } else {
318                    let deficit = cost - *tokens;
319                    let retry_after = if params.rate > 0.0 {
320                        Duration::from_secs_f64(deficit / params.rate)
321                    } else {
322                        Duration::MAX
323                    };
324                    Ok(AcquireOutcome::denied(*tokens, retry_after))
325                }
326            }
327            DistributedAlgorithm::SlidingWindow => {
328                let entry = guard
329                    .entry(key.to_string())
330                    .or_insert_with(|| KeyState::Window { stamps: Vec::new() });
331                if !matches!(entry, KeyState::Window { .. }) {
332                    *entry = KeyState::Window { stamps: Vec::new() };
333                }
334                let KeyState::Window { stamps } = entry else {
335                    unreachable!("entry coerced to Window above")
336                };
337                Self::prune_window(stamps, now, params);
338                let max = params.max_window_events();
339                let needed = cost.ceil() as u64;
340                let used = stamps.len() as u64;
341                if used + needed <= max {
342                    for _ in 0..needed {
343                        stamps.push(now);
344                    }
345                    let remaining = max.saturating_sub(used + needed) as f64;
346                    Ok(AcquireOutcome::allowed(remaining))
347                } else {
348                    let remaining = max.saturating_sub(used) as f64;
349                    let retry_after = stamps.first().map_or(Duration::ZERO, |&oldest| {
350                        let expires = oldest + Duration::from_secs(params.window_secs);
351                        expires.saturating_duration_since(now)
352                    });
353                    Ok(AcquireOutcome::denied(remaining, retry_after))
354                }
355            }
356        }
357    }
358
359    async fn available(&self, key: &str, params: RateLimitParams) -> crate::Result<f64> {
360        let now = Instant::now();
361        let mut guard = self.state.lock().await;
362        match params.algorithm {
363            DistributedAlgorithm::TokenBucket => match guard.get_mut(key) {
364                Some(KeyState::Bucket {
365                    tokens,
366                    last_refill,
367                }) => {
368                    *tokens = Self::refill_bucket(*tokens, *last_refill, now, params);
369                    *last_refill = now;
370                    Ok(*tokens)
371                }
372                _ => Ok(params.burst),
373            },
374            DistributedAlgorithm::SlidingWindow => {
375                let max = params.max_window_events() as f64;
376                match guard.get_mut(key) {
377                    Some(KeyState::Window { stamps }) => {
378                        Self::prune_window(stamps, now, params);
379                        Ok(max - stamps.len() as f64)
380                    }
381                    _ => Ok(max),
382                }
383            }
384        }
385    }
386
387    async fn time_until_available(
388        &self,
389        key: &str,
390        cost: f64,
391        params: RateLimitParams,
392    ) -> crate::Result<Duration> {
393        let now = Instant::now();
394        let mut guard = self.state.lock().await;
395        match params.algorithm {
396            DistributedAlgorithm::TokenBucket => {
397                let tokens = match guard.get_mut(key) {
398                    Some(KeyState::Bucket {
399                        tokens,
400                        last_refill,
401                    }) => {
402                        *tokens = Self::refill_bucket(*tokens, *last_refill, now, params);
403                        *last_refill = now;
404                        *tokens
405                    }
406                    _ => params.burst,
407                };
408                if tokens + f64::EPSILON >= cost {
409                    Ok(Duration::ZERO)
410                } else if params.rate > 0.0 {
411                    Ok(Duration::from_secs_f64((cost - tokens) / params.rate))
412                } else {
413                    Ok(Duration::MAX)
414                }
415            }
416            DistributedAlgorithm::SlidingWindow => {
417                let max = params.max_window_events();
418                let needed = cost.ceil() as u64;
419                match guard.get_mut(key) {
420                    Some(KeyState::Window { stamps }) => {
421                        Self::prune_window(stamps, now, params);
422                        if stamps.len() as u64 + needed <= max {
423                            Ok(Duration::ZERO)
424                        } else {
425                            Ok(stamps.first().map_or(Duration::ZERO, |&oldest| {
426                                let expires = oldest + Duration::from_secs(params.window_secs);
427                                expires.saturating_duration_since(now)
428                            }))
429                        }
430                    }
431                    _ => Ok(Duration::ZERO),
432                }
433            }
434        }
435    }
436
437    async fn reset(&self, key: &str) -> crate::Result<()> {
438        self.state.lock().await.remove(key);
439        Ok(())
440    }
441
442    fn backend_name(&self) -> &str {
443        "in-memory"
444    }
445}
446
447/// Ergonomic handle for cluster-wide rate limiting against a backend.
448///
449/// Pairs a [`DistributedRateLimitBackend`] with a limiter `key` and a
450/// [`RateLimitConfig`]. The algorithm (token bucket vs sliding window) is taken
451/// from the config, exactly as with the in-process limiters in
452/// [`crate::rate_limit`].
453///
454/// Cloning is cheap: the backend is held behind an [`Arc`] and shared.
455#[derive(Clone)]
456pub struct DistributedRateLimiter {
457    backend: Arc<dyn DistributedRateLimitBackend>,
458    key: String,
459    config: RateLimitConfig,
460    params: RateLimitParams,
461}
462
463impl std::fmt::Debug for DistributedRateLimiter {
464    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465        f.debug_struct("DistributedRateLimiter")
466            .field("key", &self.key)
467            .field("backend", &self.backend.backend_name())
468            .field("config", &self.config)
469            .finish()
470    }
471}
472
473impl DistributedRateLimiter {
474    /// Create a new distributed rate limiter.
475    ///
476    /// # Arguments
477    ///
478    /// * `backend` - Shared store implementing [`DistributedRateLimitBackend`].
479    /// * `key` - Logical limiter name (e.g. a task name or queue).
480    /// * `config` - Rate limit configuration (selects the algorithm).
481    pub fn new(
482        backend: Arc<dyn DistributedRateLimitBackend>,
483        key: impl Into<String>,
484        config: RateLimitConfig,
485    ) -> Self {
486        let params = RateLimitParams::from_config(&config);
487        Self {
488            backend,
489            key: key.into(),
490            config,
491            params,
492        }
493    }
494
495    /// The limiter key.
496    #[inline]
497    #[must_use]
498    pub fn key(&self) -> &str {
499        &self.key
500    }
501
502    /// The active configuration.
503    #[inline]
504    #[must_use]
505    pub fn config(&self) -> &RateLimitConfig {
506        &self.config
507    }
508
509    /// The active algorithm.
510    #[inline]
511    #[must_use]
512    pub fn algorithm(&self) -> DistributedAlgorithm {
513        self.params.algorithm
514    }
515
516    /// Try to acquire a single permit cluster-wide.
517    ///
518    /// Returns `Ok(true)` if granted, `Ok(false)` if rate limited.
519    ///
520    /// # Errors
521    ///
522    /// Returns an error if the backend store is unavailable.
523    pub async fn try_acquire(&self) -> crate::Result<bool> {
524        Ok(self
525            .backend
526            .acquire(&self.key, 1.0, self.params)
527            .await?
528            .allowed)
529    }
530
531    /// Try to acquire `cost` permits atomically.
532    ///
533    /// Returns the full [`AcquireOutcome`] so callers can inspect the remaining
534    /// permits and suggested retry delay.
535    ///
536    /// # Errors
537    ///
538    /// Returns an error if the backend store is unavailable.
539    pub async fn try_acquire_n(&self, cost: f64) -> crate::Result<AcquireOutcome> {
540        self.backend.acquire(&self.key, cost, self.params).await
541    }
542
543    /// Acquire a single permit, awaiting (with bounded sleeps) until granted.
544    ///
545    /// Returns the total time waited. Each retry sleeps for the backend's
546    /// suggested `retry_after`, clamped to `max_sleep` to remain responsive
547    /// under contention from other workers.
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if the backend store is unavailable.
552    pub async fn acquire(&self, max_sleep: Duration) -> crate::Result<Duration> {
553        let start = Instant::now();
554        loop {
555            let outcome = self.backend.acquire(&self.key, 1.0, self.params).await?;
556            if outcome.allowed {
557                return Ok(start.elapsed());
558            }
559            let sleep_for = outcome
560                .retry_after
561                .min(max_sleep)
562                .max(Duration::from_millis(1));
563            tokio::time::sleep(sleep_for).await;
564        }
565    }
566
567    /// Available permits for this limiter (after refill).
568    ///
569    /// # Errors
570    ///
571    /// Returns an error if the backend store is unavailable.
572    pub async fn available(&self) -> crate::Result<f64> {
573        self.backend.available(&self.key, self.params).await
574    }
575
576    /// Estimated time until a single permit is available.
577    ///
578    /// # Errors
579    ///
580    /// Returns an error if the backend store is unavailable.
581    pub async fn time_until_available(&self) -> crate::Result<Duration> {
582        self.backend
583            .time_until_available(&self.key, 1.0, self.params)
584            .await
585    }
586
587    /// Reset the limiter state for this key in the backend.
588    ///
589    /// # Errors
590    ///
591    /// Returns an error if the backend store is unavailable.
592    pub async fn reset(&self) -> crate::Result<()> {
593        self.backend.reset(&self.key).await
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[tokio::test]
602    async fn test_token_bucket_burst_then_deny() {
603        let backend = Arc::new(InMemoryDistributedBackend::new());
604        let config = RateLimitConfig::new(10.0).with_burst(5);
605        let limiter = DistributedRateLimiter::new(backend, "task", config);
606
607        for _ in 0..5 {
608            assert!(limiter.try_acquire().await.unwrap());
609        }
610        assert!(!limiter.try_acquire().await.unwrap());
611        assert_eq!(limiter.algorithm(), DistributedAlgorithm::TokenBucket);
612    }
613
614    #[tokio::test]
615    async fn test_token_bucket_refill() {
616        let backend = Arc::new(InMemoryDistributedBackend::new());
617        // 100/sec, burst 10 -> exhaust then refill after a short sleep.
618        let config = RateLimitConfig::new(100.0).with_burst(10);
619        let limiter = DistributedRateLimiter::new(backend, "task", config);
620
621        for _ in 0..10 {
622            assert!(limiter.try_acquire().await.unwrap());
623        }
624        assert!(!limiter.try_acquire().await.unwrap());
625
626        tokio::time::sleep(Duration::from_millis(30)).await;
627        // ~3 tokens should have refilled at 100/sec over 30ms.
628        assert!(limiter.try_acquire().await.unwrap());
629    }
630
631    #[tokio::test]
632    async fn test_sliding_window_basic_and_deny() {
633        let backend = Arc::new(InMemoryDistributedBackend::new());
634        let config = RateLimitConfig::new(5.0).with_sliding_window(1);
635        let limiter = DistributedRateLimiter::new(backend, "task", config);
636        assert_eq!(limiter.algorithm(), DistributedAlgorithm::SlidingWindow);
637
638        for _ in 0..5 {
639            assert!(limiter.try_acquire().await.unwrap());
640        }
641        assert!(!limiter.try_acquire().await.unwrap());
642        let wait = limiter.time_until_available().await.unwrap();
643        assert!(wait > Duration::ZERO);
644    }
645
646    #[tokio::test]
647    async fn test_cost_n_acquire() {
648        let backend = Arc::new(InMemoryDistributedBackend::new());
649        let config = RateLimitConfig::new(10.0).with_burst(10);
650        let limiter = DistributedRateLimiter::new(backend, "task", config);
651
652        let outcome = limiter.try_acquire_n(4.0).await.unwrap();
653        assert!(outcome.allowed);
654        assert!((outcome.remaining - 6.0).abs() < 1e-6);
655
656        // 6 left, request 7 -> denied with retry hint.
657        let denied = limiter.try_acquire_n(7.0).await.unwrap();
658        assert!(!denied.allowed);
659        assert!(denied.retry_after > Duration::ZERO);
660    }
661
662    #[tokio::test]
663    async fn test_available_and_reset() {
664        // Use rate 0.0 so there is no refill between operations, making the
665        // available-permit assertions deterministic.
666        let backend = Arc::new(InMemoryDistributedBackend::new());
667        let config = RateLimitConfig::new(0.0).with_burst(8);
668        let limiter = DistributedRateLimiter::new(backend.clone(), "task", config);
669
670        assert!((limiter.available().await.unwrap() - 8.0).abs() < 1e-6);
671        for _ in 0..3 {
672            assert!(limiter.try_acquire().await.unwrap());
673        }
674        let avail = limiter.available().await.unwrap();
675        assert!((avail - 5.0).abs() < 1e-6, "expected 5 tokens, got {avail}");
676
677        limiter.reset().await.unwrap();
678        assert!((limiter.available().await.unwrap() - 8.0).abs() < 1e-6);
679    }
680
681    #[tokio::test]
682    async fn test_concurrent_acquire_does_not_exceed_burst() {
683        // Use rate 0 so no refill happens mid-test; only the initial burst is grantable.
684        let backend = Arc::new(InMemoryDistributedBackend::new());
685        let config = RateLimitConfig::new(0.0).with_burst(20);
686        let limiter = DistributedRateLimiter::new(backend, "task", config);
687
688        let mut handles = Vec::new();
689        for _ in 0..8 {
690            let l = limiter.clone();
691            handles.push(tokio::spawn(async move {
692                let mut count = 0u32;
693                for _ in 0..10 {
694                    if l.try_acquire().await.unwrap_or(false) {
695                        count += 1;
696                    }
697                }
698                count
699            }));
700        }
701
702        let mut total = 0u32;
703        for h in handles {
704            total += h.await.unwrap();
705        }
706        // 8 workers * 10 attempts = 80 attempts, but only 20 tokens exist.
707        assert_eq!(total, 20, "exactly the burst capacity should be granted");
708    }
709
710    #[tokio::test]
711    async fn test_concurrent_sliding_window_cap() {
712        let backend = Arc::new(InMemoryDistributedBackend::new());
713        // 100/sec over a 10s window -> 1000 max events; cap the concurrent grants.
714        let config = RateLimitConfig::new(3.0).with_sliding_window(100);
715        let limiter = DistributedRateLimiter::new(backend, "task", config);
716        let max = limiter.params.max_window_events();
717
718        let mut handles = Vec::new();
719        for _ in 0..6 {
720            let l = limiter.clone();
721            handles.push(tokio::spawn(async move {
722                let mut count = 0u32;
723                for _ in 0..200 {
724                    if l.try_acquire().await.unwrap_or(false) {
725                        count += 1;
726                    }
727                }
728                count
729            }));
730        }
731        let mut total = 0u64;
732        for h in handles {
733            total += u64::from(h.await.unwrap());
734        }
735        assert_eq!(total, max, "sliding window must not exceed max events");
736    }
737
738    #[tokio::test]
739    async fn test_acquire_waits_then_succeeds() {
740        let backend = Arc::new(InMemoryDistributedBackend::new());
741        let config = RateLimitConfig::new(50.0).with_burst(1);
742        let limiter = DistributedRateLimiter::new(backend, "task", config);
743
744        assert!(limiter.try_acquire().await.unwrap());
745        // Now empty; acquire() should sleep until a token refills (~20ms at 50/s).
746        let waited = limiter.acquire(Duration::from_millis(100)).await.unwrap();
747        assert!(waited > Duration::ZERO);
748    }
749
750    #[tokio::test]
751    async fn test_shared_backend_across_keys() {
752        let backend = Arc::new(InMemoryDistributedBackend::new());
753        let a = DistributedRateLimiter::new(
754            backend.clone(),
755            "a",
756            RateLimitConfig::new(0.0).with_burst(2),
757        );
758        let b = DistributedRateLimiter::new(
759            backend.clone(),
760            "b",
761            RateLimitConfig::new(0.0).with_burst(2),
762        );
763
764        assert!(a.try_acquire().await.unwrap());
765        assert!(a.try_acquire().await.unwrap());
766        assert!(!a.try_acquire().await.unwrap());
767        // b is an independent key, unaffected by a's exhaustion.
768        assert!(b.try_acquire().await.unwrap());
769        assert!(b.try_acquire().await.unwrap());
770        assert!(!b.try_acquire().await.unwrap());
771        assert_eq!(backend.key_count().await, 2);
772    }
773
774    #[tokio::test]
775    async fn test_negative_cost_rejected() {
776        let backend = Arc::new(InMemoryDistributedBackend::new());
777        let config = RateLimitConfig::new(10.0).with_burst(5);
778        let limiter = DistributedRateLimiter::new(backend, "task", config);
779        assert!(limiter.try_acquire_n(-1.0).await.is_err());
780    }
781
782    #[tokio::test]
783    async fn test_params_from_config() {
784        let bucket = RateLimitParams::from_config(&RateLimitConfig::new(10.0).with_burst(20));
785        assert_eq!(bucket.algorithm, DistributedAlgorithm::TokenBucket);
786        assert!((bucket.burst - 20.0).abs() < 1e-6);
787
788        let window =
789            RateLimitParams::from_config(&RateLimitConfig::new(4.0).with_sliding_window(5));
790        assert_eq!(window.algorithm, DistributedAlgorithm::SlidingWindow);
791        assert_eq!(window.window_secs, 5);
792        assert_eq!(window.max_window_events(), 20);
793    }
794}