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}