use super::QuotaType;
use crate::constants::{
MAX_CONCURRENT_REQUESTS, MAX_SLIDING_LOG_REQUESTS, MAX_TOKEN_BUCKET_CAPACITY,
MAX_TOKEN_BUCKET_REFILL_RATE, MAX_WINDOW_REQUESTS,
};
use crate::i18n::t;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverdraftConfig {
pub enabled: bool,
pub max_overdraft: u64,
}
impl OverdraftConfig {
pub fn validate(&self) -> Result<(), String> {
if self.enabled && self.max_overdraft == 0 {
return Err("Overdraft enabled: max overdraft cannot be 0".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum LimiterConfig {
TokenBucket {
capacity: u64,
refill_rate: u64,
},
SlidingWindow {
window_size: String,
max_requests: u64,
},
FixedWindow {
window_size: String,
max_requests: u64,
},
Quota {
quota_type: QuotaType,
limit: u64,
window: String,
alert_threshold: Option<u8>,
overdraft: Option<OverdraftConfig>,
},
Concurrency {
max_concurrent: u64,
},
LeakyBucket {
capacity: u64,
leak_rate: u64,
},
SlidingWindowLog {
window_size: String,
max_requests: u64,
},
PriorityQueue {
window_size: String,
total_per_window: u64,
level_weights: Vec<u64>,
#[serde(default)]
default_priority: Option<usize>,
},
AdmissionControl {
max_concurrent: u64,
max_per_second: u64,
},
Custom {
name: String,
config: serde_json::Value,
},
}
impl LimiterConfig {
pub fn validate(&self) -> Result<(), String> {
match self {
LimiterConfig::TokenBucket {
capacity,
refill_rate,
} => {
if *capacity == 0 {
return Err("Token bucket capacity cannot be 0".to_string());
}
if *refill_rate == 0 {
return Err("Refill rate cannot be 0".to_string());
}
if *capacity > MAX_TOKEN_BUCKET_CAPACITY {
return Err(format!(
"Token bucket capacity cannot exceed {MAX_TOKEN_BUCKET_CAPACITY}"
));
}
if *refill_rate > MAX_TOKEN_BUCKET_REFILL_RATE {
return Err(format!(
"Token bucket refill rate cannot exceed {MAX_TOKEN_BUCKET_REFILL_RATE}"
));
}
}
LimiterConfig::SlidingWindow {
window_size,
max_requests,
} => {
if *max_requests == 0 {
return Err("Max requests cannot be 0".to_string());
}
if *max_requests > MAX_WINDOW_REQUESTS {
return Err(format!("Max requests cannot exceed {MAX_WINDOW_REQUESTS}"));
}
Self::validate_window_size(window_size)?;
}
LimiterConfig::FixedWindow {
window_size,
max_requests,
} => {
if *max_requests == 0 {
return Err("Max requests cannot be 0".to_string());
}
if *max_requests > MAX_WINDOW_REQUESTS {
return Err(format!("Max requests cannot exceed {MAX_WINDOW_REQUESTS}"));
}
Self::validate_window_size(window_size)?;
}
LimiterConfig::Quota {
quota_type: _,
limit,
window,
alert_threshold,
overdraft,
} => {
if *limit == 0 {
return Err("Quota limit cannot be 0".to_string());
}
if let Some(threshold) = alert_threshold
&& *threshold > 100
{
return Err("Alert threshold cannot exceed 100%".to_string());
}
Self::validate_window_size(window)?;
if let Some(overdraft) = overdraft {
overdraft.validate()?;
}
}
LimiterConfig::Concurrency { max_concurrent } => {
if *max_concurrent == 0 {
return Err("Max concurrency cannot be 0".to_string());
}
if *max_concurrent > MAX_CONCURRENT_REQUESTS {
return Err(format!(
"Max concurrency cannot exceed {MAX_CONCURRENT_REQUESTS}"
));
}
}
LimiterConfig::LeakyBucket {
capacity,
leak_rate,
} => {
if *capacity == 0 {
return Err("Leaky bucket capacity cannot be 0".to_string());
}
if *leak_rate == 0 {
return Err("Leaky bucket leak_rate cannot be 0".to_string());
}
if *capacity > MAX_TOKEN_BUCKET_CAPACITY {
return Err(format!(
"Leaky bucket capacity cannot exceed {MAX_TOKEN_BUCKET_CAPACITY}"
));
}
if *leak_rate > MAX_TOKEN_BUCKET_REFILL_RATE {
return Err(format!(
"Leaky bucket leak_rate cannot exceed {MAX_TOKEN_BUCKET_REFILL_RATE}"
));
}
}
LimiterConfig::SlidingWindowLog {
window_size,
max_requests,
} => {
if *max_requests == 0 {
return Err("Sliding window log max_requests cannot be 0".to_string());
}
if *max_requests > MAX_SLIDING_LOG_REQUESTS {
return Err(format!(
"Sliding window log max_requests cannot exceed {MAX_SLIDING_LOG_REQUESTS}"
));
}
Self::validate_window_size(window_size)?;
}
LimiterConfig::PriorityQueue {
window_size,
total_per_window,
level_weights,
default_priority,
} => {
if *total_per_window == 0 {
return Err("Priority queue total_per_window cannot be 0".to_string());
}
Self::validate_window_size(window_size)?;
if level_weights.is_empty() || level_weights.contains(&0) {
return Err(
"Priority queue level_weights must be non-empty and non-zero".to_string(),
);
}
if let Some(default_priority) = default_priority
&& *default_priority >= level_weights.len()
{
return Err(format!(
"Priority queue default_priority {} out of range (levels: {})",
default_priority,
level_weights.len()
));
}
}
LimiterConfig::AdmissionControl {
max_concurrent,
max_per_second,
} => {
if *max_concurrent == 0 {
return Err("Admission control max_concurrent cannot be 0".to_string());
}
if *max_per_second == 0 {
return Err("Admission control max_per_second cannot be 0".to_string());
}
}
LimiterConfig::Custom { name, config } => {
if name.is_empty() {
return Err("Custom limiter name cannot be empty".to_string());
}
if config.is_null() {
return Err("Custom limiter config cannot be empty".to_string());
}
}
}
Ok(())
}
fn validate_window_size(window_size: &str) -> Result<(), String> {
parse_window_size(window_size).map(|_| ())
}
}
pub(crate) fn parse_window_size(window_size: &str) -> Result<std::time::Duration, String> {
let trimmed = window_size.trim();
if trimmed.is_empty() {
return Err(t("window-size-empty", &[]));
}
let split_index = trimmed
.find(|c: char| c.is_alphabetic())
.unwrap_or(trimmed.len());
let (num_part, unit_part) = trimmed.split_at(split_index);
let num_str = num_part.trim();
let unit = unit_part.trim().to_lowercase();
if num_str.is_empty() {
return Err(t("window-size-missing-number", &[]));
}
if unit.is_empty() {
return Err(t("window-size-missing-unit", &[]));
}
let num: u64 = num_str.parse().map_err(|_| {
t(
"window-size-invalid-number",
&[("input", num_str.to_string())],
)
})?;
if num == 0 {
return Err(t("window-size-must-be-positive", &[]));
}
match unit.as_str() {
"ms" | "millisecond" | "milliseconds" => Ok(std::time::Duration::from_millis(num)),
"s" | "sec" | "second" | "seconds" => Ok(std::time::Duration::from_secs(num)),
"m" | "min" | "minute" | "minutes" => mul_secs(num, 60),
"h" | "hr" | "hour" | "hours" => mul_secs(num, 3600),
"d" | "day" | "days" => mul_secs(num, 86400),
_ => Err(t(
"window-size-unsupported-unit",
&[("unit", unit.clone())],
)),
}
}
fn mul_secs(num: u64, factor: u64) -> Result<std::time::Duration, String> {
num.checked_mul(factor)
.map(std::time::Duration::from_secs)
.ok_or_else(|| {
t(
"window-size-overflow",
&[("number", num.to_string()), ("factor", factor.to_string())],
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::QuotaType;
#[test]
fn test_parse_ms() {
let d = parse_window_size("500ms").unwrap();
assert_eq!(d, std::time::Duration::from_millis(500));
}
#[test]
fn test_parse_seconds() {
let d = parse_window_size("30s").unwrap();
assert_eq!(d, std::time::Duration::from_secs(30));
}
#[test]
fn test_parse_minutes() {
let d = parse_window_size("5m").unwrap();
assert_eq!(d, std::time::Duration::from_secs(300));
}
#[test]
fn test_parse_hours() {
let d = parse_window_size("2h").unwrap();
assert_eq!(d, std::time::Duration::from_secs(7200));
}
#[test]
fn test_parse_days() {
let d = parse_window_size("1d").unwrap();
assert_eq!(d, std::time::Duration::from_secs(86400));
}
#[test]
fn test_parse_full_unit_names() {
assert_eq!(
parse_window_size("10seconds").unwrap(),
std::time::Duration::from_secs(10)
);
assert_eq!(
parse_window_size("3minutes").unwrap(),
std::time::Duration::from_secs(180)
);
assert!(parse_window_size("2hours").is_ok());
assert!(parse_window_size("1days").is_ok());
}
#[test]
fn test_parse_empty() {
assert!(parse_window_size("").is_err());
}
#[test]
fn test_parse_zero() {
assert!(parse_window_size("0s").is_err());
}
#[test]
fn test_parse_unknown_unit() {
assert!(parse_window_size("10weeks").is_err());
}
#[test]
fn test_parse_no_number() {
assert!(parse_window_size("s").is_err());
}
#[test]
fn test_parse_no_unit() {
assert!(parse_window_size("10").is_err());
}
#[test]
fn test_parse_invalid_number() {
assert!(parse_window_size("abc").is_err());
}
#[test]
fn test_token_bucket_valid() {
let config = LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_token_bucket_zero_capacity() {
let config = LimiterConfig::TokenBucket {
capacity: 0,
refill_rate: 10,
};
assert!(config.validate().is_err());
}
#[test]
fn test_token_bucket_zero_refill() {
let config = LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 0,
};
assert!(config.validate().is_err());
}
#[test]
fn test_leaky_bucket_valid() {
let config = LimiterConfig::LeakyBucket {
capacity: 100,
leak_rate: 10,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_leaky_bucket_zero_capacity() {
let config = LimiterConfig::LeakyBucket {
capacity: 0,
leak_rate: 10,
};
assert!(config.validate().is_err());
}
#[test]
fn test_leaky_bucket_zero_leak_rate() {
let config = LimiterConfig::LeakyBucket {
capacity: 100,
leak_rate: 0,
};
assert!(config.validate().is_err());
}
#[test]
fn test_sliding_window_log_valid() {
let config = LimiterConfig::SlidingWindowLog {
window_size: "60s".into(),
max_requests: 100,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_sliding_window_log_zero_requests() {
let config = LimiterConfig::SlidingWindowLog {
window_size: "60s".into(),
max_requests: 0,
};
assert!(config.validate().is_err());
}
#[test]
fn test_sliding_window_log_invalid_window() {
let config = LimiterConfig::SlidingWindowLog {
window_size: "".into(),
max_requests: 100,
};
assert!(config.validate().is_err());
}
#[test]
fn test_sliding_window_log_requests_over_log_ceiling() {
let config = LimiterConfig::SlidingWindowLog {
window_size: "60s".into(),
max_requests: MAX_WINDOW_REQUESTS,
};
assert!(config.validate().is_err());
}
#[test]
fn test_sliding_window_valid() {
let config = LimiterConfig::SlidingWindow {
window_size: "60s".into(),
max_requests: 100,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_sliding_window_zero_requests() {
let config = LimiterConfig::SlidingWindow {
window_size: "60s".into(),
max_requests: 0,
};
assert!(config.validate().is_err());
}
#[test]
fn test_sliding_window_invalid_window() {
let config = LimiterConfig::SlidingWindow {
window_size: "".into(),
max_requests: 100,
};
assert!(config.validate().is_err());
}
#[test]
fn test_fixed_window_valid() {
let config = LimiterConfig::FixedWindow {
window_size: "60s".into(),
max_requests: 100,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_concurrency_valid() {
let config = LimiterConfig::Concurrency { max_concurrent: 10 };
assert!(config.validate().is_ok());
}
#[test]
fn test_concurrency_zero() {
let config = LimiterConfig::Concurrency { max_concurrent: 0 };
assert!(config.validate().is_err());
}
#[test]
fn test_custom_valid() {
let config = LimiterConfig::Custom {
name: "my-limiter".into(),
config: serde_json::json!({"key": "value"}),
};
assert!(config.validate().is_ok());
}
#[test]
fn test_custom_empty_name() {
let config = LimiterConfig::Custom {
name: "".into(),
config: serde_json::json!({"key": "value"}),
};
assert!(config.validate().is_err());
}
#[test]
fn test_custom_null_config() {
let config = LimiterConfig::Custom {
name: "my-limiter".into(),
config: serde_json::Value::Null,
};
assert!(config.validate().is_err());
}
#[test]
fn test_quota_valid() {
let config = LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 1000,
window: "1d".into(),
alert_threshold: Some(80),
overdraft: Some(OverdraftConfig {
enabled: true,
max_overdraft: 100,
}),
};
assert!(config.validate().is_ok());
}
#[test]
fn test_quota_zero_limit() {
let config = LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 0,
window: "1d".into(),
alert_threshold: None,
overdraft: None,
};
assert!(config.validate().is_err());
}
#[test]
fn test_quota_threshold_overflow() {
let config = LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 1000,
window: "1d".into(),
alert_threshold: Some(150),
overdraft: None,
};
assert!(config.validate().is_err());
}
#[test]
fn test_quota_overdraft_enabled_zero() {
let config = LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 1000,
window: "1d".into(),
alert_threshold: None,
overdraft: Some(OverdraftConfig {
enabled: true,
max_overdraft: 0,
}),
};
assert!(config.validate().is_err());
}
#[test]
fn test_quota_overdraft_disabled() {
let config = LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 1000,
window: "1d".into(),
alert_threshold: None,
overdraft: Some(OverdraftConfig {
enabled: false,
max_overdraft: 0,
}),
};
assert!(config.validate().is_ok());
}
#[test]
fn test_overdraft_enabled_valid() {
let config = OverdraftConfig {
enabled: true,
max_overdraft: 100,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_overdraft_enabled_zero() {
let config = OverdraftConfig {
enabled: true,
max_overdraft: 0,
};
assert!(config.validate().is_err());
}
#[test]
fn test_overdraft_disabled() {
let config = OverdraftConfig {
enabled: false,
max_overdraft: 0,
};
assert!(config.validate().is_ok());
}
}
#[cfg(test)]
mod limiter_config_validate_branches {
use super::LimiterConfig;
use super::parse_window_size;
use crate::config::QuotaType;
use crate::constants::{
MAX_SLIDING_LOG_REQUESTS, MAX_TOKEN_BUCKET_CAPACITY, MAX_TOKEN_BUCKET_REFILL_RATE,
MAX_WINDOW_REQUESTS,
};
fn err_of(cfg: &LimiterConfig) -> String {
match cfg.validate() {
Err(e) => e,
Ok(()) => panic!("the config must be rejected: {cfg:?}"),
}
}
#[test]
fn test_token_bucket_bounds() {
assert!(
LimiterConfig::TokenBucket {
capacity: 5,
refill_rate: 1
}
.validate()
.is_ok()
);
assert!(
err_of(&LimiterConfig::TokenBucket {
capacity: 0,
refill_rate: 1
})
.contains("capacity")
);
assert!(
err_of(&LimiterConfig::TokenBucket {
capacity: 5,
refill_rate: 0
})
.contains("Refill rate")
);
assert!(
err_of(&LimiterConfig::TokenBucket {
capacity: MAX_TOKEN_BUCKET_CAPACITY + 1,
refill_rate: 1,
})
.contains("cannot exceed")
);
assert!(
err_of(&LimiterConfig::TokenBucket {
capacity: 5,
refill_rate: MAX_TOKEN_BUCKET_REFILL_RATE + 1,
})
.contains("cannot exceed")
);
}
#[test]
fn test_window_backed_variants_reject_zero_and_huge() {
for name in ["SlidingWindow", "FixedWindow"] {
let zero = match name {
"SlidingWindow" => LimiterConfig::SlidingWindow {
window_size: "1m".into(),
max_requests: 0,
},
_ => LimiterConfig::FixedWindow {
window_size: "1m".into(),
max_requests: 0,
},
};
assert!(err_of(&zero).contains("Max requests"));
let huge = match name {
"SlidingWindow" => LimiterConfig::SlidingWindow {
window_size: "1m".into(),
max_requests: MAX_WINDOW_REQUESTS + 1,
},
_ => LimiterConfig::FixedWindow {
window_size: "1m".into(),
max_requests: MAX_WINDOW_REQUESTS + 1,
},
};
assert!(err_of(&huge).contains("cannot exceed"));
let bad_window = match name {
"SlidingWindow" => LimiterConfig::SlidingWindow {
window_size: "".into(),
max_requests: 5,
},
_ => LimiterConfig::FixedWindow {
window_size: "abc".into(),
max_requests: 5,
},
};
let msg = err_of(&bad_window);
assert!(
msg.contains("Window size") || msg.contains("missing number"),
"{name} window validation did not take effect: {msg}"
);
}
}
#[test]
fn test_quota_and_concurrency_bounds() {
assert!(
LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 10,
window: "1d".into(),
alert_threshold: Some(80),
overdraft: None,
}
.validate()
.is_ok()
);
assert!(
err_of(&LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 0,
window: "1d".into(),
alert_threshold: None,
overdraft: None,
})
.contains("limit")
);
assert!(
err_of(&LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 10,
window: "1d".into(),
alert_threshold: Some(101),
overdraft: None,
})
.contains("100")
);
assert!(
LimiterConfig::Concurrency { max_concurrent: 4 }
.validate()
.is_ok()
);
assert!(err_of(&LimiterConfig::Concurrency { max_concurrent: 0 }).contains("concurrency"));
assert!(
err_of(&LimiterConfig::Concurrency {
max_concurrent: u64::MAX
})
.contains("cannot exceed")
);
}
#[test]
fn test_leaky_bucket_and_log_bounds() {
assert!(
LimiterConfig::LeakyBucket {
capacity: 4,
leak_rate: 2
}
.validate()
.is_ok()
);
assert!(
err_of(&LimiterConfig::LeakyBucket {
capacity: 0,
leak_rate: 1
})
.contains("capacity")
);
assert!(
err_of(&LimiterConfig::LeakyBucket {
capacity: 4,
leak_rate: 0
})
.contains("leak_rate")
);
assert!(
err_of(&LimiterConfig::LeakyBucket {
capacity: MAX_TOKEN_BUCKET_CAPACITY + 1,
leak_rate: 1,
})
.contains("cannot exceed")
);
assert!(
err_of(&LimiterConfig::LeakyBucket {
capacity: 4,
leak_rate: MAX_TOKEN_BUCKET_REFILL_RATE + 1,
})
.contains("cannot exceed")
);
assert!(
LimiterConfig::SlidingWindowLog {
window_size: "1s".into(),
max_requests: 5
}
.validate()
.is_ok()
);
assert!(
err_of(&LimiterConfig::SlidingWindowLog {
window_size: "1s".into(),
max_requests: 0
})
.contains("max_requests")
);
assert!(
err_of(&LimiterConfig::SlidingWindowLog {
window_size: "1s".into(),
max_requests: MAX_SLIDING_LOG_REQUESTS + 1,
})
.contains("cannot exceed"),
"the log-type window upper bound must be stricter than the counter type"
);
}
#[test]
fn test_priority_queue_admission_custom_branches() {
let pq = |total: u64, weights: Vec<u64>, def: Option<usize>| LimiterConfig::PriorityQueue {
window_size: "1m".into(),
total_per_window: total,
level_weights: weights,
default_priority: def,
};
assert!(pq(10, vec![1, 2], Some(1)).validate().is_ok());
assert!(err_of(&pq(0, vec![1], None)).contains("total_per_window"));
assert!(err_of(&pq(10, vec![], None)).contains("level_weights"));
assert!(err_of(&pq(10, vec![1, 0], None)).contains("level_weights"));
assert!(err_of(&pq(10, vec![1, 2], Some(2))).contains("out of range"));
assert!(
err_of(&LimiterConfig::PriorityQueue {
window_size: "1z".into(),
total_per_window: 10,
level_weights: vec![1],
default_priority: None,
})
.contains("Unsupported unit")
);
assert!(
LimiterConfig::AdmissionControl {
max_concurrent: 2,
max_per_second: 5
}
.validate()
.is_ok()
);
assert!(
err_of(&LimiterConfig::AdmissionControl {
max_concurrent: 0,
max_per_second: 5
})
.contains("max_concurrent")
);
assert!(
err_of(&LimiterConfig::AdmissionControl {
max_concurrent: 2,
max_per_second: 0
})
.contains("max_per_second")
);
let custom = |name: &str, v: serde_json::Value| LimiterConfig::Custom {
name: name.to_string(),
config: v,
};
assert!(custom("m", serde_json::json!({"k": 1})).validate().is_ok());
assert!(err_of(&custom("", serde_json::json!({}))).contains("name"));
assert!(err_of(&custom("m", serde_json::Value::Null)).contains("config"));
}
#[test]
fn test_parse_window_size_boundaries() {
for ok in ["1s", "250ms", "1m", "1h", "2d"] {
assert!(
parse_window_size(ok).is_ok(),
"{ok} should be a valid window"
);
}
assert!(parse_window_size("").is_err());
assert!(parse_window_size(" ").is_err());
assert!(
parse_window_size("abc").is_err(),
"missing digits must error"
);
assert!(parse_window_size("10").is_err(), "missing unit must error");
assert!(parse_window_size("10q").is_err(), "unknown unit must error");
assert!(
parse_window_size("-5s").is_err(),
"negative value must error"
);
}
}