Skip to main content

throttle_kit/
sqlite.rs

1use rusqlite::{Connection, params};
2use std::path::Path;
3use std::sync::Mutex;
4use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
5
6use crate::backend::RateLimitBackend;
7use crate::metrics::RateLimitResult;
8use crate::quota::Quota;
9
10/// SQLite-backed GCRA rate limiter.
11///
12/// Stores per-key state in a SQLite database. Each key tracks its last
13/// allowed timestamp and burst capacity, enabling durable rate limiting
14/// across process restarts.
15pub struct SqliteBackend {
16    conn: Mutex<Connection>,
17}
18
19impl SqliteBackend {
20    /// Open (or create) a SQLite database at `path` for rate-limit state.
21    pub fn new(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
22        let conn = Connection::open(path)?;
23        Self::init(conn)
24    }
25
26    /// Create an in-memory SQLite database for rate-limit state.
27    pub fn in_memory() -> Result<Self, rusqlite::Error> {
28        let conn = Connection::open_in_memory()?;
29        Self::init(conn)
30    }
31
32    fn init(conn: Connection) -> Result<Self, rusqlite::Error> {
33        conn.execute_batch(
34            "CREATE TABLE IF NOT EXISTS rate_limits (
35                key TEXT PRIMARY KEY,
36                last_emission_ms INTEGER NOT NULL,
37                remaining INTEGER NOT NULL
38            );",
39        )?;
40        Ok(Self {
41            conn: Mutex::new(conn),
42        })
43    }
44}
45
46impl RateLimitBackend for SqliteBackend {
47    async fn check(&self, key: &str, quota: &Quota) -> RateLimitResult {
48        // A poisoned mutex only means a peer panicked mid-check; the
49        // SQLite connection itself remains valid, so recover the guard
50        // instead of panicking (or deadlocking) on every later request.
51        let conn = match self.conn.lock() {
52            Ok(guard) => guard,
53            Err(poisoned) => poisoned.into_inner(),
54        };
55        let interval = quota.interval();
56        let burst = quota.burst;
57        let interval_ms = interval.as_millis() as u64;
58
59        // A pre-epoch system clock is a platform misconfiguration; treat
60        // it as 0 rather than panicking inside a request path.
61        let now = SystemTime::now()
62            .duration_since(UNIX_EPOCH)
63            .unwrap_or_default()
64            .as_millis() as u64;
65
66        // Read existing entry
67        let existing: Option<(u64, u32)> = conn
68            .query_row(
69                "SELECT last_emission_ms, remaining FROM rate_limits WHERE key = ?1",
70                params![key],
71                |row| Ok((row.get(0)?, row.get(1)?)),
72            )
73            .ok();
74
75        let (last_ms, remaining) = existing.unwrap_or((now, burst));
76
77        // GCRA: tokens replenished since last check
78        let elapsed = now.saturating_sub(last_ms);
79        let replenished = (elapsed / interval_ms) as u32;
80        let new_remaining = if replenished > 0 {
81            (remaining + replenished).min(burst)
82        } else {
83            remaining
84        };
85
86        let allowed = new_remaining > 0;
87        let final_remaining = if allowed {
88            new_remaining - 1
89        } else {
90            new_remaining
91        };
92
93        // Update last emission time
94        let new_last_ms = if allowed { now } else { last_ms };
95
96        let _ = conn.execute(
97            "INSERT OR REPLACE INTO rate_limits (key, last_emission_ms, remaining) VALUES (?1, ?2, ?3)",
98            params![key, new_last_ms, final_remaining],
99        );
100
101        let reset_at = if allowed {
102            Instant::now() + interval
103        } else {
104            // How long until a token becomes available
105            let wait_ms = interval_ms.saturating_sub(elapsed % interval_ms);
106            Instant::now() + Duration::from_millis(wait_ms)
107        };
108
109        RateLimitResult {
110            allowed,
111            remaining: final_remaining as u64,
112            reset_at,
113            limit: burst as u64,
114            retry_after: if allowed {
115                None
116            } else {
117                let elapsed = now.saturating_sub(last_ms);
118                let wait_ms = interval_ms.saturating_sub(elapsed % interval_ms);
119                Some(Duration::from_millis(wait_ms))
120            },
121        }
122    }
123}
124
125// Tests exercise failure paths and invariants directly; unwrap/expect,
126// slicing, and panicking asserts are acceptable here — violations
127// surface as test failures, not production panics.
128#[allow(
129    clippy::unwrap_used,
130    clippy::expect_used,
131    clippy::indexing_slicing,
132    clippy::panic
133)]
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn test_backend() -> SqliteBackend {
139        SqliteBackend::in_memory().unwrap()
140    }
141
142    #[tokio::test]
143    async fn first_request_allowed() {
144        let backend = test_backend();
145        let quota = Quota::per_second(10);
146        let result = backend.check("user-1", &quota).await;
147        assert!(result.allowed);
148        assert_eq!(result.limit, 10);
149    }
150
151    #[tokio::test]
152    async fn burst_exhaustion() {
153        let backend = test_backend();
154        let quota = Quota::per_second(2);
155
156        let r1 = backend.check("user-1", &quota).await;
157        assert!(r1.allowed);
158        let r2 = backend.check("user-1", &quota).await;
159        assert!(r2.allowed);
160        let r3 = backend.check("user-1", &quota).await;
161        assert!(!r3.allowed);
162        assert!(r3.retry_after.is_some());
163    }
164
165    #[tokio::test]
166    async fn key_isolation() {
167        let backend = test_backend();
168        let quota = Quota::per_second(1);
169
170        let r1 = backend.check("a", &quota).await;
171        assert!(r1.allowed);
172        let r2 = backend.check("a", &quota).await;
173        assert!(!r2.allowed);
174
175        // Different key should be unaffected
176        let r3 = backend.check("b", &quota).await;
177        assert!(r3.allowed);
178    }
179
180    #[tokio::test]
181    async fn remaining_decrements() {
182        let backend = test_backend();
183        let quota = Quota::per_second(5);
184
185        let r = backend.check("u", &quota).await;
186        assert_eq!(r.remaining, 4);
187        let r = backend.check("u", &quota).await;
188        assert_eq!(r.remaining, 3);
189    }
190
191    #[tokio::test]
192    async fn reset_at_is_in_the_future_when_allowed() {
193        let backend = test_backend();
194        let r = backend.check("reset-ok", &Quota::per_second(2)).await;
195        assert!(r.allowed);
196        assert!(
197            r.reset_at > Instant::now(),
198            "reset_at must be in the future for an allowed request"
199        );
200    }
201
202    #[tokio::test]
203    async fn denial_timing_is_inside_the_current_interval() {
204        let backend = test_backend();
205        let quota = Quota::per_second(2); // 500 ms interval, burst 2
206
207        assert!(backend.check("denied", &quota).await.allowed);
208        assert!(backend.check("denied", &quota).await.allowed);
209
210        // Partial-window gap: some, but not all, of the interval elapses
211        // before the next request, so it is denied with a fraction of the
212        // interval left to wait.
213        tokio::time::sleep(Duration::from_millis(250)).await;
214
215        let before = Instant::now();
216        let r3 = backend.check("denied", &quota).await;
217        assert!(!r3.allowed);
218        let after = Instant::now();
219        assert!(
220            r3.reset_at > after,
221            "reset_at must be strictly in the future for a denied request"
222        );
223        assert!(
224            r3.reset_at < before + quota.interval(),
225            "reset_at must fall within the current interval, not a full one out"
226        );
227        let retry = r3.retry_after.expect("denied result carries retry_after");
228        assert!(retry > Duration::ZERO);
229        assert!(
230            retry < quota.interval(),
231            "retry_after must reflect the elapsed fraction of the interval"
232        );
233    }
234
235    #[tokio::test]
236    async fn tokens_replenish_after_elapsed_intervals() {
237        let backend = test_backend();
238        let quota = Quota::per_second(10); // 100 ms interval, burst 10
239
240        // Rapid requests exhaust the burst to exactly zero.
241        for i in 1..=10u64 {
242            let r = backend.check("replenish", &quota).await;
243            assert!(r.allowed);
244            assert_eq!(r.remaining, 10 - i);
245        }
246        assert!(!backend.check("replenish", &quota).await.allowed);
247
248        // After one full interval the token bucket must refill: a backend
249        // that ignores elapsed time stays denied.
250        tokio::time::sleep(Duration::from_millis(110)).await;
251        let r = backend.check("replenish", &quota).await;
252        assert!(r.allowed, "token must replenish after one interval");
253        assert!(r.remaining <= 2, "at most one interval of tokens visible");
254    }
255
256    #[test]
257    fn file_backend_creates_db() {
258        let dir = std::env::temp_dir().join("throttle-kit-test-sqlite");
259        std::fs::create_dir_all(&dir).unwrap();
260        let path = dir.join("test.db");
261        let _ = std::fs::remove_file(&path);
262
263        let _backend = SqliteBackend::new(&path).unwrap();
264        assert!(path.exists());
265
266        let _ = std::fs::remove_dir_all(&dir);
267    }
268
269    #[test]
270    fn sync_check_via_rate_limiter() {
271        use crate::{Quota, RateLimiter};
272        let backend = test_backend();
273        let limiter = RateLimiter::new(Quota::per_second(10), backend);
274        let r = limiter.check_sync("user-1");
275        assert!(r.allowed);
276    }
277
278    #[test]
279    fn init_fails_on_readonly_database() {
280        // The bootstrap CREATE TABLE cannot run on a read-only connection,
281        // so `init` must surface the error instead of half-initialising.
282        let conn = rusqlite::Connection::open_with_flags(
283            ":memory:",
284            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
285        )
286        .unwrap();
287        assert!(SqliteBackend::init(conn).is_err());
288    }
289}