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 capacity and the duration until
6/// the requested cost can be retried. A storage-capacity denial may contain
7/// the duration until the backend's earliest known expiry, when one is
8/// available.
9///
10/// Process-local backends can measure the duration at evaluation time exactly.
11/// Distributed backends may return a safe upper bound measured with their
12/// authoritative clock, which can overstate the duration at the caller by
13/// commit and transport time.
14///
15/// With the `serde` feature, this is an object tagged by `reason`. Durations
16/// use Serde's exact `{ "secs": ..., "nanos": ... }` representation.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18#[non_exhaustive]
19pub enum Denial {
20    /// Consuming the requested cost would exceed the configured quota.
21    QuotaExceeded {
22        /// Maximum immediately available policy allowance.
23        capacity: u64,
24        /// Duration until the rejected cost can be retried.
25        retry_after: Duration,
26    },
27    /// A bounded backend could not safely allocate storage for a new key.
28    StorageCapacity {
29        /// Duration until capacity may become available, when known.
30        retry_after: Option<Duration>,
31    },
32}
33
34impl Denial {
35    /// Returns the backend-reported duration after which the caller may retry,
36    /// if known.
37    pub const fn retry_after(&self) -> Option<Duration> {
38        match self {
39            Self::QuotaExceeded { retry_after, .. } => Some(*retry_after),
40            Self::StorageCapacity { retry_after } => *retry_after,
41        }
42    }
43
44    /// Returns a whole-second `Retry-After` value rounded up, if known.
45    ///
46    /// The underlying [`Duration`] remains available through
47    /// [`Denial::retry_after`]. Values beyond the representable range saturate
48    /// at [`u64::MAX`].
49    pub const fn retry_after_seconds(&self) -> Option<u64> {
50        match self.retry_after() {
51            Some(duration) => Some(ceil_seconds(duration)),
52            None => None,
53        }
54    }
55}
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58struct Allowance {
59    capacity: u64,
60    available: u64,
61    replenishes_after: Duration,
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65enum Outcome {
66    Allowed(Allowance),
67    Denied(Denial),
68    ShadowDenied(Denial),
69}
70
71/// The outcome of evaluating one check.
72///
73/// Allowed outcomes report immediately available allowance after the check
74/// and the backend-reported time until full capacity is replenished. Denied
75/// outcomes carry a [`Denial`].
76///
77/// With the `serde` feature, this is an object tagged by `outcome`. Invalid
78/// allowed metadata, such as `available` exceeding `capacity`, is rejected.
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub struct Decision {
81    outcome: Outcome,
82}
83
84impl Decision {
85    /// Constructs an allowed decision.
86    ///
87    /// Storage backends should pass the available allowance after consuming
88    /// the check's cost. This low-level constructor trusts the backend to
89    /// ensure `available <= capacity`.
90    pub const fn allowed(capacity: u64, available: u64, replenishes_after: Duration) -> Self {
91        Self {
92            outcome: Outcome::Allowed(Allowance {
93                capacity,
94                available,
95                replenishes_after,
96            }),
97        }
98    }
99
100    /// Constructs a denied decision.
101    pub const fn denied(denial: Denial) -> Self {
102        Self {
103            outcome: Outcome::Denied(denial),
104        }
105    }
106
107    /// Constructs a shadow quota denial.
108    ///
109    /// Backends must use this only for [`Denial::QuotaExceeded`]. Storage
110    /// capacity remains an enforced denial in every quota mode.
111    pub const fn shadow_denied(denial: Denial) -> Self {
112        Self {
113            outcome: Outcome::ShadowDenied(denial),
114        }
115    }
116
117    /// Returns whether the application may proceed.
118    ///
119    /// This includes both consumed allowed decisions and quota denials from a
120    /// shadow policy.
121    pub const fn is_allowed(&self) -> bool {
122        self.permits_request()
123    }
124
125    /// Returns whether the application must reject the operation.
126    pub const fn is_denied(&self) -> bool {
127        self.is_enforced_denial()
128    }
129
130    /// Returns whether the application may proceed.
131    pub const fn permits_request(&self) -> bool {
132        !matches!(self.outcome, Outcome::Denied(_))
133    }
134
135    /// Returns whether this check encountered quota or capacity denial.
136    pub const fn would_deny(&self) -> bool {
137        !matches!(self.outcome, Outcome::Allowed(_))
138    }
139
140    /// Returns whether this decision must be enforced.
141    pub const fn is_enforced_denial(&self) -> bool {
142        matches!(self.outcome, Outcome::Denied(_))
143    }
144
145    /// Returns whether quota was exceeded in shadow mode.
146    pub const fn is_shadow_denied(&self) -> bool {
147        matches!(self.outcome, Outcome::ShadowDenied(_))
148    }
149
150    /// Returns the configured capacity when it is meaningful for this outcome.
151    ///
152    /// Allowed decisions and quota denials have a capacity. Storage-capacity
153    /// denials do not.
154    pub const fn capacity(&self) -> Option<u64> {
155        match self.outcome {
156            Outcome::Allowed(allowance) => Some(allowance.capacity),
157            Outcome::Denied(Denial::QuotaExceeded { capacity, .. })
158            | Outcome::ShadowDenied(Denial::QuotaExceeded { capacity, .. }) => Some(capacity),
159            Outcome::Denied(Denial::StorageCapacity { .. })
160            | Outcome::ShadowDenied(Denial::StorageCapacity { .. }) => None,
161        }
162    }
163
164    /// Returns the immediately available allowance after a consumed check.
165    pub const fn available(&self) -> Option<u64> {
166        match self.outcome {
167            Outcome::Allowed(allowance) => Some(allowance.available),
168            Outcome::Denied(_) | Outcome::ShadowDenied(_) => None,
169        }
170    }
171
172    /// Returns when an allowed decision's full capacity is next available.
173    pub const fn replenishes_after(&self) -> Option<Duration> {
174        match self.outcome {
175            Outcome::Allowed(allowance) => Some(allowance.replenishes_after),
176            Outcome::Denied(_) | Outcome::ShadowDenied(_) => None,
177        }
178    }
179
180    /// Returns the backend-reported duration after which a denied check may
181    /// retry.
182    pub const fn retry_after(&self) -> Option<Duration> {
183        match self.outcome {
184            Outcome::Allowed(_) => None,
185            Outcome::Denied(denial) | Outcome::ShadowDenied(denial) => denial.retry_after(),
186        }
187    }
188
189    /// Returns a whole-second `Retry-After` value rounded up, if known.
190    pub const fn retry_after_seconds(&self) -> Option<u64> {
191        match self.outcome {
192            Outcome::Allowed(_) => None,
193            Outcome::Denied(denial) | Outcome::ShadowDenied(denial) => denial.retry_after_seconds(),
194        }
195    }
196
197    /// Returns denial details for a denied check.
198    pub const fn denial(&self) -> Option<&Denial> {
199        match &self.outcome {
200            Outcome::Allowed(_) => None,
201            Outcome::Denied(denial) | Outcome::ShadowDenied(denial) => Some(denial),
202        }
203    }
204
205    const fn was_consumed(&self) -> bool {
206        matches!(self.outcome, Outcome::Allowed(_))
207    }
208}
209
210/// The atomic outcome of evaluating checks in caller-supplied order.
211///
212/// An allowed batch contains one allowed decision for each input check, in the
213/// same order. A denied batch reports the original input index that failed.
214/// Backends must not consume any check when returning [`BatchDecision::Denied`].
215///
216/// With the `serde` feature, this is an object tagged by `outcome`.
217#[derive(Clone, Debug, Eq, PartialEq)]
218#[non_exhaustive]
219pub enum BatchDecision {
220    /// Every input check was allowed.
221    Allowed(Vec<Decision>),
222    /// One input check caused the whole batch to be denied.
223    Denied {
224        /// Index in the caller's original input sequence.
225        index: usize,
226        /// Details of the denial.
227        denial: Denial,
228    },
229    /// Quota was exceeded for one shadow policy, so nothing was consumed but
230    /// the application may proceed.
231    ShadowDenied {
232        /// Index in the caller's original input sequence.
233        index: usize,
234        /// Quota-denial details.
235        denial: Denial,
236    },
237}
238
239impl BatchDecision {
240    /// Returns whether the application may proceed.
241    pub const fn permits_request(&self) -> bool {
242        !matches!(self, Self::Denied { .. })
243    }
244
245    /// Returns whether evaluation encountered quota or capacity denial.
246    pub const fn would_deny(&self) -> bool {
247        !matches!(self, Self::Allowed(_))
248    }
249
250    /// Returns whether the application must reject the operation.
251    pub const fn is_enforced_denial(&self) -> bool {
252        matches!(self, Self::Denied { .. })
253    }
254
255    /// Returns whether quota was exceeded in shadow mode.
256    pub const fn is_shadow_denied(&self) -> bool {
257        matches!(self, Self::ShadowDenied { .. })
258    }
259
260    /// Converts a batch-of-one outcome into its single-check decision.
261    ///
262    /// Returns the original batch when an allowed result does not contain
263    /// exactly one allowed decision or a denied result names an index other
264    /// than zero.
265    ///
266    /// # Errors
267    ///
268    /// Returns the unchanged batch when it is not a valid batch-of-one result.
269    pub fn try_into_single_decision(self) -> Result<Decision, Self> {
270        match self {
271            Self::Allowed(decisions) if matches!(decisions.as_slice(), [decision] if decision.was_consumed()) => {
272                Ok(decisions[0])
273            }
274            Self::Denied { index: 0, denial } => Ok(Decision::denied(denial)),
275            Self::ShadowDenied { index: 0, denial } => Ok(Decision::shadow_denied(denial)),
276            batch => Err(batch),
277        }
278    }
279}
280
281#[cfg(feature = "serde")]
282fn validate_denial(denial: &Denial) -> Result<(), &'static str> {
283    match denial {
284        Denial::QuotaExceeded { capacity, .. } => {
285            if *capacity == 0 {
286                return Err("a quota denial capacity must be greater than zero");
287            }
288            if *capacity > crate::MAX_LIMIT {
289                return Err("a quota denial capacity exceeds the portable maximum");
290            }
291        }
292        Denial::StorageCapacity { .. } => {}
293    }
294    Ok(())
295}
296
297#[cfg(feature = "serde")]
298fn validate_decision(decision: &Decision) -> Result<(), &'static str> {
299    match decision.outcome {
300        Outcome::Allowed(allowance) => {
301            if allowance.capacity == 0 {
302                return Err("an allowed decision capacity must be greater than zero");
303            }
304            if allowance.capacity > crate::MAX_LIMIT {
305                return Err("an allowed decision capacity exceeds the portable maximum");
306            }
307            if allowance.available > allowance.capacity {
308                return Err("allowed decision available quota exceeds its capacity");
309            }
310            Ok(())
311        }
312        Outcome::Denied(denial) => validate_denial(&denial),
313        Outcome::ShadowDenied(denial) => {
314            if !matches!(denial, Denial::QuotaExceeded { .. }) {
315                return Err("only quota exhaustion can be shadowed");
316            }
317            validate_denial(&denial)
318        }
319    }
320}
321
322#[cfg(feature = "serde")]
323fn validate_batch_decision(batch: &BatchDecision) -> Result<(), &'static str> {
324    match batch {
325        BatchDecision::Allowed(decisions) => {
326            if decisions.iter().any(|decision| !decision.was_consumed()) {
327                return Err("an allowed batch can contain only consumed allowed decisions");
328            }
329            for decision in decisions {
330                validate_decision(decision)?;
331            }
332            Ok(())
333        }
334        BatchDecision::Denied { denial, .. } => validate_denial(denial),
335        BatchDecision::ShadowDenied { denial, .. } => {
336            if !matches!(denial, Denial::QuotaExceeded { .. }) {
337                return Err("only quota exhaustion can be shadowed");
338            }
339            validate_denial(denial)
340        }
341    }
342}
343
344#[cfg(feature = "serde")]
345#[derive(serde::Serialize)]
346#[serde(tag = "reason", rename_all = "snake_case")]
347enum DenialRef {
348    QuotaExceeded {
349        capacity: u64,
350        retry_after: Duration,
351    },
352    StorageCapacity {
353        retry_after: Option<Duration>,
354    },
355}
356
357#[cfg(feature = "serde")]
358#[derive(serde::Deserialize)]
359#[serde(tag = "reason", rename_all = "snake_case", deny_unknown_fields)]
360enum DenialWire {
361    QuotaExceeded {
362        capacity: u64,
363        retry_after: Duration,
364    },
365    StorageCapacity {
366        retry_after: Option<Duration>,
367    },
368}
369
370#[cfg(feature = "serde")]
371impl serde::Serialize for Denial {
372    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
373    where
374        S: serde::Serializer,
375    {
376        validate_denial(self).map_err(<S::Error as serde::ser::Error>::custom)?;
377        let wire = match *self {
378            Self::QuotaExceeded {
379                capacity,
380                retry_after,
381            } => DenialRef::QuotaExceeded {
382                capacity,
383                retry_after,
384            },
385            Self::StorageCapacity { retry_after } => DenialRef::StorageCapacity { retry_after },
386        };
387        serde::Serialize::serialize(&wire, serializer)
388    }
389}
390
391#[cfg(feature = "serde")]
392impl<'de> serde::Deserialize<'de> for Denial {
393    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
394    where
395        D: serde::Deserializer<'de>,
396    {
397        let wire = <DenialWire as serde::Deserialize>::deserialize(deserializer)?;
398        let denial = match wire {
399            DenialWire::QuotaExceeded {
400                capacity,
401                retry_after,
402            } => Self::QuotaExceeded {
403                capacity,
404                retry_after,
405            },
406            DenialWire::StorageCapacity { retry_after } => Self::StorageCapacity { retry_after },
407        };
408        validate_denial(&denial).map_err(serde::de::Error::custom)?;
409        Ok(denial)
410    }
411}
412
413#[cfg(feature = "serde")]
414#[derive(serde::Serialize)]
415#[serde(tag = "outcome", rename_all = "snake_case")]
416enum DecisionRef<'a> {
417    Allowed {
418        capacity: u64,
419        available: u64,
420        replenishes_after: Duration,
421    },
422    Denied {
423        denial: &'a Denial,
424    },
425    ShadowDenied {
426        denial: &'a Denial,
427    },
428}
429
430#[cfg(feature = "serde")]
431#[derive(serde::Deserialize)]
432#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
433enum DecisionWire {
434    Allowed {
435        capacity: u64,
436        available: u64,
437        replenishes_after: Duration,
438    },
439    Denied {
440        denial: Denial,
441    },
442    ShadowDenied {
443        denial: Denial,
444    },
445}
446
447#[cfg(feature = "serde")]
448impl serde::Serialize for Decision {
449    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
450    where
451        S: serde::Serializer,
452    {
453        validate_decision(self).map_err(<S::Error as serde::ser::Error>::custom)?;
454        let wire = match &self.outcome {
455            Outcome::Allowed(allowance) => DecisionRef::Allowed {
456                capacity: allowance.capacity,
457                available: allowance.available,
458                replenishes_after: allowance.replenishes_after,
459            },
460            Outcome::Denied(denial) => DecisionRef::Denied { denial },
461            Outcome::ShadowDenied(denial) => DecisionRef::ShadowDenied { denial },
462        };
463        serde::Serialize::serialize(&wire, serializer)
464    }
465}
466
467#[cfg(feature = "serde")]
468impl<'de> serde::Deserialize<'de> for Decision {
469    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
470    where
471        D: serde::Deserializer<'de>,
472    {
473        let wire = <DecisionWire as serde::Deserialize>::deserialize(deserializer)?;
474        let decision = match wire {
475            DecisionWire::Allowed {
476                capacity,
477                available,
478                replenishes_after,
479            } => Self::allowed(capacity, available, replenishes_after),
480            DecisionWire::Denied { denial } => Self::denied(denial),
481            DecisionWire::ShadowDenied { denial } => Self::shadow_denied(denial),
482        };
483        validate_decision(&decision).map_err(serde::de::Error::custom)?;
484        Ok(decision)
485    }
486}
487
488#[cfg(feature = "serde")]
489#[derive(serde::Serialize)]
490#[serde(tag = "outcome", rename_all = "snake_case")]
491enum BatchDecisionRef<'a> {
492    Allowed { decisions: &'a [Decision] },
493    Denied { index: usize, denial: &'a Denial },
494    ShadowDenied { index: usize, denial: &'a Denial },
495}
496
497#[cfg(feature = "serde")]
498#[derive(serde::Deserialize)]
499#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
500enum BatchDecisionWire {
501    Allowed { decisions: Vec<Decision> },
502    Denied { index: usize, denial: Denial },
503    ShadowDenied { index: usize, denial: Denial },
504}
505
506#[cfg(feature = "serde")]
507impl serde::Serialize for BatchDecision {
508    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
509    where
510        S: serde::Serializer,
511    {
512        validate_batch_decision(self).map_err(<S::Error as serde::ser::Error>::custom)?;
513        let wire = match self {
514            Self::Allowed(decisions) => BatchDecisionRef::Allowed { decisions },
515            Self::Denied { index, denial } => BatchDecisionRef::Denied {
516                index: *index,
517                denial,
518            },
519            Self::ShadowDenied { index, denial } => BatchDecisionRef::ShadowDenied {
520                index: *index,
521                denial,
522            },
523        };
524        serde::Serialize::serialize(&wire, serializer)
525    }
526}
527
528#[cfg(feature = "serde")]
529impl<'de> serde::Deserialize<'de> for BatchDecision {
530    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
531    where
532        D: serde::Deserializer<'de>,
533    {
534        let wire = <BatchDecisionWire as serde::Deserialize>::deserialize(deserializer)?;
535        let batch = match wire {
536            BatchDecisionWire::Allowed { decisions } => Self::Allowed(decisions),
537            BatchDecisionWire::Denied { index, denial } => Self::Denied { index, denial },
538            BatchDecisionWire::ShadowDenied { index, denial } => {
539                Self::ShadowDenied { index, denial }
540            }
541        };
542        validate_batch_decision(&batch).map_err(serde::de::Error::custom)?;
543        Ok(batch)
544    }
545}
546
547const fn ceil_seconds(duration: Duration) -> u64 {
548    let seconds = duration.as_secs();
549    if duration.subsec_nanos() == 0 {
550        seconds
551    } else {
552        seconds.saturating_add(1)
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use std::time::Duration;
559
560    use super::{BatchDecision, Decision, Denial};
561
562    #[test]
563    fn allowed_decision_exposes_available_and_replenishment() {
564        let decision = Decision::allowed(8, 7, Duration::from_millis(59_999));
565
566        assert!(decision.is_allowed());
567        assert!(!decision.is_denied());
568        assert_eq!(decision.capacity(), Some(8));
569        assert_eq!(decision.available(), Some(7));
570        assert_eq!(
571            decision.replenishes_after(),
572            Some(Duration::from_millis(59_999))
573        );
574        assert_eq!(decision.retry_after(), None);
575        assert_eq!(decision.denial(), None);
576    }
577
578    #[test]
579    fn quota_denial_exposes_exact_and_ceiling_retry_duration() {
580        let denial = Denial::QuotaExceeded {
581            capacity: 8,
582            retry_after: Duration::from_millis(1_001),
583        };
584        let decision = Decision::denied(denial);
585
586        assert!(decision.is_denied());
587        assert_eq!(decision.capacity(), Some(8));
588        assert_eq!(decision.available(), None);
589        assert_eq!(decision.replenishes_after(), None);
590        assert_eq!(decision.retry_after(), Some(Duration::from_millis(1_001)));
591        assert_eq!(decision.retry_after_seconds(), Some(2));
592        assert_eq!(decision.denial(), Some(&denial));
593        assert!(matches!(
594            denial,
595            Denial::QuotaExceeded {
596                capacity: 8,
597                retry_after
598            } if retry_after == Duration::from_millis(1_001)
599        ));
600    }
601
602    #[test]
603    fn retry_after_seconds_preserves_exact_seconds() {
604        let denial = Denial::QuotaExceeded {
605            capacity: 1,
606            retry_after: Duration::from_secs(3),
607        };
608
609        assert_eq!(denial.retry_after_seconds(), Some(3));
610    }
611
612    #[test]
613    fn retry_after_seconds_saturates_without_losing_exact_duration() {
614        let duration = Duration::new(u64::MAX, 1);
615        let denial = Denial::QuotaExceeded {
616            capacity: 1,
617            retry_after: duration,
618        };
619
620        assert_eq!(denial.retry_after(), Some(duration));
621        assert_eq!(denial.retry_after_seconds(), Some(u64::MAX));
622    }
623
624    #[test]
625    fn storage_capacity_retry_can_be_unknown() {
626        let denial = Denial::StorageCapacity { retry_after: None };
627        let decision = Decision::denied(denial);
628
629        assert!(matches!(
630            denial,
631            Denial::StorageCapacity { retry_after: None }
632        ));
633        assert_eq!(denial.retry_after(), None);
634        assert_eq!(denial.retry_after_seconds(), None);
635        assert_eq!(decision.capacity(), None);
636        assert_eq!(decision.retry_after(), None);
637    }
638
639    #[test]
640    fn batch_of_one_converts_to_a_single_decision() {
641        let allowed = Decision::allowed(8, 7, Duration::from_secs(60));
642        let denied = Denial::QuotaExceeded {
643            capacity: 8,
644            retry_after: Duration::from_secs(60),
645        };
646
647        assert_eq!(
648            BatchDecision::Allowed(vec![allowed]).try_into_single_decision(),
649            Ok(allowed)
650        );
651        assert_eq!(
652            BatchDecision::Denied {
653                index: 0,
654                denial: denied
655            }
656            .try_into_single_decision(),
657            Ok(Decision::denied(denied))
658        );
659    }
660
661    #[test]
662    fn malformed_batch_of_one_is_rejected() {
663        let decision = Decision::allowed(8, 7, Duration::from_secs(60));
664        let denial = Denial::QuotaExceeded {
665            capacity: 8,
666            retry_after: Duration::from_secs(60),
667        };
668
669        assert!(
670            BatchDecision::Allowed(Vec::new())
671                .try_into_single_decision()
672                .is_err()
673        );
674        assert!(
675            BatchDecision::Allowed(vec![decision, decision])
676                .try_into_single_decision()
677                .is_err()
678        );
679        assert!(
680            BatchDecision::Allowed(vec![Decision::denied(denial)])
681                .try_into_single_decision()
682                .is_err()
683        );
684        assert!(
685            BatchDecision::Denied { index: 1, denial }
686                .try_into_single_decision()
687                .is_err()
688        );
689    }
690
691    #[test]
692    fn shadow_denial_permits_the_request_without_claiming_consumption() {
693        let denial = Denial::QuotaExceeded {
694            capacity: 8,
695            retry_after: Duration::from_secs(30),
696        };
697        let decision = Decision::shadow_denied(denial);
698
699        assert!(decision.is_allowed());
700        assert!(!decision.is_denied());
701        assert!(decision.permits_request());
702        assert!(decision.would_deny());
703        assert!(decision.is_shadow_denied());
704        assert_eq!(decision.available(), None);
705        assert_eq!(decision.retry_after(), Some(Duration::from_secs(30)));
706        assert_eq!(
707            BatchDecision::ShadowDenied { index: 0, denial }.try_into_single_decision(),
708            Ok(decision)
709        );
710    }
711}