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}