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// }