Skip to main content

runlimit_core/
decision.rs

1use std::time::Duration;
2
3/// Structured details for a denied check.
4///
5/// A quota denial always contains its policy limit and the remaining window
6/// measured by the backend. A storage-capacity denial may contain the duration
7/// until the backend's earliest known expiry, when one is available.
8///
9/// Process-local backends can measure the duration at evaluation time exactly.
10/// Distributed backends may return a safe upper bound measured with their
11/// authoritative clock, which can overstate the duration at the caller by
12/// commit and transport time.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14#[non_exhaustive]
15pub enum Denial {
16    /// Consuming the requested cost would exceed the configured quota.
17    QuotaExceeded {
18        /// Configured policy limit.
19        limit: u64,
20        /// Remaining duration of the active window.
21        retry_after: Duration,
22    },
23    /// A bounded backend could not safely allocate storage for a new key.
24    StorageCapacity {
25        /// Duration until capacity may become available, when known.
26        retry_after: Option<Duration>,
27    },
28}
29
30impl Denial {
31    /// Returns the backend-reported duration after which the caller may retry,
32    /// if known.
33    pub const fn retry_after(&self) -> Option<Duration> {
34        match self {
35            Self::QuotaExceeded { retry_after, .. } => Some(*retry_after),
36            Self::StorageCapacity { retry_after } => *retry_after,
37        }
38    }
39
40    /// Returns a whole-second `Retry-After` value rounded up, if known.
41    ///
42    /// The underlying [`Duration`] remains available through
43    /// [`Denial::retry_after`]. Values beyond the representable range saturate
44    /// at [`u64::MAX`].
45    pub const fn retry_after_seconds(&self) -> Option<u64> {
46        match self.retry_after() {
47            Some(duration) => Some(ceil_seconds(duration)),
48            None => None,
49        }
50    }
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54struct Allowance {
55    limit: u64,
56    remaining: u64,
57    reset_after: Duration,
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61enum Outcome {
62    Allowed(Allowance),
63    Denied(Denial),
64}
65
66/// The outcome of evaluating one check.
67///
68/// Allowed outcomes report quota remaining after the check and the
69/// backend-reported time until the anchored window resets. Denied outcomes
70/// carry a [`Denial`].
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub struct Decision {
73    outcome: Outcome,
74}
75
76impl Decision {
77    /// Constructs an allowed decision.
78    ///
79    /// Storage backends should pass the remaining quota after consuming the
80    /// check's cost. This low-level constructor trusts the backend to ensure
81    /// `remaining <= limit`.
82    pub const fn allowed(limit: u64, remaining: u64, reset_after: Duration) -> Self {
83        Self {
84            outcome: Outcome::Allowed(Allowance {
85                limit,
86                remaining,
87                reset_after,
88            }),
89        }
90    }
91
92    /// Constructs a denied decision.
93    pub const fn denied(denial: Denial) -> Self {
94        Self {
95            outcome: Outcome::Denied(denial),
96        }
97    }
98
99    /// Returns whether the check was allowed.
100    pub const fn is_allowed(&self) -> bool {
101        matches!(self.outcome, Outcome::Allowed(_))
102    }
103
104    /// Returns whether the check was denied.
105    pub const fn is_denied(&self) -> bool {
106        !self.is_allowed()
107    }
108
109    /// Returns the configured limit when it is meaningful for this outcome.
110    ///
111    /// Allowed decisions and quota denials have a limit. Storage-capacity
112    /// denials do not.
113    pub const fn limit(&self) -> Option<u64> {
114        match self.outcome {
115            Outcome::Allowed(allowance) => Some(allowance.limit),
116            Outcome::Denied(Denial::QuotaExceeded { limit, .. }) => Some(limit),
117            Outcome::Denied(Denial::StorageCapacity { .. }) => None,
118        }
119    }
120
121    /// Returns the quota remaining after an allowed check.
122    pub const fn remaining(&self) -> Option<u64> {
123        match self.outcome {
124            Outcome::Allowed(allowance) => Some(allowance.remaining),
125            Outcome::Denied(_) => None,
126        }
127    }
128
129    /// Returns the backend-reported time until an allowed check's anchored
130    /// window resets.
131    pub const fn reset_after(&self) -> Option<Duration> {
132        match self.outcome {
133            Outcome::Allowed(allowance) => Some(allowance.reset_after),
134            Outcome::Denied(_) => None,
135        }
136    }
137
138    /// Returns the backend-reported duration after which a denied check may
139    /// retry.
140    pub const fn retry_after(&self) -> Option<Duration> {
141        match self.outcome {
142            Outcome::Allowed(_) => None,
143            Outcome::Denied(denial) => denial.retry_after(),
144        }
145    }
146
147    /// Returns a whole-second `Retry-After` value rounded up, if known.
148    pub const fn retry_after_seconds(&self) -> Option<u64> {
149        match self.outcome {
150            Outcome::Allowed(_) => None,
151            Outcome::Denied(denial) => denial.retry_after_seconds(),
152        }
153    }
154
155    /// Returns denial details for a denied check.
156    pub const fn denial(&self) -> Option<&Denial> {
157        match &self.outcome {
158            Outcome::Allowed(_) => None,
159            Outcome::Denied(denial) => Some(denial),
160        }
161    }
162}
163
164/// The atomic outcome of evaluating checks in caller-supplied order.
165///
166/// An allowed batch contains one allowed decision for each input check, in the
167/// same order. A denied batch reports the original input index that failed.
168/// Backends must not consume any check when returning [`BatchDecision::Denied`].
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub enum BatchDecision {
171    /// Every input check was allowed.
172    Allowed(Vec<Decision>),
173    /// One input check caused the whole batch to be denied.
174    Denied {
175        /// Index in the caller's original input sequence.
176        index: usize,
177        /// Details of the denial.
178        denial: Denial,
179    },
180}
181
182impl BatchDecision {
183    /// Converts a batch-of-one outcome into its single-check decision.
184    ///
185    /// Returns the original batch when an allowed result does not contain
186    /// exactly one allowed decision or a denied result names an index other
187    /// than zero.
188    ///
189    /// # Errors
190    ///
191    /// Returns the unchanged batch when it is not a valid batch-of-one result.
192    pub fn try_into_single_decision(self) -> Result<Decision, Self> {
193        match self {
194            Self::Allowed(decisions) if matches!(decisions.as_slice(), [decision] if decision.is_allowed()) => {
195                Ok(decisions[0])
196            }
197            Self::Denied { index: 0, denial } => Ok(Decision::denied(denial)),
198            batch => Err(batch),
199        }
200    }
201}
202
203const fn ceil_seconds(duration: Duration) -> u64 {
204    let seconds = duration.as_secs();
205    if duration.subsec_nanos() == 0 {
206        seconds
207    } else {
208        seconds.saturating_add(1)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use std::time::Duration;
215
216    use super::{BatchDecision, Decision, Denial};
217
218    #[test]
219    fn allowed_decision_exposes_remaining_and_reset() {
220        let decision = Decision::allowed(8, 7, Duration::from_millis(59_999));
221
222        assert!(decision.is_allowed());
223        assert!(!decision.is_denied());
224        assert_eq!(decision.limit(), Some(8));
225        assert_eq!(decision.remaining(), Some(7));
226        assert_eq!(decision.reset_after(), Some(Duration::from_millis(59_999)));
227        assert_eq!(decision.retry_after(), None);
228        assert_eq!(decision.denial(), None);
229    }
230
231    #[test]
232    fn quota_denial_exposes_exact_and_ceiling_retry_duration() {
233        let denial = Denial::QuotaExceeded {
234            limit: 8,
235            retry_after: Duration::from_millis(1_001),
236        };
237        let decision = Decision::denied(denial);
238
239        assert!(decision.is_denied());
240        assert_eq!(decision.limit(), Some(8));
241        assert_eq!(decision.remaining(), None);
242        assert_eq!(decision.reset_after(), None);
243        assert_eq!(decision.retry_after(), Some(Duration::from_millis(1_001)));
244        assert_eq!(decision.retry_after_seconds(), Some(2));
245        assert_eq!(decision.denial(), Some(&denial));
246        assert!(matches!(
247            denial,
248            Denial::QuotaExceeded {
249                limit: 8,
250                retry_after
251            } if retry_after == Duration::from_millis(1_001)
252        ));
253    }
254
255    #[test]
256    fn retry_after_seconds_preserves_exact_seconds() {
257        let denial = Denial::QuotaExceeded {
258            limit: 1,
259            retry_after: Duration::from_secs(3),
260        };
261
262        assert_eq!(denial.retry_after_seconds(), Some(3));
263    }
264
265    #[test]
266    fn retry_after_seconds_saturates_without_losing_exact_duration() {
267        let duration = Duration::new(u64::MAX, 1);
268        let denial = Denial::QuotaExceeded {
269            limit: 1,
270            retry_after: duration,
271        };
272
273        assert_eq!(denial.retry_after(), Some(duration));
274        assert_eq!(denial.retry_after_seconds(), Some(u64::MAX));
275    }
276
277    #[test]
278    fn storage_capacity_retry_can_be_unknown() {
279        let denial = Denial::StorageCapacity { retry_after: None };
280        let decision = Decision::denied(denial);
281
282        assert!(matches!(
283            denial,
284            Denial::StorageCapacity { retry_after: None }
285        ));
286        assert_eq!(denial.retry_after(), None);
287        assert_eq!(denial.retry_after_seconds(), None);
288        assert_eq!(decision.limit(), None);
289        assert_eq!(decision.retry_after(), None);
290    }
291
292    #[test]
293    fn batch_of_one_converts_to_a_single_decision() {
294        let allowed = Decision::allowed(8, 7, Duration::from_secs(60));
295        let denied = Denial::QuotaExceeded {
296            limit: 8,
297            retry_after: Duration::from_secs(60),
298        };
299
300        assert_eq!(
301            BatchDecision::Allowed(vec![allowed]).try_into_single_decision(),
302            Ok(allowed)
303        );
304        assert_eq!(
305            BatchDecision::Denied {
306                index: 0,
307                denial: denied
308            }
309            .try_into_single_decision(),
310            Ok(Decision::denied(denied))
311        );
312    }
313
314    #[test]
315    fn malformed_batch_of_one_is_rejected() {
316        let decision = Decision::allowed(8, 7, Duration::from_secs(60));
317        let denial = Denial::QuotaExceeded {
318            limit: 8,
319            retry_after: Duration::from_secs(60),
320        };
321
322        assert!(
323            BatchDecision::Allowed(Vec::new())
324                .try_into_single_decision()
325                .is_err()
326        );
327        assert!(
328            BatchDecision::Allowed(vec![decision, decision])
329                .try_into_single_decision()
330                .is_err()
331        );
332        assert!(
333            BatchDecision::Allowed(vec![Decision::denied(denial)])
334                .try_into_single_decision()
335                .is_err()
336        );
337        assert!(
338            BatchDecision::Denied { index: 1, denial }
339                .try_into_single_decision()
340                .is_err()
341        );
342    }
343}