Skip to main content

rate_net/
decision.rs

1//! The outcome of a rate-limit check.
2
3use core::time::Duration;
4
5/// The result of checking a key against its limit.
6///
7/// Returned by [`RateLimiter::check`](crate::RateLimiter::check) and
8/// [`check_n`](crate::RateLimiter::check_n). A check is infallible — there is no
9/// error case on the request path, only an allow/deny outcome — so this is a
10/// plain enum rather than a `Result`. When a request is denied, the decision
11/// carries how long the caller should wait before enough capacity will have
12/// accrued, which is exactly what an HTTP `Retry-After` header needs.
13///
14/// `#[non_exhaustive]` so future variants can be added without breaking callers;
15/// match with a wildcard arm, or use the [`is_allow`](Self::is_allow) /
16/// [`retry_after`](Self::retry_after) helpers.
17///
18/// # Examples
19///
20/// ```
21/// # #[cfg(feature = "std")] {
22/// use rate_net::{RateLimiter, Decision};
23///
24/// let limiter = RateLimiter::per_second(1);
25/// match limiter.check("user:42") {
26///     Decision::Allow => { /* serve the request */ }
27///     Decision::Deny { retry_after } => {
28///         // return 429 with `Retry-After: {retry_after}`
29///         let _ = retry_after;
30///     }
31///     _ => {}
32/// }
33/// # }
34/// ```
35#[must_use]
36#[non_exhaustive]
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Decision {
39    /// The request is within the limit and has been counted against the key.
40    Allow,
41    /// The request would exceed the key's limit and was refused.
42    Deny {
43        /// The minimum wait until the same request would be admitted. A value of
44        /// [`Duration::MAX`] means it can never succeed — the request asked for
45        /// more than the limit's burst capacity.
46        retry_after: Duration,
47    },
48}
49
50impl Decision {
51    /// Returns `true` if the request was admitted.
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use rate_net::Decision;
57    ///
58    /// assert!(Decision::Allow.is_allow());
59    /// ```
60    #[must_use]
61    pub const fn is_allow(&self) -> bool {
62        matches!(self, Self::Allow)
63    }
64
65    /// Returns `true` if the request was refused.
66    ///
67    /// # Examples
68    ///
69    /// ```
70    /// use rate_net::Decision;
71    /// use std::time::Duration;
72    ///
73    /// let denied = Decision::Deny { retry_after: Duration::from_millis(250) };
74    /// assert!(denied.is_deny());
75    /// ```
76    #[must_use]
77    pub const fn is_deny(&self) -> bool {
78        !self.is_allow()
79    }
80
81    /// Returns the wait until the request would be admitted, or `None` if it was
82    /// allowed.
83    ///
84    /// Use it to populate an HTTP `Retry-After` header on a `429` response, or
85    /// to drive a client-side backoff.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use rate_net::Decision;
91    /// use std::time::Duration;
92    ///
93    /// let denied = Decision::Deny { retry_after: Duration::from_millis(250) };
94    /// assert_eq!(denied.retry_after(), Some(Duration::from_millis(250)));
95    /// assert_eq!(Decision::Allow.retry_after(), None);
96    /// ```
97    #[must_use]
98    pub const fn retry_after(&self) -> Option<Duration> {
99        match self {
100            Self::Deny { retry_after } => Some(*retry_after),
101            _ => None,
102        }
103    }
104}
105
106impl From<better_bucket::Decision> for Decision {
107    /// Lifts a [`better_bucket::Decision`] into the rate-net decision. The token
108    /// bucket's `Allowed`/`Denied { retry_after }` maps directly; any future
109    /// variant added upstream is treated, conservatively, as a denial that can
110    /// never succeed.
111    fn from(decision: better_bucket::Decision) -> Self {
112        match decision {
113            better_bucket::Decision::Allowed => Self::Allow,
114            better_bucket::Decision::Denied { retry_after } => Self::Deny { retry_after },
115            _ => Self::Deny {
116                retry_after: Duration::MAX,
117            },
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::Decision;
125    use core::time::Duration;
126
127    #[test]
128    fn test_allow_predicates() {
129        let allow = Decision::Allow;
130        assert!(allow.is_allow());
131        assert!(!allow.is_deny());
132        assert_eq!(allow.retry_after(), None);
133    }
134
135    #[test]
136    fn test_deny_predicates() {
137        let deny = Decision::Deny {
138            retry_after: Duration::from_secs(2),
139        };
140        assert!(deny.is_deny());
141        assert!(!deny.is_allow());
142        assert_eq!(deny.retry_after(), Some(Duration::from_secs(2)));
143    }
144
145    #[test]
146    fn test_from_better_bucket_allowed_maps_to_allow() {
147        assert_eq!(
148            Decision::from(better_bucket::Decision::Allowed),
149            Decision::Allow
150        );
151    }
152
153    #[test]
154    fn test_from_better_bucket_denied_carries_retry_after() {
155        let upstream = better_bucket::Decision::Denied {
156            retry_after: Duration::from_millis(500),
157        };
158        assert_eq!(
159            Decision::from(upstream),
160            Decision::Deny {
161                retry_after: Duration::from_millis(500)
162            }
163        );
164    }
165}