1use std::collections::HashMap;
7use std::sync::{Arc, Mutex};
8use std::time::{Instant, SystemTime, UNIX_EPOCH};
9
10use pagesift_store::Store;
11
12use crate::error::{ApiError, ErrorCode};
13
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct KeyLimits {
17 pub name: Option<String>,
19 pub requests_per_minute: Option<u32>,
20 pub max_concurrent_jobs: Option<usize>,
21 pub pages_per_day: Option<u64>,
22}
23
24#[derive(Debug, Clone)]
26pub(crate) struct Caller {
27 pub account: Arc<str>,
28 pub limits: Arc<KeyLimits>,
29}
30
31impl Caller {
32 pub(crate) fn new(key: Option<&str>, limits: Arc<KeyLimits>) -> Self {
33 let account = match (&limits.name, key) {
34 (Some(n), _) => n.clone(),
35 (None, Some(k)) => fingerprint(k),
36 (None, None) => "anonymous".into(),
37 };
38 Self {
39 account: account.into(),
40 limits,
41 }
42 }
43}
44
45impl Caller {
46 pub(crate) fn named(account: &str, limits: Arc<KeyLimits>) -> Self {
48 Self {
49 account: account.into(),
50 limits,
51 }
52 }
53}
54
55fn fingerprint(key: &str) -> String {
57 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
58 for b in key.bytes() {
59 h ^= u64::from(b);
60 h = h.wrapping_mul(0x0100_0000_01b3);
61 }
62 format!("key-{:08x}", h >> 32)
63}
64
65struct Bucket {
66 tokens: f64,
67 last: Instant,
68}
69
70#[derive(Clone, Copy)]
71struct Usage {
72 day: u64,
73 used: u64,
74}
75
76#[derive(Default)]
77pub(crate) struct Limiter {
78 buckets: Mutex<HashMap<Arc<str>, Bucket>>,
79 usage: Mutex<HashMap<Arc<str>, Usage>>,
80 store: Option<Store>,
81}
82
83fn today() -> u64 {
84 SystemTime::now()
85 .duration_since(UNIX_EPOCH)
86 .map_or(0, |d| d.as_secs() / 86_400)
87}
88
89fn secs_to_midnight() -> u64 {
90 let now = SystemTime::now()
91 .duration_since(UNIX_EPOCH)
92 .map_or(0, |d| d.as_secs());
93 86_400 - now % 86_400
94}
95
96fn usage_key(account: &str) -> String {
97 format!("quota/{account}")
98}
99
100impl Limiter {
101 pub(crate) fn new(store: Option<Store>) -> Self {
102 Self {
103 store,
104 ..Default::default()
105 }
106 }
107
108 pub(crate) fn check_rate(&self, c: &Caller) -> Result<(), ApiError> {
110 let Some(rpm) = c.limits.requests_per_minute else {
111 return Ok(());
112 };
113 let cap = f64::from(rpm.max(1));
114 let per_sec = cap / 60.0;
115 let now = Instant::now();
116 let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
117 let b = buckets.entry(c.account.clone()).or_insert(Bucket {
118 tokens: cap,
119 last: now,
120 });
121 b.tokens = (b.tokens + now.duration_since(b.last).as_secs_f64() * per_sec).min(cap);
122 b.last = now;
123 if b.tokens >= 1.0 {
124 b.tokens -= 1.0;
125 return Ok(());
126 }
127 let wait = ((1.0 - b.tokens) / per_sec).ceil().max(1.0) as u64;
128 Err(ApiError::new(
129 ErrorCode::RateLimitExceeded,
130 format!("rate limit exceeded: {rpm} requests per minute for this API key; retry after {wait}s"),
131 )
132 .with_retry_after(wait))
133 }
134
135 pub(crate) fn check_jobs(&self, c: &Caller, active: usize) -> Result<(), ApiError> {
137 match c.limits.max_concurrent_jobs {
138 Some(max) if active >= max => Err(ApiError::new(
139 ErrorCode::ConcurrencyLimitExceeded,
140 format!("concurrency limit exceeded: at most {max} crawl or batch jobs may run at once for this API key"),
141 )
142 .with_retry_after(5)),
143 _ => Ok(()),
144 }
145 }
146
147 fn usage(&self, account: &Arc<str>) -> Usage {
148 let day = today();
149 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
150 if let Some(u) = map.get(account).filter(|u| u.day == day) {
151 return *u;
152 }
153 let stored = self
155 .store
156 .as_ref()
157 .and_then(|s| s.tasks().kv_get(usage_key(account)).ok().flatten())
158 .and_then(|v| {
159 let text = String::from_utf8(v).ok()?;
160 let (d, n) = text.split_once(' ')?;
161 Some(Usage {
162 day: d.parse().ok()?,
163 used: n.parse().ok()?,
164 })
165 })
166 .filter(|u| u.day == day);
167 let u = stored.unwrap_or(Usage { day, used: 0 });
168 map.insert(account.clone(), u);
169 u
170 }
171
172 pub(crate) fn remaining_pages(&self, c: &Caller) -> Option<u64> {
174 let max = c.limits.pages_per_day?;
175 Some(max.saturating_sub(self.usage(&c.account).used))
176 }
177
178 pub(crate) fn check_quota(&self, c: &Caller) -> Result<(), ApiError> {
179 if self.remaining_pages(c) == Some(0) {
180 return Err(quota_error(c));
181 }
182 Ok(())
183 }
184
185 pub(crate) async fn consume_pages(&self, c: &Caller, n: u64) {
187 if c.limits.pages_per_day.is_none() || n == 0 {
188 return;
189 }
190 let u = self.usage(&c.account);
191 let u = {
192 let mut map = self.usage.lock().unwrap_or_else(|e| e.into_inner());
193 let e = map.entry(c.account.clone()).or_insert(u);
194 if e.day != u.day {
195 *e = u;
196 }
197 e.used += n;
198 *e
199 };
200 if let Some(store) = &self.store {
201 let key = usage_key(&c.account);
202 let value = format!("{} {}", u.day, u.used);
203 let r = store
204 .blocking(move |s| s.tasks().kv_put(key, value.as_bytes()))
205 .await;
206 if let Err(e) = r {
207 tracing::warn!(error = %e, "saving page quota usage failed");
208 }
209 }
210 }
211}
212
213pub(crate) fn quota_error(c: &Caller) -> ApiError {
214 ApiError::new(
215 ErrorCode::QuotaExceeded,
216 format!(
217 "daily page quota exceeded: {} pages per day for this API key",
218 c.limits.pages_per_day.unwrap_or(0)
219 ),
220 )
221 .with_retry_after(secs_to_midnight())
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn caller(limits: KeyLimits) -> Caller {
229 Caller::new(Some("secret-key"), Arc::new(limits))
230 }
231
232 #[test]
233 fn token_bucket_allows_a_burst_then_429s() {
234 let l = Limiter::default();
235 let c = caller(KeyLimits {
236 requests_per_minute: Some(3),
237 ..Default::default()
238 });
239 for _ in 0..3 {
240 l.check_rate(&c).unwrap();
241 }
242 let e = l.check_rate(&c).unwrap_err();
243 assert_eq!(e.code, ErrorCode::RateLimitExceeded);
244 assert!(e.retry_after.unwrap() >= 1);
245 let other = Caller::new(Some("other"), c.limits.clone());
247 l.check_rate(&other).unwrap();
248 l.check_rate(&caller(KeyLimits::default())).unwrap();
250 }
251
252 #[test]
253 fn accounts_use_names_or_fingerprints() {
254 let named = Caller::new(
255 Some("k"),
256 Arc::new(KeyLimits {
257 name: Some("team-a".into()),
258 ..Default::default()
259 }),
260 );
261 assert_eq!(&*named.account, "team-a");
262 let anon = caller(KeyLimits::default());
263 assert!(anon.account.starts_with("key-") && !anon.account.contains("secret"));
264 assert_eq!(&*Caller::new(None, Arc::default()).account, "anonymous");
265 }
266
267 #[tokio::test]
268 async fn page_quota_survives_a_restart() {
269 let dir = tempfile::tempdir().unwrap();
270 let c = caller(KeyLimits {
271 pages_per_day: Some(5),
272 ..Default::default()
273 });
274 {
275 let store = Store::open_dir(dir.path()).unwrap();
276 let l = Limiter::new(Some(store));
277 assert_eq!(l.remaining_pages(&c), Some(5));
278 l.consume_pages(&c, 4).await;
279 assert!(l.check_quota(&c).is_ok());
280 l.consume_pages(&c, 1).await;
281 assert_eq!(
282 l.check_quota(&c).unwrap_err().code,
283 ErrorCode::QuotaExceeded
284 );
285 }
286 let l = Limiter::new(Some(Store::open_dir(dir.path()).unwrap()));
287 assert_eq!(l.remaining_pages(&c), Some(0));
288 let jobs = caller(KeyLimits {
289 max_concurrent_jobs: Some(1),
290 ..Default::default()
291 });
292 assert!(l.check_jobs(&jobs, 0).is_ok());
293 assert_eq!(
294 l.check_jobs(&jobs, 1).unwrap_err().code,
295 ErrorCode::ConcurrencyLimitExceeded
296 );
297 }
298}