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 pub const fn per_second(max_burst: NonZeroU32) -> Self {
93 let replenish_interval_ns = Duration::from_secs(1).as_nanos() / (max_burst.get() as u128);
94 Self {
95 max_burst,
96 replenish_1_per: Duration::from_nanos(replenish_interval_ns as u64),
97 }
98 }
99
100 /// Construct a quota for a number of cells per 60-second period. The given number of cells is
101 /// also assumed to be the maximum burst size.
102 pub const fn per_minute(max_burst: NonZeroU32) -> Self {
103 let replenish_interval_ns = Duration::from_secs(60).as_nanos() / (max_burst.get() as u128);
104 Self {
105 max_burst,
106 replenish_1_per: Duration::from_nanos(replenish_interval_ns as u64),
107 }
108 }
109
110 /// Construct a quota for a number of cells per 60-minute (3600-second) period. The given number
111 /// of cells is also assumed to be the maximum burst size.
112 pub const fn per_hour(max_burst: NonZeroU32) -> Self {
113 let replenish_interval_ns =
114 Duration::from_secs(60 * 60).as_nanos() / (max_burst.get() as u128);
115 Self {
116 max_burst,
117 replenish_1_per: Duration::from_nanos(replenish_interval_ns as u64),
118 }
119 }
120
121 /// Construct a quota that replenishes one cell in a given
122 /// interval.
123 ///
124 /// This constructor is meant to replace [`::new`](#method.new),
125 /// in cases where a longer refresh period than 1 cell/hour is
126 /// necessary.
127 ///
128 /// If the time interval is zero, returns `None`.
129 pub const fn with_period(replenish_1_per: Duration) -> Option<Self> {
130 if replenish_1_per.as_nanos() == 0 {
131 None
132 } else {
133 Some(Self {
134 max_burst: nonzero!(1u32),
135 replenish_1_per,
136 })
137 }
138 }
139
140 /// Adjusts the maximum burst size for a quota to construct a rate limiter with a capacity
141 /// for at most the given number of cells.
142 pub const fn allow_burst(self, max_burst: NonZeroU32) -> Self {
143 Self { max_burst, ..self }
144 }
145
146 /// Construct a quota for a given burst size, replenishing the entire burst size in that
147 /// given unit of time.
148 ///
149 /// Returns `None` if the duration is zero.
150 ///
151 /// This constructor allows greater control over the resulting
152 /// quota, but doesn't make as much intuitive sense as other
153 /// methods of constructing the same quotas. Unless your quotas
154 /// are given as "max burst size, and time it takes to replenish
155 /// that burst size", you are better served by the
156 /// [`Quota::per_second`](#method.per_second) (and similar)
157 /// constructors with the [`allow_burst`](#method.allow_burst)
158 /// modifier.
159 #[deprecated(
160 since = "0.2.0",
161 note = "This constructor is often confusing and non-intuitive. \
162 Use the `per_(interval)` / `with_period` and `max_burst` constructors instead."
163 )]
164 pub fn new(max_burst: NonZeroU32, replenish_all_per: Duration) -> Option<Self> {
165 if replenish_all_per.as_nanos() == 0 {
166 None
167 } else {
168 Some(Self {
169 max_burst,
170 replenish_1_per: replenish_all_per / max_burst.get(),
171 })
172 }
173 }
174}
175
176/// Retrieving information about a quota
177impl Quota {
178 /// The time it takes for a rate limiter with an exhausted burst budget to replenish
179 /// a single element.
180 pub const fn replenish_interval(&self) -> Duration {
181 self.replenish_1_per
182 }
183
184 /// The maximum number of cells that can be allowed in one burst.
185 pub const fn burst_size(&self) -> NonZeroU32 {
186 self.max_burst
187 }
188
189 /// The time it takes to replenish the entire maximum burst size.
190 pub const fn burst_size_replenished_in(&self) -> Duration {
191 let fill_in_ns = self.replenish_1_per.as_nanos() * self.max_burst.get() as u128;
192 Duration::from_nanos(fill_in_ns as u64)
193 }
194}
195
196impl Quota {
197 /// A way to reconstruct a Quota from an in-use Gcra.
198 ///
199 /// This is useful mainly for [`crate::middleware::RateLimitingMiddleware`]
200 /// where custom code may want to construct information based on
201 /// the amount of burst balance remaining.
202 pub(crate) fn from_gcra_parameters(t: Nanos, tau: Nanos) -> Self {
203 // Safety assurance: As we're calling this from this crate
204 // only, and we do not allow creating a Gcra from 0
205 // parameters, this is, in fact, safe.
206 //
207 // The casts may look a little sketch, but again, they're
208 // constructed from parameters that came from the crate
209 // exactly like that.
210 let max_burst = unsafe { NonZeroU32::new_unchecked((tau.as_u64() / t.as_u64()) as u32) };
211 let replenish_1_per = t.into();
212 Self {
213 max_burst,
214 replenish_1_per,
215 }
216 }
217}
218
219////////////////////////////////////////////////////////////////////////////////
220// Tests
221////////////////////////////////////////////////////////////////////////////////
222// #[cfg(test)]
223// mod test {
224// use nonzero_ext::nonzero;
225
226// use super::*;
227
228// #[test]
229// fn time_multiples() {
230// let hourly = Quota::per_hour(nonzero!(1u32));
231// let minutely = Quota::per_minute(nonzero!(1u32));
232// let secondly = Quota::per_second(nonzero!(1u32));
233
234// assert_eq!(
235// hourly.replenish_interval() / 60,
236// minutely.replenish_interval()
237// );
238// assert_eq!(
239// minutely.replenish_interval() / 60,
240// secondly.replenish_interval()
241// );
242// }
243
244// #[test]
245// fn period_error_cases() {
246// assert!(Quota::with_period(Duration::from_secs(0)).is_none());
247
248// #[allow(deprecated)]
249// {
250// assert!(Quota::new(nonzero!(1u32), Duration::from_secs(0)).is_none());
251// }
252// }
253// }