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