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
10pub struct SqliteBackend {
16 conn: Mutex<Connection>,
17}
18
19impl SqliteBackend {
20 pub fn new(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
22 let conn = Connection::open(path)?;
23 Self::init(conn)
24 }
25
26 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 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 let now = SystemTime::now()
62 .duration_since(UNIX_EPOCH)
63 .unwrap_or_default()
64 .as_millis() as u64;
65
66 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 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 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 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#[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", "a).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", "a).await;
157 assert!(r1.allowed);
158 let r2 = backend.check("user-1", "a).await;
159 assert!(r2.allowed);
160 let r3 = backend.check("user-1", "a).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", "a).await;
171 assert!(r1.allowed);
172 let r2 = backend.check("a", "a).await;
173 assert!(!r2.allowed);
174
175 let r3 = backend.check("b", "a).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", "a).await;
186 assert_eq!(r.remaining, 4);
187 let r = backend.check("u", "a).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); assert!(backend.check("denied", "a).await.allowed);
208 assert!(backend.check("denied", "a).await.allowed);
209
210 tokio::time::sleep(Duration::from_millis(250)).await;
214
215 let before = Instant::now();
216 let r3 = backend.check("denied", "a).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); for i in 1..=10u64 {
242 let r = backend.check("replenish", "a).await;
243 assert!(r.allowed);
244 assert_eq!(r.remaining, 10 - i);
245 }
246 assert!(!backend.check("replenish", "a).await.allowed);
247
248 tokio::time::sleep(Duration::from_millis(110)).await;
251 let r = backend.check("replenish", "a).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 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}