mirror of
https://github.com/mailscope/kumomta.git
synced 2026-09-08 03:28:57 +00:00
allows stuff like:
```toml
[["default".automation]]
regex = "250 2\\.0\\.0 boop"
action = {SetConfig={name="max_connection_rate", value="100/s"}}
trigger = {Threshold="2/hr"}
duration = "2 hours"
```
This commit only enables parsing this information; no action is
taken on it at this time.
326 lines
10 KiB
Rust
326 lines
10 KiB
Rust
//! This crate implements a throttling API based on a generic cell rate algorithm.
|
|
//! The implementation uses an in-memory store, but can be adjusted in the future
|
|
//! to support using a redis-cell equipped redis server to share the throttles
|
|
//! among multiple machines.
|
|
use mod_redis::{Cmd, FromRedisValue, RedisConnection, RedisError};
|
|
use once_cell::sync::OnceCell;
|
|
use redis_cell_impl::{time, MemoryStore, Rate, RateLimiter, RateQuota};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::convert::TryFrom;
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
use thiserror::Error;
|
|
|
|
pub mod limit;
|
|
|
|
static MEMORY: OnceCell<Mutex<MemoryStore>> = OnceCell::new();
|
|
static REDIS: OnceCell<RedisConnection> = OnceCell::new();
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum Error {
|
|
#[error("{0}")]
|
|
Generic(String),
|
|
#[error("{0}")]
|
|
AnyHow(#[from] anyhow::Error),
|
|
#[error("{0}")]
|
|
Redis(#[from] RedisError),
|
|
#[error("TooManyLeases, try again in {0:?}")]
|
|
TooManyLeases(Duration),
|
|
#[error("NonExistentLease")]
|
|
NonExistentLease,
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize, Hash)]
|
|
#[serde(try_from = "String")]
|
|
pub struct ThrottleSpec {
|
|
pub limit: u64,
|
|
/// Period, in seconds
|
|
pub period: u64,
|
|
pub max_burst: Option<u64>,
|
|
}
|
|
|
|
impl ThrottleSpec {
|
|
pub async fn throttle<S: AsRef<str>>(&self, key: S) -> Result<ThrottleResult, Error> {
|
|
let key = key.as_ref();
|
|
let limit = self.limit;
|
|
let period = self.period;
|
|
let max_burst = self.max_burst.unwrap_or(limit);
|
|
let key = format!("{key}:{limit}:{max_burst}:{period}");
|
|
throttle(&key, limit, Duration::from_secs(period), max_burst, Some(1)).await
|
|
}
|
|
}
|
|
|
|
impl TryFrom<String> for ThrottleSpec {
|
|
type Error = String;
|
|
fn try_from(s: String) -> Result<Self, String> {
|
|
Self::try_from(s.as_str())
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&str> for ThrottleSpec {
|
|
type Error = String;
|
|
fn try_from(s: &str) -> Result<Self, String> {
|
|
let (limit, period) = s
|
|
.split_once("/")
|
|
.ok_or_else(|| format!("expected 'limit/period', got {s}"))?;
|
|
|
|
let period = match period {
|
|
"h" | "hr" | "hour" => 3600,
|
|
"m" | "min" | "minute" => 60,
|
|
"s" | "sec" | "second" => 1,
|
|
"d" | "day" => 86400,
|
|
invalid => return Err(format!("unknown period quantity {invalid}")),
|
|
};
|
|
|
|
// Allow "1_000/hr" and "1,000/hr" for more readable config
|
|
let limit: String = limit
|
|
.chars()
|
|
.filter_map(|c| match c {
|
|
'_' | ',' => None,
|
|
c => Some(c),
|
|
})
|
|
.collect();
|
|
|
|
let limit = limit
|
|
.parse::<u64>()
|
|
.map_err(|err| format!("invalid limit '{limit}': {err:#}"))?;
|
|
|
|
Ok(Self {
|
|
limit,
|
|
period,
|
|
max_burst: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq)]
|
|
pub struct ThrottleResult {
|
|
/// true if the action was limited
|
|
pub throttled: bool,
|
|
/// The total limit of the key (max_burst + 1). This is equivalent to the common
|
|
/// X-RateLimit-Limit HTTP header.
|
|
pub limit: u64,
|
|
/// The remaining limit of the key. Equivalent to X-RateLimit-Remaining.
|
|
pub remaining: u64,
|
|
/// The number of seconds until the limit will reset to its maximum capacity.
|
|
/// Equivalent to X-RateLimit-Reset.
|
|
pub reset_after: Duration,
|
|
/// The number of seconds until the user should retry, but None if the action was
|
|
/// allowed. Equivalent to Retry-After.
|
|
pub retry_after: Option<Duration>,
|
|
}
|
|
|
|
fn local_throttle(
|
|
key: &str,
|
|
limit: u64,
|
|
period: Duration,
|
|
max_burst: u64,
|
|
quantity: Option<u64>,
|
|
) -> Result<ThrottleResult, Error> {
|
|
let mut store = MEMORY
|
|
.get_or_init(|| Mutex::new(MemoryStore::new()))
|
|
.lock()
|
|
.unwrap();
|
|
let max_rate = Rate::per_period(
|
|
limit as i64,
|
|
time::Duration::try_from(period).map_err(|err| Error::Generic(format!("{err:#}")))?,
|
|
);
|
|
let mut limiter = RateLimiter::new(
|
|
&mut *store,
|
|
&RateQuota {
|
|
max_burst: max_burst.min(limit - 1) as i64,
|
|
max_rate,
|
|
},
|
|
);
|
|
let quantity = quantity.unwrap_or(1) as i64;
|
|
let (throttled, rate_limit_result) = limiter
|
|
.rate_limit(key, quantity)
|
|
.map_err(|err| Error::Generic(format!("{err:#}")))?;
|
|
|
|
// If either time had a partial component, bump it up to the next full
|
|
// second because otherwise a fast-paced caller could try again too
|
|
// early.
|
|
let mut retry_after = rate_limit_result.retry_after.whole_seconds();
|
|
if rate_limit_result.retry_after.subsec_milliseconds() > 0 {
|
|
retry_after += 1
|
|
}
|
|
let mut reset_after = rate_limit_result.reset_after.whole_seconds();
|
|
if rate_limit_result.reset_after.subsec_milliseconds() > 0 {
|
|
reset_after += 1
|
|
}
|
|
|
|
Ok(ThrottleResult {
|
|
throttled,
|
|
limit: rate_limit_result.limit as u64,
|
|
remaining: rate_limit_result.remaining as u64,
|
|
reset_after: Duration::from_secs(reset_after.max(0) as u64),
|
|
retry_after: if retry_after == -1 {
|
|
None
|
|
} else {
|
|
Some(Duration::from_secs(retry_after.max(0) as u64))
|
|
},
|
|
})
|
|
}
|
|
|
|
async fn redis_throttle(
|
|
conn: RedisConnection,
|
|
key: &str,
|
|
limit: u64,
|
|
period: Duration,
|
|
max_burst: u64,
|
|
quantity: Option<u64>,
|
|
) -> Result<ThrottleResult, Error> {
|
|
let mut cmd = Cmd::new();
|
|
cmd.arg("CL.THROTTLE")
|
|
.arg(key)
|
|
.arg(max_burst)
|
|
.arg(limit)
|
|
.arg(period.as_secs())
|
|
.arg(quantity.unwrap_or(1));
|
|
let result = conn.query(cmd).await?;
|
|
let result = <Vec<i64> as FromRedisValue>::from_redis_value(&result)?;
|
|
|
|
Ok(ThrottleResult {
|
|
throttled: result[0] != 0,
|
|
limit: result[1] as u64,
|
|
remaining: result[2] as u64,
|
|
reset_after: Duration::from_secs(result[3].max(0) as u64),
|
|
retry_after: match result[4] {
|
|
n if n < 0 => None,
|
|
n => Some(Duration::from_secs(n as u64)),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// It is very important for `key` to be used with the same `limit`,
|
|
/// `period` and `max_burst` values in order to produce meaningful
|
|
/// results.
|
|
///
|
|
/// This interface cannot detect or report that kind of misuse.
|
|
/// It is recommended that those parameters be encoded into the
|
|
/// key to make it impossible to misuse.
|
|
///
|
|
/// * `limit` - specifies the maximum number of tokens allow
|
|
/// over the specified `period`
|
|
/// * `period` - the time period over which `limit` is allowed.
|
|
/// * `max_burst` - the maximum initial burst that will be permitted.
|
|
/// set this smaller than `limit` to prevent using
|
|
/// up the entire budget immediately and force it
|
|
/// to spread out across time.
|
|
/// * `quantity` - how many tokens to add to the throttle. If omitted,
|
|
/// 1 token is added.
|
|
pub async fn throttle(
|
|
key: &str,
|
|
limit: u64,
|
|
period: Duration,
|
|
max_burst: u64,
|
|
quantity: Option<u64>,
|
|
) -> Result<ThrottleResult, Error> {
|
|
if let Some(redis) = REDIS.get().cloned() {
|
|
redis_throttle(redis, key, limit, period, max_burst, quantity).await
|
|
} else {
|
|
local_throttle(key, limit, period, max_burst, quantity)
|
|
}
|
|
}
|
|
|
|
pub fn use_redis(conn: RedisConnection) -> Result<(), Error> {
|
|
REDIS
|
|
.set(conn)
|
|
.map_err(|_| Error::Generic("redis already configured for throttles".to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
|
|
fn test_big_limits(limit: u64, max_burst: Option<u64>, permitted_tolerance: f64) {
|
|
let period = Duration::from_secs(60);
|
|
let max_burst = max_burst.unwrap_or(limit);
|
|
let key = format!("test_big_limits-{limit}-{max_burst}");
|
|
|
|
let mut throttled_iter = None;
|
|
|
|
for i in 0..limit * 2 {
|
|
let result = local_throttle(&key, limit, period, max_burst, None).unwrap();
|
|
if result.throttled {
|
|
println!("iter: {i} -> {result:?}");
|
|
throttled_iter.replace(i);
|
|
break;
|
|
}
|
|
}
|
|
|
|
let throttled_iter = throttled_iter.expect("to hit the throttle limit");
|
|
let diff = ((max_burst as f64) - (throttled_iter as f64)).abs();
|
|
let tolerance = (max_burst as f64) * permitted_tolerance;
|
|
println!(
|
|
"throttled after {throttled_iter} iterations for \
|
|
limit {limit}. diff={diff}. tolerance {tolerance}"
|
|
);
|
|
let max_rate = Rate::per_period(limit as i64, time::Duration::try_from(period).unwrap());
|
|
println!("max_rate: {max_rate:?}");
|
|
|
|
assert!(
|
|
diff < tolerance,
|
|
"throttled after {throttled_iter} iterations for \
|
|
limit {limit}. diff={diff} is not within tolerance {tolerance}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basic_throttle_100() {
|
|
test_big_limits(100, None, 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn basic_throttle_1_000() {
|
|
test_big_limits(1_000, None, 0.02);
|
|
}
|
|
|
|
#[test]
|
|
fn basic_throttle_6_000() {
|
|
test_big_limits(6_000, None, 0.02);
|
|
}
|
|
|
|
#[test]
|
|
fn basic_throttle_60_000() {
|
|
test_big_limits(60_000, None, 0.05);
|
|
}
|
|
|
|
#[test]
|
|
fn basic_throttle_60_000_burst_30k() {
|
|
// Note that the 5% tolerance here is the same as the basic_throttle_60_000
|
|
// test case because the variance is due to timing issues with very small
|
|
// time periods produced by the overally limit, rather than the burst.
|
|
test_big_limits(60_000, Some(30_000), 0.05);
|
|
}
|
|
|
|
#[test]
|
|
fn throttle_spec_parse() {
|
|
assert_eq!(
|
|
ThrottleSpec::try_from("100/hr").unwrap(),
|
|
ThrottleSpec {
|
|
limit: 100,
|
|
period: 3600,
|
|
max_burst: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
ThrottleSpec::try_from("1_0,0/hour").unwrap(),
|
|
ThrottleSpec {
|
|
limit: 100,
|
|
period: 3600,
|
|
max_burst: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
ThrottleSpec::try_from("100/our").unwrap_err(),
|
|
"unknown period quantity our".to_string()
|
|
);
|
|
assert_eq!(
|
|
ThrottleSpec::try_from("three/hour").unwrap_err(),
|
|
"invalid limit 'three': invalid digit found in string".to_string()
|
|
);
|
|
}
|
|
}
|