use super::{Error, get_hash_key, get_str_conf};
use async_trait::async_trait;
use bytesize::ByteSize;
use fancy_regex::Regex;
use pingap_config::PluginConf;
use pingap_core::{BodyPace, Ctx, Plugin, PluginStep, RequestPluginResult};
use pingora::proxy::Session;
use std::borrow::Cow;
use std::str::FromStr;
use tracing::debug;
type Result<T, E = Error> = std::result::Result<T, E>;
const CATEGORY: &str = "bandwidth_limit";
pub struct BandwidthLimit {
rate: u64,
after: u64,
path: Option<Regex>,
hash_value: String,
}
impl TryFrom<&PluginConf> for BandwidthLimit {
type Error = Error;
fn try_from(value: &PluginConf) -> Result<Self> {
let hash_value = get_hash_key(value);
let invalid = |message: String| Error::Invalid {
category: CATEGORY.to_string(),
message,
};
let size = |key: &str| -> Result<Option<u64>> {
let text = get_str_conf(value, key);
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
ByteSize::from_str(text)
.map(|size| Some(size.as_u64()))
.map_err(|e| invalid(format!("invalid {key}({text}): {e}")))
};
let rate = size("rate")?.filter(|rate| *rate > 0).ok_or_else(|| {
invalid(
"rate should be a size per second, more than 0 (like 1mb)"
.to_string(),
)
})?;
let path = get_str_conf(value, "path");
let path = if path.is_empty() {
None
} else {
Some(
Regex::new(&path)
.map_err(|e| invalid(format!("invalid path: {e}")))?,
)
};
Ok(Self {
rate,
after: size("after")?.unwrap_or_default(),
path,
hash_value,
})
}
}
impl BandwidthLimit {
pub fn new(params: &PluginConf) -> Result<Self> {
debug!(
params = pingap_config::masked_toml(params),
"new bandwidth limit plugin"
);
Self::try_from(params)
}
}
#[async_trait]
impl Plugin for BandwidthLimit {
#[inline]
fn config_key(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.hash_value)
}
#[inline]
async fn handle_request(
&self,
step: PluginStep,
session: &mut Session,
ctx: &mut Ctx,
) -> pingora::Result<RequestPluginResult> {
if step != PluginStep::Request {
return Ok(RequestPluginResult::Skipped);
}
if let Some(path) = &self.path
&& !path
.is_match(session.req_header().uri.path())
.unwrap_or_default()
{
return Ok(RequestPluginResult::Skipped);
}
ctx.features.get_or_insert_default().body_pace =
Some(BodyPace::new(self.rate, self.after));
Ok(RequestPluginResult::Continue)
}
}
register_plugin!("bandwidth_limit", BandwidthLimit);
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tokio_test::io::Builder;
fn new_plugin(conf: &str) -> Result<BandwidthLimit> {
BandwidthLimit::new(&toml::from_str::<PluginConf>(conf).unwrap())
}
#[test]
fn test_bandwidth_limit_params() {
let plugin = new_plugin("rate = \"100kb\"").unwrap();
assert_eq!((100_000, 0), (plugin.rate, plugin.after));
let plugin = new_plugin(
"rate = \"1 MiB\"\nafter = \"512kib\"\npath = \"^/files/\"",
)
.unwrap();
assert_eq!((1024 * 1024, 512 * 1024), (plugin.rate, plugin.after));
assert_eq!(true, plugin.path.is_some());
for (conf, message) in [
("", "rate should be a size per second"),
("rate = \"0\"", "rate should be a size per second"),
("rate = \"fast\"", "invalid rate(fast)"),
("rate = \"1mb\"\nafter = \"soon\"", "invalid after(soon)"),
("rate = \"1mb\"\npath = \"(\"", "invalid path"),
] {
let error = new_plugin(conf).err().unwrap().to_string();
assert_eq!(true, error.contains(message), "{conf}: {error}");
}
}
#[tokio::test]
async fn test_bandwidth_limit_sets_the_pace() {
let plugin =
new_plugin("rate = \"1kb\"\nafter = \"2kb\"\npath = \"^/files/\"")
.unwrap();
let paced = async |target: &str, step: PluginStep| {
let input = format!("GET {target} HTTP/1.1\r\n\r\n");
let mock_io = Builder::new().read(input.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let mut ctx = Ctx::default();
plugin
.handle_request(step, &mut session, &mut ctx)
.await
.unwrap();
ctx.features.and_then(|features| features.body_pace)
};
assert_eq!(
true,
paced("/files/big.iso", PluginStep::Request).await.is_some()
);
assert_eq!(
true,
paced("/index.html", PluginStep::Request).await.is_none()
);
assert_eq!(
true,
paced("/files/big.iso", PluginStep::ProxyUpstream)
.await
.is_none()
);
let mut pace =
paced("/files/big.iso", PluginStep::Request).await.unwrap();
assert_eq!(None, pace.delay(2000));
let delay = pace.delay(500).unwrap();
assert_eq!(true, (100..=500).contains(&delay.as_millis()), "{delay:?}");
}
}