dwara_core/config/quotas.rs
1//! Pure quota types shared by the state-store and Redis-backed quota
2//! implementations (DW-033, DW-155).
3//!
4//! This module lives in `config` (the lowest consuming domain) so both
5//! `state::quotas` (the OSS local-counter implementation) and
6//! `extensions::redis_quotas` (the Ent Redis-backed implementation) can
7//! import it without violating the downward-only dependency direction.
8//! The types here are pure: no I/O, no state store, no Redis. The
9//! stateful logic lives in the consuming domains.
10
11/// Counter-key of the daily budget (`quota_counters.counter_key`).
12pub const DAILY_KEY: &str = "daily";
13/// Counter-key of the monthly budget (`quota_counters.counter_key`).
14pub const MONTHLY_KEY: &str = "monthly";
15
16/// Seconds per day (the daily window's whole length).
17const SECS_PER_DAY: i64 = 86_400;
18
19/// One of the two budget kinds (DW-033). The set is closed: budgets are
20/// calendar windows, not arbitrary durations.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Budget {
23 Daily,
24 Monthly,
25}
26
27impl Budget {
28 /// Counter-key spelling (the `quota_counters.counter_key` value).
29 pub fn key(self) -> &'static str {
30 match self {
31 Budget::Daily => DAILY_KEY,
32 Budget::Monthly => MONTHLY_KEY,
33 }
34 }
35
36 /// Label spelling (metric label values, event payloads, admin API).
37 pub fn as_str(self) -> &'static str {
38 self.key()
39 }
40
41 /// This budget's configured limit, when the config sets one.
42 pub fn limit(self, quotas: &crate::config::ConsumerQuotas) -> Option<u64> {
43 match self {
44 Budget::Daily => quotas.daily_requests,
45 Budget::Monthly => quotas.monthly_requests,
46 }
47 }
48
49 /// The window (start, reset) this budget's counter keys on at
50 /// `now_epoch_s`.
51 pub fn window(self, now_epoch_s: i64) -> (i64, i64) {
52 match self {
53 Budget::Daily => day_window(now_epoch_s),
54 Budget::Monthly => month_window(now_epoch_s),
55 }
56 }
57
58 /// Both budgets, shortest window first (the evaluation order; see
59 /// the module docs).
60 pub const ALL: &'static [Budget] = &[Budget::Daily, Budget::Monthly];
61}
62
63/// The UTC day window containing `now_epoch_s`:
64/// (window_start_epoch_s, next_reset_epoch_s). Pure; floor semantics
65/// for any input (negative epochs round toward the past, matching
66/// euclidean division).
67pub fn day_window(now_epoch_s: i64) -> (i64, i64) {
68 let days = now_epoch_s.div_euclid(SECS_PER_DAY);
69 let start = days * SECS_PER_DAY;
70 (start, start + SECS_PER_DAY)
71}
72
73/// The UTC month window containing `now_epoch_s`:
74/// (window_start_epoch_s, next_reset_epoch_s). Calendar-correct across
75/// month lengths and leap years (the standard civil-from-days
76/// algorithm; no calendar dependency). Pure.
77pub fn month_window(now_epoch_s: i64) -> (i64, i64) {
78 let days = now_epoch_s.div_euclid(SECS_PER_DAY);
79 let (y, m, _) = civil_from_days(days);
80 let start_days = days_from_civil(y, m, 1);
81 let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
82 let reset_days = days_from_civil(ny, nm, 1);
83 (start_days * SECS_PER_DAY, reset_days * SECS_PER_DAY)
84}
85
86/// Civil date (year, month 1..=12, day 1..=31) from days since the
87/// Unix epoch (Howard Hinnant's `civil_from_days`; valid for the whole
88/// proleptic Gregorian calendar the epoch-seconds domain can express).
89fn civil_from_days(z: i64) -> (i64, u32, u32) {
90 let z = z + 719_468;
91 let era = z.div_euclid(146_097);
92 let doe = z.rem_euclid(146_097); // [0, 146096]
93 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
94 let y = yoe + era * 400;
95 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
96 let mp = (5 * doy + 2) / 153; // [0, 11]
97 let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
98 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
99 (if m <= 2 { y + 1 } else { y }, m, d)
100}
101
102/// Days since the Unix epoch from a civil date (the exact inverse of
103/// [`civil_from_days`]).
104fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
105 let y = if m <= 2 { y - 1 } else { y };
106 let era = y.div_euclid(400);
107 let yoe = y.rem_euclid(400); // [0, 399]
108 let mp = if m > 2 { m - 3 } else { m + 9 } as i64; // [0, 11]
109 let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
110 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
111 era * 146_097 + doe - 719_468
112}
113
114/// What the quota check decided for one request (DW-033). Shape mirrors
115/// the rate limiter's outcome type (extensions::rate_limiter, named
116/// textually — a doc link would be an upward import) so the request
117/// path answers both with the same 429 builder.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum QuotaOutcome {
120 /// No budget applied (no quota config, anonymous traffic, or no
121 /// state store attached): the request is not quota-gated and
122 /// carries no quota headers.
123 NotQuotaed,
124 /// Admitted (the unit is already spent — `check` reserves like the
125 /// rate limiter does). `limit`/`remaining`/`reset_epoch_s` describe
126 /// the binding constraint (the budget with the least remaining).
127 /// Quotas stamp headers on DENIALS only: an admitted response's
128 /// `X-RateLimit-*` family belongs to the rate limiter when it
129 /// applies (documented choice — two mechanisms would race to write
130 /// the same header names on every success).
131 Allowed {
132 limit: u64,
133 remaining: u64,
134 reset_epoch_s: u64,
135 },
136 /// Over budget: answer 429 with `Retry-After` = `retry_after_s`
137 /// (ceil to the window boundary, min 1) and the binding budget's
138 /// Limit/Remaining/Reset headers. `budget` names which wall was hit
139 /// (the metric label); when both budgets deny, the headers come
140 /// from the first (daily) and `retry_after_s` is the MAXIMUM wait.
141 Denied {
142 limit: u64,
143 remaining: u64,
144 reset_epoch_s: u64,
145 retry_after_s: u32,
146 budget: Budget,
147 },
148 /// The store failed mid-check: the gateway cannot vouch for the
149 /// budget either way. The request path answers 500 (the authN
150 /// "unavailable" posture), never a guess.
151 Unavailable,
152}
153
154/// `Retry-After` seconds: whole seconds until `reset_epoch_s`, rounded
155/// up, minimum 1 (a denied request inside the last partial second must
156/// still advertise a wait, never 0).
157pub fn retry_after(reset_epoch_s: i64, now_epoch_s: i64) -> u32 {
158 u32::try_from((reset_epoch_s - now_epoch_s).max(1)).unwrap_or(1)
159}