1use std::collections::HashMap;
11use std::sync::{Arc, Mutex};
12use std::time::{Instant, SystemTime, UNIX_EPOCH};
13
14use pagesift_store::Store;
15
16use crate::error::{ApiError, ErrorCode};
17
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct KeyLimits {
21 pub name: Option<String>,
23 pub requests_per_minute: Option<u32>,
24 pub max_concurrent_jobs: Option<usize>,
25 pub pages_per_day: Option<u64>,
26}
27
28#[derive(Debug, Clone)]
30pub(crate) struct Caller {
31 pub account: Arc<str>,
32 pub limits: Arc<KeyLimits>,
33}
34
35impl Caller {
36 pub(crate) fn new(key: Option<&str>, limits: Arc<KeyLimits>) -> Self {
37 let account = match (&limits.name, key) {
38 (Some(n), _) => n.clone(),
39 (None, Some(k)) => fingerprint(k),
40 (None, None) => "anonymous".into(),
41 };
42 Self {
43 account: account.into(),
44 limits,
45 }
46 }
47}
48
49impl Caller {
50 pub(crate) fn named(account: &str, limits: Arc<KeyLimits>) -> Self {
52 Self {
53 account: account.into(),
54 limits,
55 }
56 }
57}
58
59fn fingerprint(key: &str) -> String {
61 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
62 for b in key.bytes() {
63 h ^= u64::from(b);
64 h = h.wrapping_mul(0x0100_0000_01b3);
65 }
66 format!("key-{h:016x}")
67}
68
69struct Bucket {
70 tokens: f64,
71 last: Instant,
72}
73
74#[derive(Clone, Copy)]
75struct Usage {
76 day: u64,
77 used: u64,
78}
79
80#[derive(Default)]
81pub(crate) struct Limiter {
82 buckets: Mutex<HashMap<Arc<str>, Bucket>>,
83 usage: Mutex<HashMap<Arc<str>, Usage>>,
84 store: Option<Store>,
85 ledger: Option<Arc<pagesift_cluster::Ledger>>,
86 page_reservation: tokio::sync::Mutex<()>,
87}
88
89fn rate_error(rpm: u32, wait: u64) -> ApiError {
90 ApiError::new(
91 ErrorCode::RateLimitExceeded,
92 format!(
93 "rate limit exceeded: {rpm} requests per minute for this API key; retry after {wait}s"
94 ),
95 )
96 .with_retry_after(wait)
97}
98
99fn ledger_error(e: impl std::fmt::Display) -> ApiError {
100 ApiError::new(ErrorCode::Internal, format!("quota ledger: {e}"))
101}
102
103fn today() -> u64 {
104 SystemTime::now()
105 .duration_since(UNIX_EPOCH)
106 .map_or(0, |d| d.as_secs() / 86_400)
107}
108
109fn secs_to_midnight() -> u64 {
110 let now = SystemTime::now()
111 .duration_since(UNIX_EPOCH)
112 .map_or(0, |d| d.as_secs());
113 86_400 - now % 86_400
114}
115
116fn usage_key(account: &str) -> String {
117 format!("quota/{account}")
118}
119
120impl Limiter {
121 pub(crate) fn new(store: Option<Store>) -> Self {
122 Self {
123 store,
124 ..Default::default()
125 }
126 }
127
128 pub(crate) fn with_ledger(
130 ledger: Arc<pagesift_cluster::Ledger>,
131 accounts: &HashMap<String, Arc<KeyLimits>>,
132 ) -> Self {
133 for (name, l) in accounts {
134 ledger.set_limits(
135 name,
136 pagesift_cluster::AccountLimits {
137 pages_per_day: l.pages_per_day,
138 requests_per_minute: l.requests_per_minute,
139 },
140 );
141 }
142 Self {
143 ledger: Some(ledger),
144 ..Default::default()
145 }
146 }
147
148 pub(crate) fn check_rate(&self, c: &Caller) -> Result<(), ApiError> {
150 let Some(rpm) = c.limits.requests_per_minute else {
151 return Ok(());
152 };
153 if let Some(l) = &self.ledger {
154 use pagesift_cluster::Refusal;
155 return match l.check_rate(&c.account) {
156 Ok(()) => Ok(()),
157 Err(Refusal::Rate { retry_after, .. }) => Err(rate_error(
158 rpm,
159 retry_after.as_secs_f64().ceil().max(1.0) as u64,
160 )),
161 Err(Refusal::Quota { .. }) => Err(quota_error(c)),
162 Err(Refusal::Error(e)) => Err(ledger_error(e)),
163 };
164 }
165 let cap = f64::from(rpm.max(1));
166 let per_sec = cap / 60.0;
167 let now = Instant::now();
168 let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
169 let b = buckets.entry(c.account.clone()).or_insert(Bucket {
170 tokens: cap,
171 last: now,
172 });
173 b.tokens = (b.tokens + now.duration_since(b.last).as_secs_f64() * per_sec).min(cap);
174 b.last = now;
175 if b.tokens >= 1.0 {
176 b.tokens -= 1.0;
177 return Ok(());
178 }
179 let wait = ((1.0 - b.tokens) / per_sec).ceil().max(1.0) as u64;
180 Err(rate_error(rpm, wait))
181 }
182
183 pub(crate) fn check_jobs(&self, c: &Caller, active: usize) -> Result<(), ApiError> {
185 match c.limits.max_concurrent_jobs {
186 Some(max) if active >= max => Err(ApiError::new(
187 ErrorCode::ConcurrencyLimitExceeded,
188 format!("concurrency limit exceeded: at most {max} crawl or batch jobs may run at once for this API key"),
189 )
190 .with_retry_after(5)),
191 _ => Ok(()),
192 }
193 }
194
195 fn usage(&self, account: &Arc<str>) -> Usage {
196 let day = today();
197 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
198 if let Some(u) = map.get(account).filter(|u| u.day == day) {
199 return *u;
200 }
201 let stored = self
203 .store
204 .as_ref()
205 .and_then(|s| s.tasks().kv_get(usage_key(account)).ok().flatten())
206 .and_then(|v| {
207 let text = String::from_utf8(v).ok()?;
208 let (d, n) = text.split_once(' ')?;
209 Some(Usage {
210 day: d.parse().ok()?,
211 used: n.parse().ok()?,
212 })
213 })
214 .filter(|u| u.day == day);
215 let u = stored.unwrap_or(Usage { day, used: 0 });
216 map.insert(account.clone(), u);
217 u
218 }
219
220 pub(crate) fn remaining_pages(&self, c: &Caller) -> Option<u64> {
222 let max = c.limits.pages_per_day?;
223 if let Some(l) = &self.ledger {
224 return l.balance(&c.account);
225 }
226 Some(max.saturating_sub(self.usage(&c.account).used))
227 }
228
229 pub(crate) fn check_quota(&self, c: &Caller) -> Result<(), ApiError> {
230 if self.remaining_pages(c) == Some(0) {
231 return Err(quota_error(c));
232 }
233 Ok(())
234 }
235
236 pub(crate) async fn reserve_page(&self, c: &Caller) -> Result<(), ApiError> {
237 let _guard = self.page_reservation.lock().await;
238 if c.limits.pages_per_day.is_some()
239 && let Some(ledger) = &self.ledger
240 {
241 let hold = ledger.reserve(&c.account, 1).map_err(|err| match err {
242 pagesift_cluster::Refusal::Error(err) => ledger_error(err),
243 _ => quota_error(c),
244 })?;
245 return ledger.settle(&hold, 1).map_err(ledger_error);
246 }
247 self.check_quota(c)?;
248 self.consume_pages(c, 1).await;
249 Ok(())
250 }
251
252 pub(crate) async fn consume_pages(&self, c: &Caller, n: u64) {
254 if c.limits.pages_per_day.is_none() || n == 0 {
255 return;
256 }
257 if let Some(l) = &self.ledger {
258 match l.reserve(&c.account, n) {
261 Ok(hold) => {
262 if let Err(e) = l.settle(&hold, n) {
263 tracing::warn!(error = %e, "settling page quota failed");
264 }
265 }
266 Err(pagesift_cluster::Refusal::Error(e)) => {
267 tracing::warn!(error = %e, "reserving page quota failed");
268 }
269 Err(_) => {}
270 }
271 return;
272 }
273 let u = self.usage(&c.account);
274 let u = {
275 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
276 let e = map.entry(c.account.clone()).or_insert(u);
277 if e.day != u.day {
278 *e = u;
279 }
280 e.used += n;
281 *e
282 };
283 if let Some(store) = &self.store {
284 let key = usage_key(&c.account);
285 let value = format!("{} {}", u.day, u.used);
286 let r = store
287 .blocking(move |s| s.tasks().kv_put(key, value.as_bytes()))
288 .await;
289 if let Err(e) = r {
290 tracing::warn!(error = %e, "saving page quota usage failed");
291 }
292 }
293 }
294}
295
296pub(crate) fn quota_error(c: &Caller) -> ApiError {
297 ApiError::new(
298 ErrorCode::QuotaExceeded,
299 format!(
300 "daily page quota exceeded: {} pages per day for this API key",
301 c.limits.pages_per_day.unwrap_or(0)
302 ),
303 )
304 .with_retry_after(secs_to_midnight())
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn caller(limits: KeyLimits) -> Caller {
312 Caller::new(Some("secret-key"), Arc::new(limits))
313 }
314
315 #[test]
316 fn token_bucket_allows_a_burst_then_429s() {
317 let l = Limiter::default();
318 let c = caller(KeyLimits {
319 requests_per_minute: Some(3),
320 ..Default::default()
321 });
322 for _ in 0..3 {
323 l.check_rate(&c).unwrap();
324 }
325 let e = l.check_rate(&c).unwrap_err();
326 assert_eq!(e.code, ErrorCode::RateLimitExceeded);
327 assert!(e.retry_after.unwrap() >= 1);
328 let other = Caller::new(Some("other"), c.limits.clone());
330 l.check_rate(&other).unwrap();
331 l.check_rate(&caller(KeyLimits::default())).unwrap();
333 }
334
335 #[test]
336 fn accounts_use_names_or_fingerprints() {
337 let named = Caller::new(
338 Some("k"),
339 Arc::new(KeyLimits {
340 name: Some("team-a".into()),
341 ..Default::default()
342 }),
343 );
344 assert_eq!(&*named.account, "team-a");
345 let anon = caller(KeyLimits::default());
346 assert!(anon.account.starts_with("key-") && !anon.account.contains("secret"));
347 assert_eq!(&*Caller::new(None, Arc::default()).account, "anonymous");
348 }
349
350 #[tokio::test]
352 async fn ledger_backed_limits_match_the_local_ones() {
353 let ledger = Arc::new(
354 pagesift_cluster::Ledger::open(pagesift_cluster::LedgerConfig::new(
355 pagesift_cluster::MonoClock::new(),
356 false,
357 ))
358 .unwrap(),
359 );
360 let limits = Arc::new(KeyLimits {
361 requests_per_minute: Some(2),
362 pages_per_day: Some(2),
363 ..Default::default()
364 });
365 let c = Caller::new(Some("k"), limits.clone());
366 let accounts = HashMap::from([(c.account.to_string(), limits)]);
367 let l = Limiter::with_ledger(ledger, &accounts);
368 l.check_rate(&c).unwrap();
369 l.check_rate(&c).unwrap();
370 let e = l.check_rate(&c).unwrap_err();
371 assert_eq!(e.code, ErrorCode::RateLimitExceeded);
372 assert!(e.retry_after.unwrap() >= 1);
373 assert_eq!(l.remaining_pages(&c), Some(2));
374 l.consume_pages(&c, 1).await;
375 assert!(l.check_quota(&c).is_ok());
376 l.consume_pages(&c, 1).await;
377 assert_eq!(l.remaining_pages(&c), Some(0));
378 assert_eq!(
379 l.check_quota(&c).unwrap_err().code,
380 ErrorCode::QuotaExceeded
381 );
382 l.check_rate(&caller(KeyLimits::default())).unwrap();
384 }
385
386 #[tokio::test]
387 async fn concurrent_page_reservations_stop_at_quota() {
388 let c = caller(KeyLimits {
389 pages_per_day: Some(1),
390 ..Default::default()
391 });
392 let limiter = Limiter::new(None);
393 let (a, b) = tokio::join!(limiter.reserve_page(&c), limiter.reserve_page(&c));
394 assert_ne!(a.is_ok(), b.is_ok());
395 assert_eq!(limiter.remaining_pages(&c), Some(0));
396 }
397
398 #[tokio::test]
399 async fn page_quota_survives_a_restart() {
400 let dir = tempfile::tempdir().unwrap();
401 let c = caller(KeyLimits {
402 pages_per_day: Some(5),
403 ..Default::default()
404 });
405 {
406 let store = Store::open_dir(dir.path()).unwrap();
407 let l = Limiter::new(Some(store));
408 assert_eq!(l.remaining_pages(&c), Some(5));
409 l.consume_pages(&c, 4).await;
410 assert!(l.check_quota(&c).is_ok());
411 l.consume_pages(&c, 1).await;
412 assert_eq!(
413 l.check_quota(&c).unwrap_err().code,
414 ErrorCode::QuotaExceeded
415 );
416 }
417 let l = Limiter::new(Some(Store::open_dir(dir.path()).unwrap()));
418 assert_eq!(l.remaining_pages(&c), Some(0));
419 let jobs = caller(KeyLimits {
420 max_concurrent_jobs: Some(1),
421 ..Default::default()
422 });
423 assert!(l.check_jobs(&jobs, 0).is_ok());
424 assert_eq!(
425 l.check_jobs(&jobs, 1).unwrap_err().code,
426 ErrorCode::ConcurrencyLimitExceeded
427 );
428 }
429}