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-{:08x}", h >> 32)
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 usage(&self, account: &Arc<str>) -> Usage {
195 let day = today();
196 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
197 if let Some(u) = map.get(account).filter(|u| u.day == day) {
198 return *u;
199 }
200 let stored = self
202 .store
203 .as_ref()
204 .and_then(|s| s.tasks().kv_get(usage_key(account)).ok().flatten())
205 .and_then(|v| {
206 let text = String::from_utf8(v).ok()?;
207 let (d, n) = text.split_once(' ')?;
208 Some(Usage {
209 day: d.parse().ok()?,
210 used: n.parse().ok()?,
211 })
212 })
213 .filter(|u| u.day == day);
214 let u = stored.unwrap_or(Usage { day, used: 0 });
215 map.insert(account.clone(), u);
216 u
217 }
218
219 pub(crate) fn remaining_pages(&self, c: &Caller) -> Option<u64> {
221 let max = c.limits.pages_per_day?;
222 if let Some(l) = &self.ledger {
223 return l.balance(&c.account);
224 }
225 Some(max.saturating_sub(self.usage(&c.account).used))
226 }
227
228 pub(crate) fn check_quota(&self, c: &Caller) -> Result<(), ApiError> {
229 if self.remaining_pages(c) == Some(0) {
230 return Err(quota_error(c));
231 }
232 Ok(())
233 }
234
235 pub(crate) async fn consume_pages(&self, c: &Caller, n: u64) {
237 if c.limits.pages_per_day.is_none() || n == 0 {
238 return;
239 }
240 if let Some(l) = &self.ledger {
241 match l.reserve(&c.account, n) {
244 Ok(hold) => {
245 if let Err(e) = l.settle(&hold, n) {
246 tracing::warn!(error = %e, "settling page quota failed");
247 }
248 }
249 Err(pagesift_cluster::Refusal::Error(e)) => {
250 tracing::warn!(error = %e, "reserving page quota failed");
251 }
252 Err(_) => {}
253 }
254 return;
255 }
256 let u = self.usage(&c.account);
257 let u = {
258 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
259 let e = map.entry(c.account.clone()).or_insert(u);
260 if e.day != u.day {
261 *e = u;
262 }
263 e.used += n;
264 *e
265 };
266 if let Some(store) = &self.store {
267 let key = usage_key(&c.account);
268 let value = format!("{} {}", u.day, u.used);
269 let r = store
270 .blocking(move |s| s.tasks().kv_put(key, value.as_bytes()))
271 .await;
272 if let Err(e) = r {
273 tracing::warn!(error = %e, "saving page quota usage failed");
274 }
275 }
276 }
277}
278
279pub(crate) fn quota_error(c: &Caller) -> ApiError {
280 ApiError::new(
281 ErrorCode::QuotaExceeded,
282 format!(
283 "daily page quota exceeded: {} pages per day for this API key",
284 c.limits.pages_per_day.unwrap_or(0)
285 ),
286 )
287 .with_retry_after(secs_to_midnight())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 fn caller(limits: KeyLimits) -> Caller {
295 Caller::new(Some("secret-key"), Arc::new(limits))
296 }
297
298 #[test]
299 fn token_bucket_allows_a_burst_then_429s() {
300 let l = Limiter::default();
301 let c = caller(KeyLimits {
302 requests_per_minute: Some(3),
303 ..Default::default()
304 });
305 for _ in 0..3 {
306 l.check_rate(&c).unwrap();
307 }
308 let e = l.check_rate(&c).unwrap_err();
309 assert_eq!(e.code, ErrorCode::RateLimitExceeded);
310 assert!(e.retry_after.unwrap() >= 1);
311 let other = Caller::new(Some("other"), c.limits.clone());
313 l.check_rate(&other).unwrap();
314 l.check_rate(&caller(KeyLimits::default())).unwrap();
316 }
317
318 #[test]
319 fn accounts_use_names_or_fingerprints() {
320 let named = Caller::new(
321 Some("k"),
322 Arc::new(KeyLimits {
323 name: Some("team-a".into()),
324 ..Default::default()
325 }),
326 );
327 assert_eq!(&*named.account, "team-a");
328 let anon = caller(KeyLimits::default());
329 assert!(anon.account.starts_with("key-") && !anon.account.contains("secret"));
330 assert_eq!(&*Caller::new(None, Arc::default()).account, "anonymous");
331 }
332
333 #[tokio::test]
335 async fn ledger_backed_limits_match_the_local_ones() {
336 let ledger = Arc::new(
337 pagesift_cluster::Ledger::open(pagesift_cluster::LedgerConfig::new(
338 pagesift_cluster::MonoClock::new(),
339 false,
340 ))
341 .unwrap(),
342 );
343 let limits = Arc::new(KeyLimits {
344 requests_per_minute: Some(2),
345 pages_per_day: Some(2),
346 ..Default::default()
347 });
348 let c = Caller::new(Some("k"), limits.clone());
349 let accounts = HashMap::from([(c.account.to_string(), limits)]);
350 let l = Limiter::with_ledger(ledger, &accounts);
351 l.check_rate(&c).unwrap();
352 l.check_rate(&c).unwrap();
353 let e = l.check_rate(&c).unwrap_err();
354 assert_eq!(e.code, ErrorCode::RateLimitExceeded);
355 assert!(e.retry_after.unwrap() >= 1);
356 assert_eq!(l.remaining_pages(&c), Some(2));
357 l.consume_pages(&c, 1).await;
358 assert!(l.check_quota(&c).is_ok());
359 l.consume_pages(&c, 1).await;
360 assert_eq!(l.remaining_pages(&c), Some(0));
361 assert_eq!(
362 l.check_quota(&c).unwrap_err().code,
363 ErrorCode::QuotaExceeded
364 );
365 l.check_rate(&caller(KeyLimits::default())).unwrap();
367 }
368
369 #[tokio::test]
370 async fn page_quota_survives_a_restart() {
371 let dir = tempfile::tempdir().unwrap();
372 let c = caller(KeyLimits {
373 pages_per_day: Some(5),
374 ..Default::default()
375 });
376 {
377 let store = Store::open_dir(dir.path()).unwrap();
378 let l = Limiter::new(Some(store));
379 assert_eq!(l.remaining_pages(&c), Some(5));
380 l.consume_pages(&c, 4).await;
381 assert!(l.check_quota(&c).is_ok());
382 l.consume_pages(&c, 1).await;
383 assert_eq!(
384 l.check_quota(&c).unwrap_err().code,
385 ErrorCode::QuotaExceeded
386 );
387 }
388 let l = Limiter::new(Some(Store::open_dir(dir.path()).unwrap()));
389 assert_eq!(l.remaining_pages(&c), Some(0));
390 let jobs = caller(KeyLimits {
391 max_concurrent_jobs: Some(1),
392 ..Default::default()
393 });
394 assert!(l.check_jobs(&jobs, 0).is_ok());
395 assert_eq!(
396 l.check_jobs(&jobs, 1).unwrap_err().code,
397 ErrorCode::ConcurrencyLimitExceeded
398 );
399 }
400}