#![cfg(feature = "api")]
use arcature::routing::{KeySource, RateLimit, Route, Routes};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use tower::ServiceExt as _;
async fn ok() -> &'static str {
"ok"
}
async fn call(
router: &axum::Router,
uri: &str,
headers: &[(&str, &str)],
) -> axum::http::Response<Body> {
let mut builder = Request::builder().uri(uri);
for (name, value) in headers {
builder = builder.header(*name, *value);
}
router
.clone()
.oneshot(builder.body(Body::empty()).expect("request"))
.await
.expect("infallible")
}
fn limit_headers(response: &axum::http::Response<Body>) -> (String, String, String) {
let read = |name: &str| {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string()
};
(
read("ratelimit-limit"),
read("ratelimit-remaining"),
read("ratelimit-reset"),
)
}
#[tokio::test]
async fn a_route_answers_until_the_bucket_empties_and_then_refuses() {
let router = Routes::new([
Route::get("/limited", ok).layer(RateLimit::per_minute(2).by(KeySource::Global))
])
.into_router();
for expected_remaining in ["1", "0"] {
let response = call(&router, "/limited", &[]).await;
assert_eq!(response.status(), StatusCode::OK);
let (limit, remaining, _) = limit_headers(&response);
assert_eq!(limit, "2");
assert_eq!(remaining, expected_remaining);
}
let refused = call(&router, "/limited", &[]).await;
assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn a_refusal_is_an_rfc_9457_problem_with_retry_after() {
let router = Routes::new([
Route::get("/limited", ok).layer(RateLimit::per_hour(1).by(KeySource::Global))
])
.into_router();
assert_eq!(
call(&router, "/limited", &[]).await.status(),
StatusCode::OK
);
let refused = call(&router, "/limited", &[]).await;
assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS);
let content_type = refused
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
content_type.starts_with("application/problem+json"),
"{content_type}"
);
let retry_after: u64 = refused
.headers()
.get(header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.expect("a Retry-After in seconds");
assert!(retry_after >= 1, "a refusal must say when to come back");
let (limit, remaining, reset) = limit_headers(&refused);
assert_eq!(limit, "1");
assert_eq!(remaining, "0");
assert!(
reset.parse::<u64>().is_ok(),
"reset should be seconds: {reset}"
);
let body = axum::body::to_bytes(refused.into_body(), 64 * 1024)
.await
.expect("a body");
let problem: serde_json::Value = serde_json::from_slice(&body).expect("problem JSON");
assert_eq!(problem["status"], 429);
assert!(problem["title"].is_string(), "{problem}");
assert!(problem["type"].is_string(), "{problem}");
}
#[tokio::test]
async fn a_limit_on_one_route_does_not_throttle_its_neighbour() {
let router = Routes::new([
Route::get("/open", ok),
Route::get("/limited", ok).layer(RateLimit::per_hour(1).by(KeySource::Global)),
])
.into_router();
assert_eq!(
call(&router, "/limited", &[]).await.status(),
StatusCode::OK
);
assert_eq!(
call(&router, "/limited", &[]).await.status(),
StatusCode::TOO_MANY_REQUESTS
);
for _ in 0..5 {
assert_eq!(
call(&router, "/open", &[]).await.status(),
StatusCode::OK,
"an unlimited route must not inherit a sibling's quota"
);
}
}
#[tokio::test]
async fn a_limit_on_a_group_covers_every_route_in_it() {
use arcature::routing::RouteGroup;
let grouped =
Routes::new([
RouteGroup::new("/api", [Route::get("/one", ok), Route::get("/two", ok)])
.layer(RateLimit::per_hour(2).by(KeySource::Global)),
])
.into_router();
let outside = Routes::new([Route::get("/outside", ok)]).into_router();
let router = grouped.merge(outside);
assert_eq!(
call(&router, "/api/one", &[]).await.status(),
StatusCode::OK
);
assert_eq!(
call(&router, "/api/two", &[]).await.status(),
StatusCode::OK
);
assert_eq!(
call(&router, "/api/one", &[]).await.status(),
StatusCode::TOO_MANY_REQUESTS,
"the group shares one bucket"
);
assert_eq!(
call(&router, "/outside", &[]).await.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn two_clients_keyed_by_header_get_their_own_buckets() {
let router = Routes::new([Route::get("/limited", ok).layer(RateLimit::per_hour(1).by(
KeySource::Header(axum::http::HeaderName::from_static("x-api-key")),
))])
.into_router();
let alice = [("x-api-key", "alice")];
let bob = [("x-api-key", "bob")];
assert_eq!(
call(&router, "/limited", &alice).await.status(),
StatusCode::OK
);
assert_eq!(
call(&router, "/limited", &alice).await.status(),
StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(
call(&router, "/limited", &bob).await.status(),
StatusCode::OK,
"one client's quota must not spend another's"
);
}
#[tokio::test]
async fn requests_with_nothing_to_key_on_share_one_bucket() {
let router = Routes::new([Route::get("/limited", ok).layer(RateLimit::per_hour(1).by(
KeySource::Header(axum::http::HeaderName::from_static("x-api-key")),
))])
.into_router();
assert_eq!(
call(&router, "/limited", &[]).await.status(),
StatusCode::OK
);
assert_eq!(
call(&router, "/limited", &[]).await.status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn a_burst_allows_more_than_the_rate_before_it_refuses() {
let router = Routes::new([
Route::get("/limited", ok).layer(RateLimit::per_hour(1).burst(3).by(KeySource::Global))
])
.into_router();
for _ in 0..3 {
assert_eq!(
call(&router, "/limited", &[]).await.status(),
StatusCode::OK
);
}
assert_eq!(
call(&router, "/limited", &[]).await.status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn a_zero_limit_refuses_the_first_request() {
let router = Routes::new([
Route::get("/closed", ok).layer(RateLimit::per_minute(0).by(KeySource::Global))
])
.into_router();
let response = call(&router, "/closed", &[]).await;
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
let (limit, remaining, _) = limit_headers(&response);
assert_eq!(limit, "0");
assert_eq!(remaining, "0");
}
#[tokio::test]
async fn a_permitted_response_still_carries_the_limit_headers() {
let router = Routes::new([
Route::get("/limited", ok).layer(RateLimit::per_minute(10).by(KeySource::Global))
])
.into_router();
let response = call(&router, "/limited", &[]).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(response.headers().get(header::RETRY_AFTER).is_none());
let (limit, remaining, reset) = limit_headers(&response);
assert_eq!(limit, "10");
assert_eq!(remaining, "9");
assert!(reset.parse::<u64>().is_ok(), "{reset}");
}
#[cfg(feature = "cache")]
#[tokio::test]
#[ignore = "needs a live Redis on 127.0.0.1:6379"]
async fn the_redis_backend_shares_a_bucket_across_handles() {
use arcature::cache::{Cache, CacheConfig, Namespace};
let namespace =
Namespace::new(&format!("ratelimit-test-{}", std::process::id())).expect("a namespace");
let cache = Cache::connect(
CacheConfig::new("redis://127.0.0.1:6379")
.expect("a cache config")
.namespace(namespace),
)
.await
.expect("a live Redis");
let first = RateLimit::per_hour(2)
.by(KeySource::Global)
.redis(cache.clone());
let second = RateLimit::per_hour(2).by(KeySource::Global).redis(cache);
let one = Routes::new([Route::get("/limited", ok).layer(first)]).into_router();
let two = Routes::new([Route::get("/limited", ok).layer(second)]).into_router();
assert_eq!(call(&one, "/limited", &[]).await.status(), StatusCode::OK);
assert_eq!(call(&two, "/limited", &[]).await.status(), StatusCode::OK);
assert_eq!(
call(&one, "/limited", &[]).await.status(),
StatusCode::TOO_MANY_REQUESTS
);
}