dwara_core/state/quotas.rs
1//! Consumer request budgets (DW-033): daily/monthly quotas and metering.
2//!
3//! # What a quota is (and is not)
4//!
5//! A quota is a BUDGET, not a rate: [`crate::config::ConsumerQuotas`]
6//! caps the total number of requests one consumer may make inside a
7//! fixed UTC calendar window — a day (midnight to midnight UTC) or a
8//! month (the first through the last instant of the UTC month). The
9//! rate limiter (DW-017) shapes traffic inside seconds or minutes via
10//! GCRA replenishment; a quota never replenishes inside its window,
11//! just resets whole at the boundary. The two mechanisms are SEPARATE
12//! by design and compose: both apply when both are configured, and
13//! quota 429s carry the same header family as rate-limit 429s
14//! (`Retry-After` + `X-RateLimit-Limit` / `-Remaining` / `-Reset`)
15//! because the client-facing contract is identical — "you may retry at
16//! this time".
17//!
18//! # Counters and durability
19//!
20//! Usage counters are the state store's `quota_counters` rows (DW-018
21//! seeded the table and [`StateStore::incr_quota`]'s atomic
22//! increment-or-refuse — this module is the policy layer that was
23//! missing). Counters therefore persist restarts and crashes like the
24//! rest of the store: every accepted request's increment is a
25//! synchronous point write committed before the request proceeds, not
26//! a fire-and-forget record, so a crash can lose at most the request
27//! that was in flight — never a committed counter. This is the OSS
28//! local-counter implementation the issue scopes: per-instance SQLite
29//! (or in-memory) truth. A fleet-wide shared-counter variant (Redis)
30//! is the Ent follow-up (DW-155) and deliberately does not exist here.
31//!
32//! # Hot-path cost (documented trade)
33//!
34//! Every request of a quota-configured consumer performs one or two
35//! SYNCHRONOUS SQLite write transactions (`incr_quota`) on the single
36//! mutex-guarded state-store connection — and that store keeps
37//! SQLite's default `synchronous=FULL` (an fsync per commit; the
38//! analytics store pins `NORMAL` for contrast, but relaxing the state
39//! store's durability is a separate decision touching credential-write
40//! semantics, not this feature's call). Budgets therefore add a
41//! per-request fsync and serialize quota'd traffic through one
42//! connection — the accepted OSS per-instance shape; scaling quota
43//! enforcement across a fleet is exactly the DW-155 follow-up.
44//!
45//! # Window math
46//!
47//! Windows are UTC calendar aligned and computed purely from an
48//! epoch-seconds input ([`day_window`], [`month_window`]) so tests are
49//! deterministic. The day window is `days * 86400`; the month window
50//! uses the standard civil-calendar days algorithm (no calendar
51//! dependency). `reset_epoch_s` is the boundary at which the budget
52//! becomes whole again — the value advertised in `X-RateLimit-Reset`.
53//!
54//! # Evaluation semantics (mirrors DW-017 where the client sees it)
55//!
56//! Budgets evaluate shortest-window-first (daily, then monthly). All
57//! configured budgets apply (AND). A denied request answers 429 with
58//! `Retry-After` = seconds until the denying budget's window resets
59//! (rounded up, minimum 1) and the FIRST denying budget's
60//! Limit/Remaining/Reset headers; when a LATER budget is ALSO
61//! exhausted, `Retry-After` stretches to that later wall (the monthly
62//! boundary is a midnight at or after the daily one) — a client
63//! honoring the hint never retries out of the daily wall straight into
64//! the monthly one, the same max-wait rule the multi-rule rate limiter
65//! applies. Evaluation is decide-and-reserve and STOPS at the first
66//! denial (the GCRA stacking rule): a request denied by the monthly
67//! budget has already spent its daily unit (the daily window evaluated
68//! first), while a request denied by the daily budget consumes
69//! NOTHING — later budgets are peeked read-only for the max-wait
70//! computation, never incremented for a refused request. The stacking
71//! waste is one unit in the faster-resetting window; never more
72//! permissive, bounded, documented.
73//!
74//! # Failure model
75//!
76//! The store's failure answers are surfaced, not hidden:
77//! [`QuotaOutcome::Unavailable`] (a SQLite error) maps to a 500 on the
78//! request path — the gateway cannot vouch for the budget either way,
79//! the same posture as an authN backend failure. A consumer present in
80//! config but absent from the store (sync never ran) fails OPEN with a
81//! logged warn: there is no counter to enforce, and 500-ing every
82//! request of an unsynced consumer would turn a wiring gap into an
83//! outage. Quota config without ANY attached state store is likewise
84//! inert (warned once at request time from the dataplane — validation
85//! cannot see `DWARA_STATE_DB`, which is runtime environment, not
86//! config).
87//!
88//! # Metering
89//!
90//! Usage is queryable three ways: the admin API's `GET /quotas/usage`
91//! (current windows, per consumer), the `dwara_quota_used` /
92//! `dwara_quota_limit` / `dwara_quota_denied_total` metric families
93//! (DW-021 conventions), and the analytics store — a quota-denied
94//! request completes with `rate_limited = true` and its consumer name,
95//! so the existing per-consumer analytics axis (raw rows and the
96//! `rollup_fixed` consumer dimension) already answers "how much did
97//! this consumer send and how much was refused" over history.
98
99use super::store::{StateStore, StoreError};
100use crate::config::ConsumerQuotas;
101
102// Re-export the pure quota types from config::quotas so existing
103// callers (`use crate::state::quotas::{Budget, QuotaOutcome, ...}`)
104// continue to work. The canonical definitions now live in
105// `config::quotas` (the lowest consuming domain) so both
106// `state::quotas` and `extensions::redis_quotas` can share them
107// without violating the downward-only dependency direction.
108pub use crate::config::quotas::{day_window, month_window, retry_after, Budget, QuotaOutcome};
109pub use crate::config::quotas::{DAILY_KEY, MONTHLY_KEY};
110
111/// Check-and-reserve one request against every configured budget of
112/// `quotas` for consumer row `consumer_id` (DW-033). Pure policy over
113/// [`StateStore::incr_quota`]; see the module docs for the evaluation
114/// order, the stacking consumption trade, and the failure model.
115///
116/// Evaluation STOPS at the first denying budget (the GCRA stacking
117/// rule: a refused request never consumes a longer window's budget),
118/// but every REMAINING budget is then PEEKED (read-only) for a
119/// would-deny — when a later budget is also exhausted, the reported
120/// `Retry-After` stretches to the LATER reset so a client honoring the
121/// hint never retries out of the daily wall straight into the monthly
122/// one (the DW-017 max-wait rule, implemented without reservation).
123///
124/// `now_epoch_s` is the request time (injected so tests are
125/// deterministic; the caller passes wall-clock seconds).
126pub fn check(
127 store: &StateStore,
128 consumer_id: i64,
129 quotas: &ConsumerQuotas,
130 now_epoch_s: i64,
131) -> QuotaOutcome {
132 let mut binding: Option<(u64, u64, u64)> = None; // (limit, remaining, reset)
133 for budget in Budget::ALL {
134 let Some(limit) = budget.limit(quotas) else {
135 continue;
136 };
137 let (window_start, reset_epoch_s) = budget.window(now_epoch_s);
138 match store.incr_quota(consumer_id, budget.key(), window_start, 1, Some(limit)) {
139 Ok(used_after) => {
140 let remaining = limit.saturating_sub(used_after);
141 let reset = reset_epoch_s.unsigned_abs();
142 // The tightest budget (least remaining) is the binding
143 // constraint on success — the rate limiter's rule.
144 if binding
145 .as_ref()
146 .is_none_or(|(_, best, _)| remaining < *best)
147 {
148 binding = Some((limit, remaining, reset));
149 }
150 }
151 // Nothing was written: the budget is exhausted. remaining
152 // is the honest post-check figure the headers advertise.
153 Err(StoreError::QuotaExceeded { used, .. }) => {
154 let mut retry_after_s = retry_after(reset_epoch_s, now_epoch_s);
155 let mut denied_reset = reset_epoch_s.unsigned_abs();
156 // Peek every later budget read-only: an exhausted one's
157 // reset is strictly later (a month boundary is a
158 // midnight at or after the day boundary), so any
159 // would-deny stretches the wait.
160 for later in Budget::ALL {
161 if *later == *budget {
162 continue;
163 }
164 let Some(later_limit) = later.limit(quotas) else {
165 continue;
166 };
167 let (later_start, later_reset) = later.window(now_epoch_s);
168 if let Ok(used) = store.get_quota(consumer_id, later.key(), later_start) {
169 if used >= later_limit {
170 let wait = retry_after(later_reset, now_epoch_s);
171 if wait > retry_after_s {
172 retry_after_s = wait;
173 denied_reset = later_reset.unsigned_abs();
174 }
175 }
176 }
177 }
178 return QuotaOutcome::Denied {
179 limit,
180 remaining: limit.saturating_sub(used),
181 reset_epoch_s: denied_reset,
182 retry_after_s,
183 budget: *budget,
184 };
185 }
186 // Unknown consumer row: fail open (module docs). Surface as
187 // not-quotaed — the caller logged the gap at most once.
188 Err(StoreError::UnknownConsumer(_)) => return QuotaOutcome::NotQuotaed,
189 // Any other store failure: the budget cannot be vouched for.
190 Err(StoreError::Sqlite(_)) => return QuotaOutcome::Unavailable,
191 }
192 }
193 match binding {
194 Some((limit, remaining, reset_epoch_s)) => QuotaOutcome::Allowed {
195 limit,
196 remaining,
197 reset_epoch_s,
198 },
199 None => QuotaOutcome::NotQuotaed,
200 }
201}
202
203/// One budget's current-window usage figure (the admin API and metric
204/// gauges' read model).
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct BudgetUsage {
207 pub budget: Budget,
208 /// Configured cap.
209 pub limit: u64,
210 /// Requests counted in the current window.
211 pub used: u64,
212 /// `limit - used` (saturating; always in range while counters and
213 /// config agree).
214 pub remaining: u64,
215 /// Window start (epoch seconds) — the counter row's key.
216 pub window_start_epoch_s: u64,
217 /// When the budget becomes whole again (epoch seconds).
218 pub reset_epoch_s: u64,
219}
220
221/// Read the current-window usage of every configured budget for one
222/// consumer (no reservation, no increment — the query side of
223/// metering). A store that cannot answer yields an empty read; callers
224/// surface the gap their own way (the admin endpoint 500s, the gauge
225/// refresh skips).
226pub fn current_usage(
227 store: &StateStore,
228 consumer_id: i64,
229 quotas: &ConsumerQuotas,
230 now_epoch_s: i64,
231) -> Vec<BudgetUsage> {
232 let mut out = Vec::new();
233 for budget in Budget::ALL {
234 let Some(limit) = budget.limit(quotas) else {
235 continue;
236 };
237 let (window_start, reset_epoch_s) = budget.window(now_epoch_s);
238 match store.get_quota(consumer_id, budget.key(), window_start) {
239 Ok(used) => out.push(BudgetUsage {
240 budget: *budget,
241 limit,
242 used,
243 remaining: limit.saturating_sub(used),
244 window_start_epoch_s: window_start.unsigned_abs(),
245 reset_epoch_s: reset_epoch_s.unsigned_abs(),
246 }),
247 Err(_) => return Vec::new(),
248 }
249 }
250 out
251}
252
253// White-box tests staying in src/ per AGENTS.md: the calendar-arithmetic
254// helpers (`days_from_civil`, `civil_from_days`, `SECS_PER_DAY`,
255// `retry_after`) are private and cannot be exercised through the public
256// API. The end-to-end `check`/`current_usage` tests use these private
257// helpers to construct exact epoch values for deterministic assertions.
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 // Local copies of the calendar-arithmetic helpers (now in
263 // config::quotas but private there) for deterministic test epochs.
264 const SECS_PER_DAY: i64 = 86_400;
265
266 fn civil_from_days(z: i64) -> (i64, u32, u32) {
267 let z = z + 719_468;
268 let era = z.div_euclid(146_097);
269 let doe = z.rem_euclid(146_097);
270 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
271 let y = yoe + era * 400;
272 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
273 let mp = (5 * doy + 2) / 153;
274 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
275 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
276 (if m <= 2 { y + 1 } else { y }, m, d)
277 }
278
279 fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
280 let y = if m <= 2 { y - 1 } else { y };
281 let era = y.div_euclid(400);
282 let yoe = y.rem_euclid(400);
283 let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
284 let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
285 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
286 era * 146_097 + doe - 719_468
287 }
288
289 /// The civil-calendar pair over a spread of epochs (leap years,
290 /// century non-leap 2100, month/year boundaries both directions).
291 #[test]
292 fn day_and_month_windows_are_calendar_aligned() {
293 // Epoch day 0 = 1970-01-01 (Thursday).
294 assert_eq!(day_window(0), (0, 86_400));
295 // Mid-day stays in the same window; the boundary rolls exactly.
296 assert_eq!(day_window(86_399), (0, 86_400));
297 assert_eq!(day_window(86_400), (86_400, 172_800));
298 // 2026-08-29T00:00:00Z = 1787961600 (a verified UTC midnight).
299 assert_eq!(
300 day_window(1_787_961_600),
301 (1_787_961_600, 1_787_961_600 + 86_400)
302 );
303 assert_eq!(
304 day_window(1_787_961_600 + 86_399),
305 (1_787_961_600, 1_787_961_600 + 86_400)
306 );
307 }
308
309 #[test]
310 fn month_windows_cross_month_year_and_leap_boundaries() {
311 // 1970-01-01: the first month window.
312 assert_eq!(month_window(0), (0, 2_678_400)); // 31 days
313 // 1970-01-31 23:59:59 still January; one second later is
314 // February (28 days in 1970).
315 assert_eq!(month_window(2_678_399), (0, 2_678_400));
316 assert_eq!(month_window(2_678_400), (2_678_400, 2_678_400 + 2_419_200));
317 // Leap year 1972: February has 29 days (25 days = 2_160_000 s
318 // into the epoch-year; compute from civil instead of magic).
319 let feb72 = days_from_civil(1972, 2, 1) * SECS_PER_DAY;
320 let mar72 = days_from_civil(1972, 3, 1) * SECS_PER_DAY;
321 assert_eq!(month_window(feb72), (feb72, mar72));
322 assert_eq!(mar72 - feb72, 29 * SECS_PER_DAY);
323 // Century non-leap 2100: February has 28 days there.
324 let feb2100 = days_from_civil(2100, 2, 1) * SECS_PER_DAY;
325 let mar2100 = days_from_civil(2100, 3, 1) * SECS_PER_DAY;
326 assert_eq!(month_window(feb2100), (feb2100, mar2100));
327 assert_eq!(mar2100 - feb2100, 28 * SECS_PER_DAY);
328 // December rolls into the next YEAR.
329 let dec2026 = days_from_civil(2026, 12, 1) * SECS_PER_DAY;
330 let jan2027 = days_from_civil(2027, 1, 1) * SECS_PER_DAY;
331 assert_eq!(month_window(dec2026), (dec2026, jan2027));
332 // Civil round trip: every day of 2026 maps back to itself.
333 for d in days_from_civil(2026, 1, 1)..days_from_civil(2027, 1, 1) {
334 let (y, m, day) = civil_from_days(d);
335 assert_eq!(days_from_civil(y, m, day), d);
336 }
337 }
338
339 fn quotas(daily: Option<u64>, monthly: Option<u64>) -> ConsumerQuotas {
340 ConsumerQuotas {
341 daily_requests: daily,
342 monthly_requests: monthly,
343 dry_run: false,
344 }
345 }
346
347 #[test]
348 fn check_admits_under_budget_and_denies_at_it_with_reset() {
349 let store = StateStore::open_in_memory().unwrap();
350 let cid = store.upsert_consumer("acme", None, &[]).unwrap().id;
351 let now = 1_787_961_600_i64 + 40_000; // mid-day 2026-08-29 UTC
352 let q = quotas(Some(2), None);
353 assert_eq!(
354 check(&store, cid, &q, now),
355 QuotaOutcome::Allowed {
356 limit: 2,
357 remaining: 1,
358 reset_epoch_s: 1_787_961_600_u64 + 86_400,
359 }
360 );
361 // Second request: budget now exhausted for the next one.
362 check(&store, cid, &q, now);
363 let (start, reset) = day_window(now);
364 assert_eq!(
365 check(&store, cid, &q, now),
366 QuotaOutcome::Denied {
367 limit: 2,
368 remaining: 0,
369 reset_epoch_s: reset.unsigned_abs(),
370 retry_after_s: (reset - now) as u32,
371 budget: Budget::Daily,
372 }
373 );
374 // Next day, same second-of-day: a fresh window admits again.
375 assert_eq!(day_window(now + SECS_PER_DAY).0, start + SECS_PER_DAY);
376 assert!(matches!(
377 check(&store, cid, &q, now + SECS_PER_DAY),
378 QuotaOutcome::Allowed { .. }
379 ));
380 // Denials do not consume: the exhausted-budget request above
381 // left `used` at 2 (the next-day window is a different row).
382 assert_eq!(store.get_quota(cid, DAILY_KEY, start).unwrap(), 2);
383 }
384
385 #[test]
386 fn monthly_denial_reports_month_scale_retry_and_daily_was_consumed() {
387 let store = StateStore::open_in_memory().unwrap();
388 let cid = store.upsert_consumer("acme", None, &[]).unwrap().id;
389 let now = days_from_civil(2026, 8, 29) * SECS_PER_DAY + 3600;
390 // A daily budget the request fits, a monthly budget it does not
391 // (already at its limit from a request earlier this month).
392 let q = quotas(Some(10), Some(1));
393 store
394 .incr_quota(cid, MONTHLY_KEY, month_window(now).0, 1, Some(1))
395 .unwrap();
396 let outcome = check(&store, cid, &q, now);
397 let (_, month_reset) = month_window(now);
398 assert_eq!(
399 outcome,
400 QuotaOutcome::Denied {
401 limit: 1,
402 remaining: 0,
403 reset_epoch_s: month_reset.unsigned_abs(),
404 retry_after_s: (month_reset - now) as u32,
405 budget: Budget::Monthly,
406 }
407 );
408 // The documented stacking trade: the daily unit was spent by
409 // the request the monthly budget refused.
410 let (day_start, _) = day_window(now);
411 assert_eq!(store.get_quota(cid, DAILY_KEY, day_start).unwrap(), 1);
412 }
413
414 #[test]
415 fn both_budgets_exhausted_reports_the_later_wall() {
416 let store = StateStore::open_in_memory().unwrap();
417 let cid = store.upsert_consumer("acme", None, &[]).unwrap().id;
418 let now = days_from_civil(2026, 8, 29) * SECS_PER_DAY + 3600;
419 let (day_start, day_reset) = day_window(now);
420 let (month_start, month_reset) = month_window(now);
421 assert!(month_reset > day_reset);
422 // Both budgets already at their limits from earlier traffic
423 // (daily cap 1 spent; monthly cap 1 spent).
424 store
425 .incr_quota(cid, DAILY_KEY, day_start, 1, Some(1))
426 .unwrap();
427 store
428 .incr_quota(cid, MONTHLY_KEY, month_start, 1, Some(1))
429 .unwrap();
430 let out = check(&store, cid, "as(Some(1), Some(1)), now);
431 match out {
432 QuotaOutcome::Denied {
433 limit,
434 budget,
435 retry_after_s,
436 reset_epoch_s,
437 ..
438 } => {
439 // Daily denies first (shortest window evaluated first)
440 // and binds the Limit/Remaining headers...
441 assert_eq!(budget, Budget::Daily);
442 assert_eq!(limit, 1);
443 // ...but the monthly budget is also exhausted, and its
444 // wall is later: the max-wait peek stretches Retry-After
445 // (and the matching Reset) past the daily reset, so a
446 // client honoring the hint never retries out of the
447 // daily wall straight into the monthly one.
448 assert_eq!(retry_after_s as i64 + now, month_reset);
449 assert_eq!(reset_epoch_s, month_reset.unsigned_abs());
450 assert!(retry_after_s as i64 + now > day_reset);
451 }
452 other => panic!("expected denial, got {other:?}"),
453 }
454 // The refused request consumed nothing in either window.
455 assert_eq!(store.get_quota(cid, DAILY_KEY, day_start).unwrap(), 1);
456 assert_eq!(store.get_quota(cid, MONTHLY_KEY, month_start).unwrap(), 1);
457 }
458
459 #[test]
460 fn unknown_consumer_fails_open_and_empty_config_is_not_quotaed() {
461 let store = StateStore::open_in_memory().unwrap();
462 // No consumer row: fail-open, never a 500 loop.
463 assert_eq!(
464 check(&store, 9999, "as(Some(5), None), 1000),
465 QuotaOutcome::NotQuotaed
466 );
467 // Configured consumer with no budgets set at all.
468 let cid = store.upsert_consumer("acme", None, &[]).unwrap().id;
469 assert_eq!(
470 check(&store, cid, "as(None, None), 1000),
471 QuotaOutcome::NotQuotaed
472 );
473 // current_usage mirrors: empty config -> empty read.
474 assert!(current_usage(&store, cid, "as(None, None), 1000).is_empty());
475 }
476
477 #[test]
478 fn retry_after_is_ceiled_and_never_zero() {
479 assert_eq!(retry_after(100, 100), 1);
480 assert_eq!(retry_after(99, 100), 1);
481 assert_eq!(retry_after(101, 100), 1);
482 assert_eq!(retry_after(160, 100), 60);
483 }
484
485 #[test]
486 fn current_usage_reads_both_budgets_current_windows_only() {
487 let store = StateStore::open_in_memory().unwrap();
488 let cid = store.upsert_consumer("acme", None, &[]).unwrap().id;
489 let now = days_from_civil(2026, 8, 29) * SECS_PER_DAY + 7200;
490 let q = quotas(Some(10), Some(100));
491 // Yesterday's usage must not leak into today's figures.
492 store
493 .incr_quota(cid, DAILY_KEY, day_window(now - SECS_PER_DAY).0, 9, None)
494 .unwrap();
495 store
496 .incr_quota(cid, MONTHLY_KEY, month_window(now).0, 3, None)
497 .unwrap();
498 store
499 .incr_quota(cid, DAILY_KEY, day_window(now).0, 2, None)
500 .unwrap();
501 let usage = current_usage(&store, cid, &q, now);
502 assert_eq!(usage.len(), 2);
503 assert_eq!(
504 usage[0],
505 BudgetUsage {
506 budget: Budget::Daily,
507 limit: 10,
508 used: 2,
509 remaining: 8,
510 window_start_epoch_s: day_window(now).0 as u64,
511 reset_epoch_s: day_window(now).1 as u64,
512 }
513 );
514 assert_eq!(usage[1].budget, Budget::Monthly);
515 assert_eq!(usage[1].used, 3);
516 }
517}