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}
87
88fn rate_error(rpm: u32, wait: u64) -> ApiError {
89 ApiError::new(
90 ErrorCode::RateLimitExceeded,
91 format!(
92 "rate limit exceeded: {rpm} requests per minute for this API key; retry after {wait}s"
93 ),
94 )
95 .with_retry_after(wait)
96}
97
98fn ledger_error(e: impl std::fmt::Display) -> ApiError {
99 ApiError::new(ErrorCode::Internal, format!("quota ledger: {e}"))
100}
101
102fn today() -> u64 {
103 SystemTime::now()
104 .duration_since(UNIX_EPOCH)
105 .map_or(0, |d| d.as_secs() / 86_400)
106}
107
108fn secs_to_midnight() -> u64 {
109 let now = SystemTime::now()
110 .duration_since(UNIX_EPOCH)
111 .map_or(0, |d| d.as_secs());
112 86_400 - now % 86_400
113}
114
115fn usage_key(account: &str) -> String {
116 format!("quota/{account}")
117}
118
119impl Limiter {
120 pub(crate) fn new(store: Option<Store>) -> Self {
121 Self {
122 store,
123 ..Default::default()
124 }
125 }
126
127 pub(crate) fn with_ledger(
129 ledger: Arc<pagesift_cluster::Ledger>,
130 accounts: &HashMap<String, Arc<KeyLimits>>,
131 ) -> Self {
132 for (name, l) in accounts {
133 ledger.set_limits(
134 name,
135 pagesift_cluster::AccountLimits {
136 pages_per_day: l.pages_per_day,
137 requests_per_minute: l.requests_per_minute,
138 },
139 );
140 }
141 Self {
142 ledger: Some(ledger),
143 ..Default::default()
144 }
145 }
146
147 pub(crate) fn check_rate(&self, c: &Caller) -> Result<(), ApiError> {
149 let Some(rpm) = c.limits.requests_per_minute else {
150 return Ok(());
151 };
152 if let Some(l) = &self.ledger {
153 use pagesift_cluster::Refusal;
154 return match l.check_rate(&c.account) {
155 Ok(()) => Ok(()),
156 Err(Refusal::Rate { retry_after, .. }) => Err(rate_error(
157 rpm,
158 retry_after.as_secs_f64().ceil().max(1.0) as u64,
159 )),
160 Err(Refusal::Quota { .. }) => Err(quota_error(c)),
161 Err(Refusal::Error(e)) => Err(ledger_error(e)),
162 };
163 }
164 let cap = f64::from(rpm.max(1));
165 let per_sec = cap / 60.0;
166 let now = Instant::now();
167 let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
168 let b = buckets.entry(c.account.clone()).or_insert(Bucket {
169 tokens: cap,
170 last: now,
171 });
172 b.tokens = (b.tokens + now.duration_since(b.last).as_secs_f64() * per_sec).min(cap);
173 b.last = now;
174 if b.tokens >= 1.0 {
175 b.tokens -= 1.0;
176 return Ok(());
177 }
178 let wait = ((1.0 - b.tokens) / per_sec).ceil().max(1.0) as u64;
179 Err(rate_error(rpm, wait))
180 }
181
182 pub(crate) fn check_jobs(&self, c: &Caller, active: usize) -> Result<(), ApiError> {
184 match c.limits.max_concurrent_jobs {
185 Some(max) if active >= max => Err(ApiError::new(
186 ErrorCode::ConcurrencyLimitExceeded,
187 format!("concurrency limit exceeded: at most {max} crawl or batch jobs may run at once for this API key"),
188 )
189 .with_retry_after(5)),
190 _ => Ok(()),
191 }
192 }
193
194 fn load_usage(&self, account: &Arc<str>, day: u64) -> Usage {
195 let stored = self
197 .store
198 .as_ref()
199 .and_then(|s| s.tasks().kv_get(usage_key(account)).ok().flatten())
200 .and_then(|v| {
201 let text = String::from_utf8(v).ok()?;
202 let (d, n) = text.split_once(' ')?;
203 Some(Usage {
204 day: d.parse().ok()?,
205 used: n.parse().ok()?,
206 })
207 })
208 .filter(|u| u.day == day);
209 stored.unwrap_or(Usage { day, used: 0 })
210 }
211
212 fn usage_entry<'a>(
213 &self,
214 map: &'a mut HashMap<Arc<str>, Usage>,
215 account: &Arc<str>,
216 day: u64,
217 ) -> &'a mut Usage {
218 if map.get(account).is_none_or(|u| u.day != day) {
219 let u = self.load_usage(account, day);
220 map.insert(account.clone(), u);
221 }
222 map.get_mut(account).expect("inserted above")
223 }
224
225 fn usage(&self, account: &Arc<str>) -> Usage {
226 let day = today();
227 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
228 *self.usage_entry(&mut map, account, day)
229 }
230
231 pub(crate) fn remaining_pages(&self, c: &Caller) -> Option<u64> {
233 let max = c.limits.pages_per_day?;
234 if let Some(l) = &self.ledger {
235 return l.balance(&c.account);
236 }
237 Some(max.saturating_sub(self.usage(&c.account).used))
238 }
239
240 pub(crate) fn check_quota(&self, c: &Caller) -> Result<(), ApiError> {
241 if self.remaining_pages(c) == Some(0) {
242 return Err(quota_error(c));
243 }
244 Ok(())
245 }
246
247 pub(crate) async fn reserve_page(self: &Arc<Self>, c: &Caller) -> Result<PageHold, ApiError> {
253 let day = today();
254 let amount = 1;
255 let Some(_) = c.limits.pages_per_day else {
256 return Ok(PageHold {
257 limiter: self.clone(),
258 account: c.account.clone(),
259 day,
260 amount,
261 ledger: None,
262 tracked: false,
263 settled: false,
264 });
265 };
266 if let Some(ledger) = &self.ledger {
267 let hold = ledger
268 .reserve(&c.account, amount)
269 .map_err(|err| match err {
270 pagesift_cluster::Refusal::Error(err) => ledger_error(err),
271 _ => quota_error(c),
272 })?;
273 return Ok(PageHold {
274 limiter: self.clone(),
275 account: c.account.clone(),
276 day,
277 amount,
278 ledger: Some(hold),
279 tracked: true,
280 settled: false,
281 });
282 }
283 let max = c.limits.pages_per_day.unwrap_or(0);
284 {
285 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
286 let u = self.usage_entry(&mut map, &c.account, day);
287 if u.used.saturating_add(amount) > max {
288 return Err(quota_error(c));
289 }
290 u.used += amount;
291 }
292 Ok(PageHold {
293 limiter: self.clone(),
294 account: c.account.clone(),
295 day,
296 amount,
297 ledger: None,
298 tracked: true,
299 settled: false,
300 })
301 }
302
303 fn refund(&self, account: &Arc<str>, day: u64, amount: u64) {
305 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
306 if let Some(u) = map.get_mut(account)
307 && u.day == day
308 {
309 u.used = u.used.saturating_sub(amount);
310 }
311 }
312
313 async fn persist_usage(&self, account: &Arc<str>) {
314 let Some(store) = &self.store else {
315 return;
316 };
317 let u = {
318 let map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
319 match map.get(account) {
320 Some(u) => *u,
321 None => return,
322 }
323 };
324 let key = usage_key(account);
325 let value = format!("{} {}", u.day, u.used);
326 let r = store
327 .blocking(move |s| s.tasks().kv_put(key, value.as_bytes()))
328 .await;
329 if let Err(e) = r {
330 tracing::warn!(error = %e, "saving page quota usage failed");
331 }
332 }
333}
334
335pub(crate) struct PageHold {
337 limiter: Arc<Limiter>,
338 account: Arc<str>,
339 day: u64,
340 amount: u64,
341 ledger: Option<pagesift_cluster::Hold>,
342 tracked: bool,
343 settled: bool,
344}
345
346impl PageHold {
347 pub(crate) async fn commit(mut self) {
348 self.settled = true;
349 if !self.tracked {
350 return;
351 }
352 if let Some(ledger) = &self.limiter.ledger {
353 if let Some(hold) = self.ledger.take()
354 && let Err(e) = ledger.settle(&hold, self.amount)
355 {
356 tracing::warn!(error = %e, "settling page quota failed");
357 }
358 return;
359 }
360 self.limiter.persist_usage(&self.account).await;
361 }
362}
363
364impl Drop for PageHold {
365 fn drop(&mut self) {
366 if self.settled || !self.tracked {
367 return;
368 }
369 if let Some(ledger) = &self.limiter.ledger {
370 if let Some(hold) = self.ledger.take()
371 && let Err(e) = ledger.release(&hold)
372 {
373 tracing::warn!(error = %e, "releasing page quota failed");
374 }
375 return;
376 }
377 self.limiter.refund(&self.account, self.day, self.amount);
378 }
379}
380
381pub(crate) fn quota_error(c: &Caller) -> ApiError {
382 ApiError::new(
383 ErrorCode::QuotaExceeded,
384 format!(
385 "daily page quota exceeded: {} pages per day for this API key",
386 c.limits.pages_per_day.unwrap_or(0)
387 ),
388 )
389 .with_retry_after(secs_to_midnight())
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn caller(limits: KeyLimits) -> Caller {
397 Caller::new(Some("secret-key"), Arc::new(limits))
398 }
399
400 #[test]
401 fn token_bucket_allows_a_burst_then_429s() {
402 let l = Limiter::default();
403 let c = caller(KeyLimits {
404 requests_per_minute: Some(3),
405 ..Default::default()
406 });
407 for _ in 0..3 {
408 l.check_rate(&c).unwrap();
409 }
410 let e = l.check_rate(&c).unwrap_err();
411 assert_eq!(e.code, ErrorCode::RateLimitExceeded);
412 assert!(e.retry_after.unwrap() >= 1);
413 let other = Caller::new(Some("other"), c.limits.clone());
415 l.check_rate(&other).unwrap();
416 l.check_rate(&caller(KeyLimits::default())).unwrap();
418 }
419
420 #[test]
421 fn accounts_use_names_or_fingerprints() {
422 let named = Caller::new(
423 Some("k"),
424 Arc::new(KeyLimits {
425 name: Some("team-a".into()),
426 ..Default::default()
427 }),
428 );
429 assert_eq!(&*named.account, "team-a");
430 let anon = caller(KeyLimits::default());
431 assert!(anon.account.starts_with("key-") && !anon.account.contains("secret"));
432 assert_eq!(&*Caller::new(None, Arc::default()).account, "anonymous");
433 }
434
435 #[tokio::test]
437 async fn ledger_backed_limits_match_the_local_ones() {
438 let ledger = Arc::new(
439 pagesift_cluster::Ledger::open(pagesift_cluster::LedgerConfig::new(
440 pagesift_cluster::MonoClock::new(),
441 false,
442 ))
443 .unwrap(),
444 );
445 let limits = Arc::new(KeyLimits {
446 requests_per_minute: Some(2),
447 pages_per_day: Some(2),
448 ..Default::default()
449 });
450 let c = Caller::new(Some("k"), limits.clone());
451 let accounts = HashMap::from([(c.account.to_string(), limits)]);
452 let l = Arc::new(Limiter::with_ledger(ledger, &accounts));
453 l.check_rate(&c).unwrap();
454 l.check_rate(&c).unwrap();
455 let e = l.check_rate(&c).unwrap_err();
456 assert_eq!(e.code, ErrorCode::RateLimitExceeded);
457 assert!(e.retry_after.unwrap() >= 1);
458 assert_eq!(l.remaining_pages(&c), Some(2));
459 l.reserve_page(&c).await.unwrap().commit().await;
460 assert!(l.check_quota(&c).is_ok());
461 l.reserve_page(&c).await.unwrap().commit().await;
462 assert_eq!(l.remaining_pages(&c), Some(0));
463 assert_eq!(
464 l.check_quota(&c).unwrap_err().code,
465 ErrorCode::QuotaExceeded
466 );
467 l.check_rate(&caller(KeyLimits::default())).unwrap();
469 }
470
471 #[tokio::test]
472 async fn concurrent_page_reservations_stop_at_quota() {
473 let c = caller(KeyLimits {
474 pages_per_day: Some(1),
475 ..Default::default()
476 });
477 let limiter = Arc::new(Limiter::new(None));
478 let (a, b) = tokio::join!(limiter.reserve_page(&c), limiter.reserve_page(&c));
479 assert_ne!(a.is_ok(), b.is_ok());
480 assert_eq!(limiter.remaining_pages(&c), Some(0));
481 drop(a);
483 drop(b);
484 assert_eq!(limiter.remaining_pages(&c), Some(1));
485 }
486
487 #[tokio::test]
488 async fn twenty_concurrent_reservations_allow_exactly_one() {
489 let c = Arc::new(caller(KeyLimits {
490 pages_per_day: Some(1),
491 ..Default::default()
492 }));
493 let limiter = Arc::new(Limiter::new(None));
494 let mut tasks = Vec::new();
495 for _ in 0..20 {
496 let (limiter, c) = (limiter.clone(), c.clone());
497 tasks.push(tokio::spawn(async move { limiter.reserve_page(&c).await }));
498 }
499 let mut granted = 0;
500 let mut holds = Vec::new();
501 for task in tasks {
502 match task.await.unwrap() {
503 Ok(hold) => {
504 granted += 1;
505 holds.push(hold);
506 }
507 Err(e) => assert_eq!(e.code, ErrorCode::QuotaExceeded),
508 }
509 }
510 assert_eq!(granted, 1);
511 assert_eq!(limiter.remaining_pages(&c), Some(0));
512 drop(holds);
513 assert_eq!(limiter.remaining_pages(&c), Some(1));
514 }
515
516 #[tokio::test]
517 async fn committed_pages_survive_restart_and_holds_refund() {
518 let dir = tempfile::tempdir().unwrap();
519 let c = caller(KeyLimits {
520 pages_per_day: Some(2),
521 ..Default::default()
522 });
523 let limiter = Arc::new(Limiter::new(Some(Store::open_dir(dir.path()).unwrap())));
524 let hold = limiter.reserve_page(&c).await.unwrap();
525 assert_eq!(limiter.remaining_pages(&c), Some(1));
526 drop(hold);
527 assert_eq!(limiter.remaining_pages(&c), Some(2));
528 let hold = limiter.reserve_page(&c).await.unwrap();
529 hold.commit().await;
530 assert_eq!(limiter.remaining_pages(&c), Some(1));
531 drop(limiter);
532 let limiter = Limiter::new(Some(Store::open_dir(dir.path()).unwrap()));
534 assert_eq!(limiter.remaining_pages(&c), Some(1));
535 }
536
537 #[tokio::test]
538 async fn ledger_reservations_are_atomic_across_limiter_instances() {
539 let ledger = Arc::new(
540 pagesift_cluster::Ledger::open(pagesift_cluster::LedgerConfig::new(
541 pagesift_cluster::MonoClock::new(),
542 false,
543 ))
544 .unwrap(),
545 );
546 let limits = Arc::new(KeyLimits {
547 pages_per_day: Some(1),
548 ..Default::default()
549 });
550 let c = Arc::new(Caller::new(Some("k"), limits.clone()));
551 let accounts = HashMap::from([(c.account.to_string(), limits)]);
552 let a = Arc::new(Limiter::with_ledger(ledger.clone(), &accounts));
553 let b = Arc::new(Limiter::with_ledger(ledger.clone(), &accounts));
554 let mut tasks = Vec::new();
555 for i in 0..20 {
556 let (l, c) = (if i % 2 == 0 { a.clone() } else { b.clone() }, c.clone());
557 tasks.push(tokio::spawn(async move { l.reserve_page(&c).await }));
558 }
559 let mut holds = Vec::new();
560 let mut granted = 0;
561 for task in tasks {
562 match task.await.unwrap() {
563 Ok(hold) => {
564 granted += 1;
565 holds.push(hold);
566 }
567 Err(e) => assert_eq!(e.code, ErrorCode::QuotaExceeded),
568 }
569 }
570 assert_eq!(granted, 1);
571 assert_eq!(a.remaining_pages(&c), Some(0));
572 drop(holds);
573 assert_eq!(a.remaining_pages(&c), Some(1));
574 }
575
576 #[tokio::test]
577 async fn page_quota_survives_a_restart() {
578 let dir = tempfile::tempdir().unwrap();
579 let c = caller(KeyLimits {
580 pages_per_day: Some(5),
581 ..Default::default()
582 });
583 {
584 let store = Store::open_dir(dir.path()).unwrap();
585 let l = Arc::new(Limiter::new(Some(store)));
586 assert_eq!(l.remaining_pages(&c), Some(5));
587 for _ in 0..4 {
588 l.reserve_page(&c).await.unwrap().commit().await;
589 }
590 assert!(l.check_quota(&c).is_ok());
591 l.reserve_page(&c).await.unwrap().commit().await;
592 assert_eq!(
593 l.check_quota(&c).unwrap_err().code,
594 ErrorCode::QuotaExceeded
595 );
596 }
597 let l = Limiter::new(Some(Store::open_dir(dir.path()).unwrap()));
598 assert_eq!(l.remaining_pages(&c), Some(0));
599 let jobs = caller(KeyLimits {
600 max_concurrent_jobs: Some(1),
601 ..Default::default()
602 });
603 assert!(l.check_jobs(&jobs, 0).is_ok());
604 assert_eq!(
605 l.check_jobs(&jobs, 1).unwrap_err().code,
606 ErrorCode::ConcurrencyLimitExceeded
607 );
608 }
609}