use std::net::IpAddr;
use std::num::NonZeroU32;
use std::sync::{Arc, RwLock as StdRwLock};
use std::time::Duration;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderValue, Response, StatusCode, header};
use axum::middleware::Next;
use governor::clock::{Clock, DefaultClock};
use governor::{DefaultKeyedRateLimiter, RateLimiter};
use ipnetwork::IpNetwork;
use tokio::sync::RwLock;
use tower_governor::key_extractor::KeyExtractor;
use utoipa_axum::router::OpenApiRouter;
use vta_config::{AppConfig, ServerConfig};
use vti_common::rate_limit::TrustedProxyKeyExtractor;
pub const RATE_LIMIT_SOURCE_HEADER: &str = vta_sdk::rate_limit::SOURCE_HEADER;
pub const RATE_LIMIT_SOURCE_VTA: &str = "vta";
pub const RATE_LIMIT_SCOPE_HEADER: &str = "x-rate-limit-scope";
const LEGACY_RATE_LIMIT_AFTER_HEADER: &str = vta_sdk::rate_limit::LEGACY_RETRY_AFTER_HEADER;
pub(crate) const AUTH_INTERVAL_SECS: u64 = 5;
pub(crate) const AUTH_BURST: u32 = 10;
pub(crate) const DID_LOG_INTERVAL_SECS: u64 = 1;
pub(crate) const DID_LOG_BURST: u32 = 60;
pub const MAX_INTERVAL_SECS: u64 = 3600;
pub const MAX_BURST: u32 = 10_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Limiter {
Auth,
DidLog,
BackupBlob,
}
impl Limiter {
pub const fn name(self) -> &'static str {
match self {
Limiter::Auth => "auth",
Limiter::DidLog => "did-log",
Limiter::BackupBlob => "backup-blob",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Quota {
interval_secs: u64,
burst: u32,
}
impl Quota {
pub fn new(interval_secs: u64, burst: u32) -> Self {
Self {
interval_secs: interval_secs.max(1),
burst: burst.max(1),
}
}
pub fn interval_secs(self) -> u64 {
self.interval_secs
}
pub fn burst(self) -> u32 {
self.burst
}
fn to_governor(self) -> governor::Quota {
governor::Quota::with_period(Duration::from_secs(self.interval_secs))
.expect("interval is clamped to >= 1 s, so the period is non-zero")
.allow_burst(NonZeroU32::new(self.burst).expect("burst is clamped to >= 1"))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RateLimits {
auth: Quota,
did_log: Quota,
}
impl RateLimits {
pub fn new(auth: Quota, did_log: Quota) -> Self {
Self { auth, did_log }
}
pub fn from_server_config(server: &ServerConfig) -> Self {
Self::new(
Quota::new(server.rate_limit_interval_secs, server.rate_limit_burst),
Quota::new(
server.did_log_rate_limit_interval_secs,
server.did_log_rate_limit_burst,
),
)
}
pub fn quota(self, limiter: Limiter) -> Quota {
match limiter {
Limiter::Auth | Limiter::BackupBlob => self.auth,
Limiter::DidLog => self.did_log,
}
}
}
impl Default for RateLimits {
fn default() -> Self {
Self::new(
Quota::new(AUTH_INTERVAL_SECS, AUTH_BURST),
Quota::new(DID_LOG_INTERVAL_SECS, DID_LOG_BURST),
)
}
}
#[derive(Clone)]
pub enum QuotaSource {
Live(Arc<RwLock<AppConfig>>),
Fixed(RateLimits),
}
impl QuotaSource {
async fn current(&self, limiter: Limiter) -> Quota {
match self {
QuotaSource::Live(config) => {
RateLimits::from_server_config(&config.read().await.server).quota(limiter)
}
QuotaSource::Fixed(limits) => limits.quota(limiter),
}
}
}
struct Buckets {
quota: Quota,
limiter: DefaultKeyedRateLimiter<IpAddr>,
}
impl Buckets {
fn new(quota: Quota) -> Arc<Self> {
Arc::new(Self {
quota,
limiter: RateLimiter::keyed(quota.to_governor()),
})
}
}
struct LimiterState {
limiter: Limiter,
extractor: TrustedProxyKeyExtractor,
source: QuotaSource,
buckets: StdRwLock<Arc<Buckets>>,
}
impl LimiterState {
fn buckets_for(&self, quota: Quota) -> Arc<Buckets> {
{
let current = self.buckets.read().unwrap_or_else(|e| e.into_inner());
if current.quota == quota {
return Arc::clone(¤t);
}
}
let mut current = self.buckets.write().unwrap_or_else(|e| e.into_inner());
if current.quota != quota {
tracing::info!(
limiter = self.limiter.name(),
interval_secs = quota.interval_secs(),
burst = quota.burst(),
"rate limit quota changed; limiter buckets reset"
);
*current = Buckets::new(quota);
}
Arc::clone(¤t)
}
fn client_ip(&self, req: &Request) -> Option<IpAddr> {
self.extractor.extract(req).ok()
}
}
pub(super) fn apply<S>(
router: OpenApiRouter<S>,
limiter: Limiter,
trust_xff_cidrs: &[IpNetwork],
source: &QuotaSource,
) -> OpenApiRouter<S>
where
S: Clone + Send + Sync + 'static,
{
let initial = match source {
QuotaSource::Fixed(limits) => limits.quota(limiter),
QuotaSource::Live(_) => RateLimits::default().quota(limiter),
};
let state = Arc::new(LimiterState {
limiter,
extractor: TrustedProxyKeyExtractor::new(trust_xff_cidrs.to_vec()),
source: source.clone(),
buckets: StdRwLock::new(Buckets::new(initial)),
});
router.layer(axum::middleware::from_fn_with_state(state, enforce))
}
async fn enforce(
State(state): State<Arc<LimiterState>>,
req: Request,
next: Next,
) -> Response<Body> {
let Some(ip) = state.client_ip(&req) else {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("Unable To Extract Key!"))
.expect("static response always builds");
};
let quota = state.source.current(state.limiter).await;
let buckets = state.buckets_for(quota);
match buckets.limiter.check_key(&ip) {
Ok(()) => next.run(req).await,
Err(not_until) => {
let wait = not_until.wait_time_from(DefaultClock::default().now());
too_many_requests(state.limiter, ceil_secs(wait))
}
}
}
fn ceil_secs(d: Duration) -> u64 {
d.as_secs() + u64::from(d.subsec_nanos() > 0)
}
pub(crate) fn too_many_requests(limiter: Limiter, retry_after_secs: u64) -> Response<Body> {
let retry_after = retry_after_secs.max(1);
let body = serde_json::json!({
"error": "rate_limited",
"limiter": limiter.name(),
"message": format!(
"Too Many Requests: rejected by the VTA's `{}` rate limiter. Retry after {retry_after} s.",
limiter.name()
),
"retryAfterSecs": retry_after,
})
.to_string();
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header(header::CONTENT_TYPE, "application/json")
.header(header::RETRY_AFTER, HeaderValue::from(retry_after))
.header(
LEGACY_RATE_LIMIT_AFTER_HEADER,
HeaderValue::from(retry_after),
)
.header(RATE_LIMIT_SOURCE_HEADER, RATE_LIMIT_SOURCE_VTA)
.header(RATE_LIMIT_SCOPE_HEADER, limiter.name())
.body(Body::from(body))
.expect("static header names and numeric values always build")
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use axum::http::Request as HttpRequest;
use axum::routing::get;
use tower::ServiceExt;
async fn ok() -> &'static str {
"ok"
}
fn trusted_loopback() -> Vec<IpNetwork> {
vec!["127.0.0.1/32".parse().unwrap()]
}
fn two_branch_router(source: QuotaSource) -> axum::Router {
let auth = apply(
OpenApiRouter::<()>::new().route("/auth", get(ok)),
Limiter::Auth,
&trusted_loopback(),
&source,
);
let did_log = apply(
OpenApiRouter::<()>::new().route("/did.jsonl", get(ok)),
Limiter::DidLog,
&trusted_loopback(),
&source,
);
let (router, _) = OpenApiRouter::<()>::new()
.merge(auth)
.merge(did_log)
.split_for_parts();
router.layer(axum::middleware::from_fn(
vti_common::rate_limit::insert_default_connect_info_if_missing,
))
}
async fn get_from(app: &axum::Router, uri: &str, ip: &str) -> Response<Body> {
let req = HttpRequest::builder()
.uri(uri)
.header("x-forwarded-for", ip)
.body(Body::empty())
.unwrap();
app.clone().oneshot(req).await.unwrap()
}
fn tight() -> RateLimits {
RateLimits::new(Quota::new(3600, 2), Quota::new(3600, 3))
}
fn live_config(limits: (u64, u32, u64, u32)) -> Arc<RwLock<AppConfig>> {
let mut config = crate::test_support::test_app_config("unused".into());
config.server.rate_limit_interval_secs = limits.0;
config.server.rate_limit_burst = limits.1;
config.server.did_log_rate_limit_interval_secs = limits.2;
config.server.did_log_rate_limit_burst = limits.3;
Arc::new(RwLock::new(config))
}
async fn admitted(app: &axum::Router, uri: &str, ip: &str, cap: usize) -> usize {
for i in 0..cap {
if get_from(app, uri, ip).await.status() == StatusCode::TOO_MANY_REQUESTS {
return i;
}
}
cap
}
#[tokio::test]
async fn did_log_burst_does_not_spend_auth_budget() {
let app = two_branch_router(QuotaSource::Fixed(tight()));
assert_eq!(admitted(&app, "/did.jsonl", "198.51.100.1", 10).await, 3);
assert_eq!(
admitted(&app, "/auth", "198.51.100.1", 10).await,
2,
"exhausting did-log must not spend the auth bucket"
);
}
#[tokio::test]
async fn auth_burst_does_not_spend_did_log_budget() {
let app = two_branch_router(QuotaSource::Fixed(tight()));
assert_eq!(admitted(&app, "/auth", "198.51.100.2", 10).await, 2);
assert_eq!(
admitted(&app, "/did.jsonl", "198.51.100.2", 10).await,
3,
"exhausting auth must not spend the did-log bucket"
);
}
#[tokio::test]
async fn limits_are_per_ip() {
let app = two_branch_router(QuotaSource::Fixed(tight()));
assert_eq!(admitted(&app, "/auth", "198.51.100.3", 10).await, 2);
assert_eq!(
get_from(&app, "/auth", "198.51.100.4").await.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn rejection_carries_the_vta_429_contract() {
let app = two_branch_router(QuotaSource::Fixed(tight()));
for (uri, scope, n) in [("/auth", "auth", 2), ("/did.jsonl", "did-log", 3)] {
for _ in 0..n {
get_from(&app, uri, "198.51.100.5").await;
}
let r = get_from(&app, uri, "198.51.100.5").await;
assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
let h = r.headers();
assert_eq!(h[RATE_LIMIT_SOURCE_HEADER], "vta");
assert_eq!(h[RATE_LIMIT_SCOPE_HEADER], scope);
let retry: u64 = h[header::RETRY_AFTER].to_str().unwrap().parse().unwrap();
assert!(
(1..=3600).contains(&retry),
"retry-after must be within one interval, got {retry}"
);
assert_eq!(h[LEGACY_RATE_LIMIT_AFTER_HEADER], h[header::RETRY_AFTER]);
assert_eq!(h[header::CONTENT_TYPE], "application/json");
let body = to_bytes(r.into_body(), usize::MAX).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
body,
serde_json::json!({
"error": "rate_limited",
"limiter": scope,
"message": format!(
"Too Many Requests: rejected by the VTA's `{scope}` rate limiter. \
Retry after {retry} s."
),
"retryAfterSecs": retry,
})
);
}
}
#[tokio::test]
async fn live_quota_change_applies_without_rebuilding_the_router() {
let config = live_config((3600, 2, 3600, 3));
let app = two_branch_router(QuotaSource::Live(Arc::clone(&config)));
assert_eq!(admitted(&app, "/auth", "198.51.100.6", 20).await, 2);
config.write().await.server.rate_limit_burst = 5;
assert_eq!(
admitted(&app, "/auth", "198.51.100.6", 20).await,
5,
"a quota change swaps in fresh buckets at the new burst"
);
config.write().await.server.rate_limit_burst = 1;
assert_eq!(admitted(&app, "/auth", "198.51.100.6", 20).await, 1);
assert_eq!(admitted(&app, "/did.jsonl", "198.51.100.6", 20).await, 3);
}
#[tokio::test]
async fn unrelated_config_change_keeps_bucket_state() {
let config = live_config((3600, 2, 3600, 3));
let app = two_branch_router(QuotaSource::Live(Arc::clone(&config)));
assert_eq!(admitted(&app, "/auth", "198.51.100.7", 20).await, 2);
{
let mut c = config.write().await;
c.vta_name = Some("renamed".into());
c.server.did_log_rate_limit_burst = 50;
}
assert_eq!(
get_from(&app, "/auth", "198.51.100.7").await.status(),
StatusCode::TOO_MANY_REQUESTS,
"auth buckets must survive a change that leaves the auth quota alone"
);
}
#[tokio::test]
async fn live_source_honours_configured_quota_from_the_first_request() {
let config = live_config((3600, 1, 3600, 1));
let app = two_branch_router(QuotaSource::Live(config));
assert_eq!(admitted(&app, "/auth", "198.51.100.8", 20).await, 1);
}
#[tokio::test]
async fn no_attributable_client_is_refused_not_unlimited() {
for cidrs in [vec![], trusted_loopback()] {
let router = apply(
OpenApiRouter::<()>::new().route("/auth", get(ok)),
Limiter::Auth,
&cidrs,
&QuotaSource::Fixed(RateLimits::default()),
);
let (app, _) = router.split_for_parts();
let resp = get_from(&app, "/auth", "198.51.100.9").await;
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"cidrs={cidrs:?}"
);
}
}
#[test]
fn ceil_secs_rounds_up() {
assert_eq!(ceil_secs(Duration::from_secs(4)), 4);
assert_eq!(ceil_secs(Duration::from_millis(4001)), 5);
assert_eq!(ceil_secs(Duration::from_millis(1)), 1);
assert_eq!(ceil_secs(Duration::ZERO), 0);
}
#[test]
fn source_label_is_what_the_sdk_reads_as_vta() {
use vta_sdk::rate_limit::RateLimitSource;
assert_eq!(
RateLimitSource::from_source_header(Some(RATE_LIMIT_SOURCE_VTA)),
RateLimitSource::Vta
);
assert_eq!(RATE_LIMIT_SOURCE_HEADER, "x-rate-limit-source");
}
#[test]
fn scope_names_are_stable() {
assert_eq!(Limiter::Auth.name(), "auth");
assert_eq!(Limiter::DidLog.name(), "did-log");
assert_eq!(Limiter::BackupBlob.name(), "backup-blob");
}
#[test]
fn zero_quota_is_clamped() {
let q = Quota::new(0, 0);
assert_eq!((q.interval_secs(), q.burst()), (1, 1));
let server = ServerConfig {
rate_limit_interval_secs: 0,
rate_limit_burst: 0,
did_log_rate_limit_interval_secs: 0,
did_log_rate_limit_burst: 0,
..Default::default()
};
let limits = RateLimits::from_server_config(&server);
assert_eq!(limits.quota(Limiter::DidLog), Quota::new(1, 1));
assert_eq!(limits.quota(Limiter::Auth), Quota::new(1, 1));
let _ = two_branch_router(QuotaSource::Fixed(limits));
}
#[test]
fn defaults_match_server_config_defaults() {
assert_eq!(
RateLimits::default(),
RateLimits::from_server_config(&ServerConfig::default()),
"the no-config defaults must match vta-config's [server] defaults"
);
}
#[test]
fn backup_blob_uses_the_auth_quota() {
let limits = tight();
assert_eq!(
limits.quota(Limiter::BackupBlob),
limits.quota(Limiter::Auth)
);
}
}