plausible_cli/rate_limit/
mod.rs1use governor::{
2 clock::DefaultClock,
3 state::{direct::NotKeyed, InMemoryState},
4 Jitter, Quota, RateLimiter as GovernorRateLimiter,
5};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use std::fs;
8use std::num::NonZeroU32;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Duration as StdDuration;
12use time::{
13 format_description::FormatItem, macros::format_description, Date, Duration as TimeDuration,
14 OffsetDateTime,
15};
16use tokio::sync::Mutex;
17
18use crate::config::ConfigPaths;
19
20const DEFAULT_JITTER_UP_TO_MS: u64 = 250;
21
22#[derive(Debug, Clone)]
24pub struct RateLimitConfig {
25 pub hourly_quota: NonZeroU32,
26 pub daily_quota: Option<NonZeroU32>,
27 pub jitter_max: StdDuration,
28 quota: Quota,
29}
30
31impl RateLimitConfig {
32 pub fn new(hourly_quota: NonZeroU32) -> Self {
33 let quota = Quota::per_hour(hourly_quota);
34 Self {
35 hourly_quota,
36 daily_quota: Some(hourly_quota),
37 jitter_max: StdDuration::from_millis(DEFAULT_JITTER_UP_TO_MS),
38 quota,
39 }
40 }
41
42 pub fn with_daily_quota(mut self, daily: Option<NonZeroU32>) -> Self {
43 self.daily_quota = daily;
44 self
45 }
46
47 #[cfg(test)]
48 pub fn with_quota(mut self, quota: Quota) -> Self {
49 self.quota = quota;
50 self
51 }
52
53 fn jitter(&self) -> Jitter {
54 if self.jitter_max.is_zero() {
55 Jitter::up_to(StdDuration::from_millis(0))
56 } else {
57 Jitter::up_to(self.jitter_max)
58 }
59 }
60
61 fn quota(&self) -> Quota {
62 self.quota
63 }
64}
65
66impl Default for RateLimitConfig {
67 fn default() -> Self {
68 RateLimitConfig::new(NonZeroU32::new(600).expect("non zero"))
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct RateLimiter {
75 limiter: Arc<GovernorRateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
76 usage: Arc<Mutex<UsageLedger>>,
77 config: RateLimitConfig,
78 jitter: Jitter,
79}
80
81impl RateLimiter {
82 pub async fn new(
83 paths: ConfigPaths,
84 account: &str,
85 config: RateLimitConfig,
86 ) -> Result<Self, RateLimitError> {
87 paths.ensure_exists()?;
88 let usage = UsageLedger::load(paths, account.to_string())?;
89 let limiter = GovernorRateLimiter::direct(config.quota());
90 Ok(Self {
91 limiter: Arc::new(limiter),
92 usage: Arc::new(Mutex::new(usage)),
93 jitter: config.jitter(),
94 config,
95 })
96 }
97
98 pub async fn acquire(&self, weight: NonZeroU32) {
100 let _ = self
101 .limiter
102 .until_n_ready_with_jitter(weight, self.jitter)
103 .await;
104 }
105
106 pub async fn record_success(
108 &self,
109 weight: u32,
110 now: OffsetDateTime,
111 ) -> Result<RateStatus, RateLimitError> {
112 let mut usage = self.usage.lock().await;
113 usage.record(weight, now)?;
114 usage.persist()?;
115 Ok(usage.status(&self.config, now))
116 }
117
118 pub async fn status(&self, now: OffsetDateTime) -> Result<RateStatus, RateLimitError> {
120 let usage = self.usage.lock().await;
121 Ok(usage.status(&self.config, now))
122 }
123}
124
125#[derive(Debug, Clone)]
126struct UsageLedger {
127 path: PathBuf,
128 snapshot: UsageSnapshot,
129}
130
131impl UsageLedger {
132 fn load(paths: ConfigPaths, account: String) -> Result<Self, RateLimitError> {
133 let path = paths.usage_dir().join(format!("{account}.json"));
134 let snapshot = match fs::read(&path) {
135 Ok(bytes) => {
136 if bytes.is_empty() {
137 UsageSnapshot::new(account.clone())
138 } else {
139 serde_json::from_slice(&bytes).map_err(RateLimitError::Deserialize)?
140 }
141 }
142 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
143 UsageSnapshot::new(account.clone())
144 }
145 Err(source) => return Err(RateLimitError::Io { path, source }),
146 };
147 Ok(Self { path, snapshot })
148 }
149
150 fn persist(&self) -> Result<(), RateLimitError> {
151 let contents =
152 serde_json::to_vec_pretty(&self.snapshot).map_err(RateLimitError::Serialize)?;
153 if let Some(parent) = self.path.parent() {
154 fs::create_dir_all(parent).map_err(|source| RateLimitError::Io {
155 path: parent.to_path_buf(),
156 source,
157 })?;
158 }
159 fs::write(&self.path, contents).map_err(|source| RateLimitError::Io {
160 path: self.path.clone(),
161 source,
162 })?;
163 #[cfg(unix)]
164 {
165 use std::os::unix::fs::PermissionsExt;
166 let mut perms = fs::metadata(&self.path)
167 .map_err(|source| RateLimitError::Io {
168 path: self.path.clone(),
169 source,
170 })?
171 .permissions();
172 perms.set_mode(0o600);
173 fs::set_permissions(&self.path, perms).map_err(|source| RateLimitError::Io {
174 path: self.path.clone(),
175 source,
176 })?;
177 }
178 Ok(())
179 }
180
181 fn record(&mut self, amount: u32, now: OffsetDateTime) -> Result<(), RateLimitError> {
182 let hour = hour_bucket(now);
183 let entry = self.snapshot.hourly.get_or_insert(HourWindow {
184 start: hour,
185 count: 0,
186 });
187 if entry.start != hour {
188 entry.start = hour;
189 entry.count = 0;
190 }
191 entry.count = entry.count.saturating_add(amount);
192
193 let day = now.date();
194 let daily = self.snapshot.daily.get_or_insert(DayWindow {
195 date: day,
196 count: 0,
197 });
198 if daily.date != day {
199 daily.date = day;
200 daily.count = 0;
201 }
202 daily.count = daily.count.saturating_add(amount);
203 Ok(())
204 }
205
206 fn status(&self, config: &RateLimitConfig, now: OffsetDateTime) -> RateStatus {
207 let hour = hour_bucket(now);
208 let day = now.date();
209 let hourly_used = self
210 .snapshot
211 .hourly
212 .as_ref()
213 .filter(|window| window.start == hour)
214 .map(|window| window.count)
215 .unwrap_or(0);
216 let hourly_remaining = config.hourly_quota.get().saturating_sub(hourly_used);
217 let hourly_reset_at = hour.checked_add(TimeDuration::hours(1)).unwrap_or(now);
218
219 let (daily_used, daily_remaining, daily_reset_at) = match config.daily_quota {
220 Some(limit) => {
221 let used = self
222 .snapshot
223 .daily
224 .as_ref()
225 .filter(|window| window.date == day)
226 .map(|window| window.count)
227 .unwrap_or(0);
228 let remaining = limit.get().saturating_sub(used);
229 let reset = day
230 .next_day()
231 .and_then(|d| d.with_hms(0, 0, 0).ok())
232 .map(|dt| dt.assume_utc());
233 (Some(used), Some(remaining), reset)
234 }
235 None => (None, None, None),
236 };
237
238 RateStatus {
239 hourly_remaining,
240 hourly_limit: config.hourly_quota.get(),
241 hourly_reset_at,
242 hourly_used,
243 daily_limit: config.daily_quota.map(|n| n.get()),
244 daily_remaining,
245 daily_used,
246 daily_reset_at,
247 }
248 }
249}
250
251fn hour_bucket(now: OffsetDateTime) -> OffsetDateTime {
252 now.replace_minute(0)
253 .unwrap()
254 .replace_second(0)
255 .unwrap()
256 .replace_nanosecond(0)
257 .unwrap()
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261struct UsageSnapshot {
262 account: String,
263 #[serde(default)]
264 hourly: Option<HourWindow>,
265 #[serde(default)]
266 daily: Option<DayWindow>,
267}
268
269impl UsageSnapshot {
270 fn new(account: String) -> Self {
271 Self {
272 account,
273 hourly: None,
274 daily: None,
275 }
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280struct HourWindow {
281 #[serde(with = "time::serde::rfc3339")]
282 start: OffsetDateTime,
283 count: u32,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287struct DayWindow {
288 #[serde(with = "serde_date")]
289 date: Date,
290 count: u32,
291}
292
293mod serde_date {
294 use super::*;
295
296 const FORMAT: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]");
297
298 pub fn serialize<S>(date: &Date, serializer: S) -> Result<S::Ok, S::Error>
299 where
300 S: Serializer,
301 {
302 let formatted = date.format(FORMAT).map_err(serde::ser::Error::custom)?;
303 serializer.serialize_str(&formatted)
304 }
305
306 pub fn deserialize<'de, D>(deserializer: D) -> Result<Date, D::Error>
307 where
308 D: Deserializer<'de>,
309 {
310 let value = String::deserialize(deserializer)?;
311 Date::parse(&value, FORMAT).map_err(serde::de::Error::custom)
312 }
313}
314
315#[derive(Debug, Clone, PartialEq)]
317pub struct RateStatus {
318 pub hourly_remaining: u32,
319 pub hourly_limit: u32,
320 pub hourly_used: u32,
321 pub hourly_reset_at: OffsetDateTime,
322 pub daily_limit: Option<u32>,
323 pub daily_used: Option<u32>,
324 pub daily_remaining: Option<u32>,
325 pub daily_reset_at: Option<OffsetDateTime>,
326}
327
328#[derive(thiserror::Error, Debug)]
329pub enum RateLimitError {
330 #[error("I/O error at {path:?}")]
331 Io {
332 path: PathBuf,
333 #[source]
334 source: std::io::Error,
335 },
336 #[error("failed to deserialize usage file: {0}")]
337 Deserialize(#[source] serde_json::Error),
338 #[error("failed to serialize usage file: {0}")]
339 Serialize(#[source] serde_json::Error),
340 #[error(transparent)]
341 Config(#[from] crate::config::ConfigError),
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::config::ConfigPaths;
348 use std::num::NonZeroU32;
349 use tempfile::tempdir;
350 use tokio::time::Instant;
351
352 fn temp_paths() -> (ConfigPaths, tempfile::TempDir) {
353 let tmp = tempdir().expect("tempdir");
354 let paths = ConfigPaths::from_base_dir(tmp.path());
355 (paths, tmp)
356 }
357
358 #[tokio::test]
359 async fn records_and_persists_usage() {
360 let (paths, _guard) = temp_paths();
361 let config = RateLimitConfig::new(NonZeroU32::new(10).unwrap());
362 let limiter = RateLimiter::new(paths.clone(), "test", config)
363 .await
364 .expect("limiter");
365 let now = OffsetDateTime::now_utc();
366 let status = limiter.record_success(5, now).await.expect("record");
367 assert_eq!(status.hourly_used, 5);
368 assert_eq!(status.hourly_remaining, 5);
369
370 let limiter2 = RateLimiter::new(
372 paths,
373 "test",
374 RateLimitConfig::new(NonZeroU32::new(10).unwrap()),
375 )
376 .await
377 .expect("limiter2");
378 let status2 = limiter2.status(now).await.expect("status");
379 assert_eq!(status2.hourly_used, 5);
380 }
381
382 #[tokio::test]
383 async fn hourly_window_resets_after_boundary() {
384 let (paths, _guard) = temp_paths();
385 let config = RateLimitConfig::new(NonZeroU32::new(10).unwrap());
386 let limiter = RateLimiter::new(paths, "test", config)
387 .await
388 .expect("limiter");
389 let now = OffsetDateTime::now_utc();
390 limiter.record_success(10, now).await.expect("record");
391 let next_hour = hour_bucket(now)
392 .checked_add(TimeDuration::hours(1))
393 .unwrap();
394 let status = limiter.status(next_hour).await.expect("status");
395 assert_eq!(status.hourly_used, 0);
396 assert_eq!(status.hourly_remaining, 10);
397 }
398
399 #[tokio::test]
400 async fn rate_limiter_waits_when_quota_exceeded() {
401 let (paths, _guard) = temp_paths();
402 let quota = Quota::per_second(NonZeroU32::new(1).unwrap());
403 let config = RateLimitConfig::new(NonZeroU32::new(1).unwrap()).with_quota(quota);
404 let limiter = RateLimiter::new(paths, "test", config)
405 .await
406 .expect("limiter");
407
408 limiter.acquire(NonZeroU32::new(1).unwrap()).await;
409 let start = Instant::now();
410 limiter.acquire(NonZeroU32::new(1).unwrap()).await;
411 let elapsed = start.elapsed();
412 assert!(elapsed >= StdDuration::from_millis(900));
413 }
414}