use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Full};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::TcpListener;
use plecto_control::{Control, ControlError, Manifest};
use plecto_server::serve;
async fn svc(_req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(Response::builder()
.status(200)
.body(Full::new(Bytes::from_static(b"ok")))
.unwrap())
}
async fn spawn_upstream() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), service_fn(svc))
.await;
});
}
});
addr
}
async fn free_addr() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
addr
}
fn client() -> Client<HttpConnector, Empty<Bytes>> {
Client::builder(TokioExecutor::new()).build_http()
}
async fn get(
client: &Client<HttpConnector, Empty<Bytes>>,
addr: SocketAddr,
path: &str,
) -> (StatusCode, Option<String>, String) {
let resp = client
.request(
Request::builder()
.method("GET")
.uri(format!("http://{addr}{path}"))
.body(Empty::<Bytes>::new())
.unwrap(),
)
.await
.expect("request");
let (parts, body) = resp.into_parts();
let retry_after = parts
.headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let bytes = body.collect().await.unwrap().to_bytes();
(
parts.status,
retry_after,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
async fn wait_ready(client: &Client<HttpConnector, Empty<Bytes>>, proxy: SocketAddr) {
for _ in 0..150 {
let (status, _, _) = get(client, proxy, "/").await;
if status != StatusCode::SERVICE_UNAVAILABLE {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("upstream never became healthy");
}
async fn scrape_rate_limited(
client: &Client<HttpConnector, Empty<Bytes>>,
admin: SocketAddr,
) -> u64 {
let (status, _, metrics) = get(client, admin, "/metrics").await;
assert_eq!(status, StatusCode::OK);
metrics
.lines()
.find(|l| l.starts_with("plecto_rate_limited_total "))
.and_then(|l| l.rsplit(' ').next())
.and_then(|n| n.parse::<u64>().ok())
.expect("the rate-limited counter is exposed")
}
async fn run_limit_test(key: &str) {
let upstream = spawn_upstream().await;
let admin = free_addr().await;
let toml = format!(
r#"
[observability]
admin_addr = "{admin}"
[[upstream]]
name = "u"
addresses = ["{upstream}"]
[upstream.health]
path = "/healthz"
interval_ms = 50
[[route]]
upstream = "u"
[route.match]
path_prefix = "/"
[route.rate_limit]
rate = 1
burst = 1
key = "{key}"
"#
);
let manifest = Manifest::from_toml(&toml).unwrap();
let control = Arc::new(Control::from_manifest(&manifest, Path::new(".")).unwrap());
let data_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let data = data_listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = serve(control, data_listener).await;
});
let client = client();
wait_ready(&client, data).await;
tokio::time::sleep(Duration::from_millis(1100)).await;
let (s1, _, b1) = get(&client, data, "/").await;
assert_eq!(
s1,
StatusCode::OK,
"the first request within burst is forwarded"
);
assert_eq!(b1, "ok");
let (s2, retry_after, b2) = get(&client, data, "/").await;
assert_eq!(
s2,
StatusCode::TOO_MANY_REQUESTS,
"a request over the rate cap is shed with 429 (not the breaker's 503)"
);
assert_eq!(b2, "rate limit exceeded", "the rate-limit fast-fail body");
assert_eq!(
retry_after.as_deref(),
Some("1"),
"429 carries a Retry-After hint (one refill interval)"
);
assert!(
scrape_rate_limited(&client, admin).await >= 1,
"the shed request is counted in plecto_rate_limited_total"
);
}
#[tokio::test]
async fn route_keyed_rate_limit_sheds_over_the_cap() {
run_limit_test("route").await;
}
#[tokio::test]
async fn client_ip_keyed_rate_limit_sheds_over_the_cap() {
run_limit_test("client-ip").await;
}
#[test]
fn zero_rate_or_burst_is_rejected_fail_closed_at_build() {
for (rate, burst) in [(0u64, 1u64), (1, 0)] {
let toml = format!(
r#"
[[upstream]]
name = "u"
addresses = ["127.0.0.1:9000"]
[upstream.health]
path = "/healthz"
[[route]]
upstream = "u"
[route.match]
path_prefix = "/"
[route.rate_limit]
rate = {rate}
burst = {burst}
"#
);
let manifest = Manifest::from_toml(&toml).unwrap();
let result = Control::from_manifest(&manifest, Path::new("."));
assert!(
matches!(result, Err(ControlError::InvalidRouteRateLimit { .. })),
"rate={rate} burst={burst} must be rejected fail-closed at build"
);
}
}