nautilus_network/ratelimiter/
quota.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{num::NonZeroU32, prelude::v1::*, time::Duration};
17
18use nonzero_ext::nonzero;
19use pyo3::{exceptions::PyException, prelude::*};
20
21use super::nanos::Nanos;
22
23/// A rate-limiting quota.
24///
25/// Quotas are expressed in a positive number of "cells" (the maximum number of positive decisions /
26/// allowed items until the rate limiter needs to replenish) and the amount of time for the rate
27/// limiter to replenish a single cell.
28///
29/// Neither the number of cells nor the replenishment unit of time may be zero.
30///
31/// # Burst sizes
32/// There are multiple ways of expressing the same quota: a quota given as `Quota::per_second(1)`
33/// allows, on average, the same number of cells through as a quota given as `Quota::per_minute(60)`.
34/// The quota of `Quota::per_minute(60)` has a burst size of 60 cells, meaning it is
35/// possible to accomodate 60 cells in one go, after which the equivalent of a minute of inactivity
36/// is required for the burst allowance to be fully restored.
37///
38/// Burst size gets really important when you construct a rate limiter that should allow multiple
39/// elements through at one time (using [`RateLimiter.check_n`](struct.RateLimiter.html#method.check_n)
40/// and its related functions): Only
41/// at most as many cells can be let through in one call as are given as the burst size.
42///
43/// In other words, the burst size is the maximum number of cells that the rate limiter will ever
44/// allow through without replenishing them.
45#[derive(Debug, PartialEq, Eq, Clone, Copy)]
46#[cfg_attr(
47    feature = "python",
48    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network")
49)]
50pub struct Quota {
51    pub(crate) max_burst: NonZeroU32,
52    pub(crate) replenish_1_per: Duration,
53}
54
55#[pymethods]
56impl Quota {
57    #[staticmethod]
58    pub fn rate_per_second(max_burst: u32) -> PyResult<Self> {
59        match NonZeroU32::new(max_burst) {
60            Some(max_burst) => Ok(Self::per_second(max_burst)),
61            None => Err(PyErr::new::<PyException, _>(
62                "Max burst capacity should be a non-zero integer",
63            )),
64        }
65    }
66
67    #[staticmethod]
68    pub fn rate_per_minute(max_burst: u32) -> PyResult<Self> {
69        match NonZeroU32::new(max_burst) {
70            Some(max_burst) => Ok(Self::per_minute(max_burst)),
71            None => Err(PyErr::new::<PyException, _>(
72                "Max burst capacity should be a non-zero integer",
73            )),
74        }
75    }
76
77    #[staticmethod]
78    pub fn rate_per_hour(max_burst: u32) -> PyResult<Self> {
79        match NonZeroU32::new(max_burst) {
80            Some(max_burst) => Ok(Self::per_hour(max_burst)),
81            None => Err(PyErr::new::<PyException, _>(
82                "Max burst capacity should be a non-zero integer",
83            )),
84        }
85    }
86}
87
88/// Constructors for Quotas
89impl Quota {
90    /// Construct a quota for a number of cells per second. The given number of cells is also
91    /// assumed to be the maximum burst size.
92    #[must_use]
93    pub const fn per_second(max_burst: NonZeroU32) -> Self {
94        let replenish_interval_ns = Duration::from_secs(1).as_nanos() / (max_burst.get() as u128);
95        Self {
96            max_burst,
97            replenish_1_per: Duration::from_nanos(replenish_interval_ns as u64),
98        }
99    }
100
101    /// Construct a quota for a number of cells per 60-second period. The given number of cells is
102    /// also assumed to be the maximum burst size.
103    #[must_use]
104    pub const fn per_minute(max_burst: NonZeroU32) -> Self {
105        let replenish_interval_ns = Duration::from_secs(60).as_nanos() / (max_burst.get() as u128);
106        Self {
107            max_burst,
108            replenish_1_per: Duration::from_nanos(replenish_interval_ns as u64),
109        }
110    }
111
112    /// Construct a quota for a number of cells per 60-minute (3600-second) period. The given number
113    /// of cells is also assumed to be the maximum burst size.
114    #[must_use]
115    pub const fn per_hour(max_burst: NonZeroU32) -> Self {
116        let replenish_interval_ns =
117            Duration::from_secs(60 * 60).as_nanos() / (max_burst.get() as u128);
118        Self {
119            max_burst,
120            replenish_1_per: Duration::from_nanos(replenish_interval_ns as u64),
121        }
122    }
123
124    /// Construct a quota that replenishes one cell in a given
125    /// interval.
126    ///
127    /// This constructor is meant to replace [`::new`](#method.new),
128    /// in cases where a longer refresh period than 1 cell/hour is
129    /// necessary.
130    ///
131    /// If the time interval is zero, returns `None`.
132    #[must_use]
133    pub const fn with_period(replenish_1_per: Duration) -> Option<Self> {
134        if replenish_1_per.as_nanos() == 0 {
135            None
136        } else {
137            Some(Self {
138                max_burst: nonzero!(1u32),
139                replenish_1_per,
140            })
141        }
142    }
143
144    /// Adjusts the maximum burst size for a quota to construct a rate limiter with a capacity
145    /// for at most the given number of cells.
146    #[must_use]
147    pub const fn allow_burst(self, max_burst: NonZeroU32) -> Self {
148        Self { max_burst, ..self }
149    }
150
151    /// Construct a quota for a given burst size, replenishing the entire burst size in that
152    /// given unit of time.
153    ///
154    /// Returns `None` if the duration is zero.
155    ///
156    /// This constructor allows greater control over the resulting
157    /// quota, but doesn't make as much intuitive sense as other
158    /// methods of constructing the same quotas. Unless your quotas
159    /// are given as "max burst size, and time it takes to replenish
160    /// that burst size", you are better served by the
161    /// [`Quota::per_second`](#method.per_second) (and similar)
162    /// constructors with the [`allow_burst`](#method.allow_burst)
163    /// modifier.
164    #[deprecated(
165        since = "0.2.0",
166        note = "This constructor is often confusing and non-intuitive. \
167    Use the `per_(interval)` / `with_period` and `max_burst` constructors instead."
168    )]
169    #[must_use]
170    pub fn new(max_burst: NonZeroU32, replenish_all_per: Duration) -> Option<Self> {
171        if replenish_all_per.as_nanos() == 0 {
172            None
173        } else {
174            Some(Self {
175                max_burst,
176                replenish_1_per: replenish_all_per / max_burst.get(),
177            })
178        }
179    }
180}
181
182/// Retrieving information about a quota
183impl Quota {
184    /// The time it takes for a rate limiter with an exhausted burst budget to replenish
185    /// a single element.
186    #[must_use]
187    pub const fn replenish_interval(&self) -> Duration {
188        self.replenish_1_per
189    }
190
191    /// The maximum number of cells that can be allowed in one burst.
192    #[must_use]
193    pub const fn burst_size(&self) -> NonZeroU32 {
194        self.max_burst
195    }
196
197    /// The time it takes to replenish the entire maximum burst size.
198    #[must_use]
199    pub const fn burst_size_replenished_in(&self) -> Duration {
200        let fill_in_ns = self.replenish_1_per.as_nanos() * self.max_burst.get() as u128;
201        Duration::from_nanos(fill_in_ns as u64)
202    }
203}
204
205impl Quota {
206    /// A way to reconstruct a Quota from an in-use Gcra.
207    ///
208    /// This is useful mainly for [`crate::middleware::RateLimitingMiddleware`]
209    /// where custom code may want to construct information based on
210    /// the amount of burst balance remaining.
211    pub(crate) fn from_gcra_parameters(t: Nanos, tau: Nanos) -> Self {
212        // Safety assurance: As we're calling this from this crate
213        // only, and we do not allow creating a Gcra from 0
214        // parameters, this is, in fact, safe.
215        //
216        // The casts may look a little sketch, but again, they're
217        // constructed from parameters that came from the crate
218        // exactly like that.
219        let max_burst = unsafe { NonZeroU32::new_unchecked((tau.as_u64() / t.as_u64()) as u32) };
220        let replenish_1_per = t.into();
221        Self {
222            max_burst,
223            replenish_1_per,
224        }
225    }
226}
227
228////////////////////////////////////////////////////////////////////////////////
229// Tests
230////////////////////////////////////////////////////////////////////////////////
231// #[cfg(test)]
232// mod test {
233//     use nonzero_ext::nonzero;
234
235//     use super::*;
236
237//     #[test]
238//     fn time_multiples() {
239//         let hourly = Quota::per_hour(nonzero!(1u32));
240//         let minutely = Quota::per_minute(nonzero!(1u32));
241//         let secondly = Quota::per_second(nonzero!(1u32));
242
243//         assert_eq!(
244//             hourly.replenish_interval() / 60,
245//             minutely.replenish_interval()
246//         );
247//         assert_eq!(
248//             minutely.replenish_interval() / 60,
249//             secondly.replenish_interval()
250//         );
251//     }
252
253//     #[test]
254//     fn period_error_cases() {
255//         assert!(Quota::with_period(Duration::from_secs(0)).is_none());
256
257//         #[allow(deprecated)]
258//         {
259//             assert!(Quota::new(nonzero!(1u32), Duration::from_secs(0)).is_none());
260//         }
261//     }
262// }