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, &quotas(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, &quotas(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, &quotas(None, None), 1000),
471            QuotaOutcome::NotQuotaed
472        );
473        // current_usage mirrors: empty config -> empty read.
474        assert!(current_usage(&store, cid, &quotas(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}