Skip to main content

cratestack_axum/ratelimit/
store.rs

1use std::sync::Arc;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4
5use async_trait::async_trait;
6use cratestack_core::{
7    BoundedOutcome, Charged, ConsumeRequest, CratestackError, bucket_ttl_secs, scope_ttl_secs,
8};
9
10use super::config::{RateLimitConfig, RateLimitDecision};
11
12mod buckets;
13mod capacity;
14mod scopes;
15
16// Re-export from cratestack-core for internal use
17pub use cratestack_core::RateLimitStore;
18
19use buckets::Buckets;
20use scopes::Scopes;
21
22/// Default ceiling on live buckets.
23///
24/// The in-memory store documents itself as single-replica/development
25/// scale, and 100k live token buckets is far past that — a deployment
26/// legitimately tracking that many distinct callers in one process wants
27/// the Redis store, whose keyspace is not this process's heap. The cap
28/// exists as a backstop *under* the cardinality budget, not instead of it:
29/// with the budget doing its job the map is O(peers × 128), and the cap is
30/// what keeps "peers" from being the unbounded term when a botnet supplies
31/// them.
32pub const DEFAULT_MAX_BUCKETS: usize = 100_000;
33
34#[derive(Debug, Default)]
35struct State {
36    buckets: Buckets,
37    scopes: Scopes,
38}
39
40/// In-memory `RateLimitStore`. Suitable for single-replica deployments and
41/// development; banks running multi-replica clusters need a Redis-backed
42/// implementation so the limit is enforced cluster-wide.
43///
44/// Bounded in two independent ways since cratestack#871: an amortised
45/// sweep drops buckets idle for a full [`cratestack_core::bucket_ttl_secs`]
46/// (the same horizon Redis's `EXPIRE` uses), and [`Self::with_max_buckets`]
47/// caps live buckets outright, failing closed when a sweep frees nothing.
48#[derive(Debug, Clone)]
49pub struct InMemoryRateLimitStore {
50    state: Arc<Mutex<State>>,
51    max_buckets: Option<usize>,
52}
53
54impl Default for InMemoryRateLimitStore {
55    fn default() -> Self {
56        Self {
57            state: Arc::new(Mutex::new(State::default())),
58            max_buckets: Some(DEFAULT_MAX_BUCKETS),
59        }
60    }
61}
62
63impl InMemoryRateLimitStore {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Hard ceiling on live buckets, defaulting to [`DEFAULT_MAX_BUCKETS`].
69    ///
70    /// At the ceiling, a request for a bucket that does not exist yet is
71    /// refused with `CratestackError::Internal` — a *logical* failure, so
72    /// it stays closed under every [`super::StoreErrorPolicy`]. Requests
73    /// for buckets that already exist keep being served normally.
74    pub fn with_max_buckets(mut self, max_buckets: usize) -> Self {
75        self.max_buckets = Some(max_buckets);
76        self
77    }
78
79    /// Remove the ceiling entirely, leaving only the TTL sweep. For
80    /// deployments that would rather risk the heap than refuse a caller.
81    pub fn without_max_buckets(mut self) -> Self {
82        self.max_buckets = None;
83        self
84    }
85
86    /// Test seam: consume against an injected clock.
87    ///
88    /// Eviction is a clock decision, and a test that sleeps through a real
89    /// 60s TTL is a test nobody runs. Precedent:
90    /// `cratestack_core::log_throttle::LogThrottle::check_at`.
91    #[doc(hidden)]
92    pub fn _consume_at(
93        &self,
94        request: ConsumeRequest<'_>,
95        now: Instant,
96    ) -> Result<BoundedOutcome, CratestackError> {
97        let ttl = Duration::from_secs(bucket_ttl_secs(request.config));
98        let mut state = self
99            .state
100            .lock()
101            .map_err(|_| CratestackError::Internal("rate limit store poisoned".to_owned()))?;
102
103        if state.buckets.maybe_sweep(now, ttl) {
104            // Each scope carries its own deadline, so the sweep needs no
105            // horizon argument — one fewer way to get the scope lifetime
106            // wrong (cratestack#871 review, blocker 2).
107            state.scopes.sweep(now);
108        }
109
110        let charged = match request.budget {
111            None => Charged::Requested,
112            Some(budget) => {
113                // Refuse BEFORE touching the scope index, not after
114                // (cratestack#871 round-2, item 2). Admitting first and
115                // letting `Buckets::consume` refuse afterwards left a
116                // scope entry — and an interned member key — behind for
117                // every refused request: measured `max_buckets=10` ->
118                // `buckets=10 scopes=5000`, each scope able to hold 128
119                // keys for up to a day. `max_buckets` bounded the bucket
120                // map and nothing else.
121                self.reserve_admission(&mut state, &request, budget, now, ttl)?;
122                // Never shorter than the bucket TTL, so a member's slot
123                // outlives the bucket it admitted.
124                let scope_ttl = Duration::from_secs(scope_ttl_secs(request.config, budget.window));
125                if state.scopes.admit(budget, request.key, now, scope_ttl) {
126                    Charged::Requested
127                } else {
128                    Charged::Fallback
129                }
130            }
131        };
132        let decision = state.buckets.consume(
133            request.charged_key(charged),
134            request.config,
135            now,
136            self.max_buckets,
137            ttl,
138        )?;
139        Ok(BoundedOutcome::new(decision, charged))
140    }
141
142    /// Test seam: how many buckets are live right now. The number the
143    /// cratestack#871 regression tests assert a bound on.
144    #[doc(hidden)]
145    pub fn _bucket_count(&self) -> usize {
146        self.state
147            .lock()
148            .map(|state| state.buckets.len())
149            .unwrap_or(0)
150    }
151
152    /// Test seam: how many scope records are live. Separate from
153    /// [`Self::_bucket_count`] because the round-2 review found the two
154    /// diverging by three orders of magnitude.
155    #[doc(hidden)]
156    pub fn _scope_count(&self) -> usize {
157        self.state
158            .lock()
159            .map(|state| state.scopes.len())
160            .unwrap_or(0)
161    }
162}
163
164#[async_trait]
165impl RateLimitStore for InMemoryRateLimitStore {
166    async fn consume(
167        &self,
168        key: &str,
169        config: RateLimitConfig,
170    ) -> Result<RateLimitDecision, CratestackError> {
171        self.consume_bounded(ConsumeRequest::new(key, config, None))
172            .await
173            .map(|outcome| outcome.decision)
174    }
175
176    async fn consume_bounded(
177        &self,
178        request: ConsumeRequest<'_>,
179    ) -> Result<BoundedOutcome, CratestackError> {
180        self._consume_at(request, Instant::now())
181    }
182}