use axum::Json;
use axum::body::Body;
use axum::http::{HeaderName, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use tower_governor::GovernorError;
use vta_sdk::rate_limit::SOURCE_HEADER;
pub const RATE_LIMIT_SOURCE_HEADER: HeaderName = HeaderName::from_static(SOURCE_HEADER);
pub const RATE_LIMIT_SOURCE: &str = "vtc";
pub const RATE_LIMITED_ERROR: &str = "rate_limited";
pub const UNAUTH_LIMITER: &str = "unauth";
pub const RELATIONSHIPS_LIMITER: &str = "relationships";
pub const SIGNED_ADDRESS_LIMITER: &str = "signed-address";
pub const SIGNER_LIMITER: &str = "signer";
pub const ROOM_ADDRESS_LIMITER: &str = "room-address";
pub const ROOM_PRESENTER_LIMITER: &str = "room-presenter";
pub const ROOM_TRANSFER_LIMITER: &str = "room-transfer";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RateLimitedBody {
pub error: String,
pub limiter: String,
pub message: String,
pub retry_after_secs: u64,
}
#[derive(Debug, Clone)]
pub struct RateLimited {
limiter: &'static str,
retry_after_secs: u64,
message: String,
}
impl RateLimited {
#[must_use]
pub fn new(limiter: &'static str, retry_after_secs: u64, message: impl Into<String>) -> Self {
Self {
limiter,
retry_after_secs: retry_after_secs.max(1),
message: message.into(),
}
}
#[must_use]
pub fn limiter(&self) -> &'static str {
self.limiter
}
#[must_use]
pub fn retry_after_secs(&self) -> u64 {
self.retry_after_secs
}
}
impl IntoResponse for RateLimited {
fn into_response(self) -> Response {
let retry_after = self.retry_after_secs;
let body = RateLimitedBody {
error: RATE_LIMITED_ERROR.to_string(),
limiter: self.limiter.to_string(),
message: self.message,
retry_after_secs: retry_after,
};
let mut res = (StatusCode::TOO_MANY_REQUESTS, Json(body)).into_response();
let headers = res.headers_mut();
headers.insert(
RATE_LIMIT_SOURCE_HEADER,
HeaderValue::from_static(RATE_LIMIT_SOURCE),
);
headers.insert(header::RETRY_AFTER, HeaderValue::from(retry_after));
res
}
}
pub fn governor_error_response(err: GovernorError) -> Response<Body> {
match err {
GovernorError::TooManyRequests { wait_time, headers } => {
let limited = RateLimited::new(
UNAUTH_LIMITER,
wait_time,
"too many unauthenticated requests from this address; wait for \
the Retry-After period before retrying",
);
let mut res = limited.into_response();
if let Some(extra) = headers {
for (name, value) in &extra {
if !res.headers().contains_key(name) {
res.headers_mut().insert(name.clone(), value.clone());
}
}
}
res
}
other => other.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;
use http_body_util::BodyExt;
async fn json(res: Response) -> serde_json::Value {
let bytes = res.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn a_refusal_carries_the_source_retry_after_and_body() {
let res = RateLimited::new(RELATIONSHIPS_LIMITER, 17, "slow down").into_response();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(res.headers()[SOURCE_HEADER], "vtc");
assert_eq!(res.headers()[header::RETRY_AFTER], "17");
assert_eq!(res.headers()[header::CONTENT_TYPE], "application/json");
assert_eq!(
json(res).await,
serde_json::json!({
"error": "rate_limited",
"limiter": "relationships",
"message": "slow down",
"retryAfterSecs": 17,
})
);
}
#[tokio::test]
async fn retry_after_is_never_zero() {
let res = RateLimited::new(UNAUTH_LIMITER, 0, "x").into_response();
assert_eq!(res.headers()[header::RETRY_AFTER], "1");
assert_eq!(json(res).await["retryAfterSecs"], 1);
}
#[tokio::test]
async fn governor_refusal_is_reshaped_and_keeps_its_informational_headers() {
let mut headers = HeaderMap::new();
headers.insert("x-ratelimit-after", 4.into());
headers.insert("retry-after", 4.into());
let res = governor_error_response(GovernorError::TooManyRequests {
wait_time: 4,
headers: Some(headers),
});
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(res.headers()[SOURCE_HEADER], "vtc");
assert_eq!(res.headers()[header::RETRY_AFTER], "4");
assert_eq!(res.headers()["x-ratelimit-after"], "4");
assert_eq!(res.headers()[header::CONTENT_TYPE], "application/json");
let body = json(res).await;
assert_eq!(body["error"], "rate_limited");
assert_eq!(body["limiter"], "unauth");
assert_eq!(body["retryAfterSecs"], 4);
}
#[tokio::test]
async fn the_sdk_parser_attributes_a_refusal_to_the_vtc() {
use vta_sdk::error::VtaError;
use vta_sdk::rate_limit::RateLimitSource;
let res = RateLimited::new(RELATIONSHIPS_LIMITER, 30, "slow down").into_response();
let status = res.status();
let headers = res.headers().clone();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8(bytes.to_vec()).unwrap();
let before = chrono::Utc::now();
let err = VtaError::rate_limited_from_http(
status,
&headers,
&body,
"https://vtc.example.com/v1/relationships",
)
.expect("a 429 parses as a rate limit");
match err {
VtaError::RateLimited {
limited_by,
retry_after,
limiter,
url,
} => {
assert_eq!(limited_by, RateLimitSource::Vtc);
assert_eq!(limiter.as_deref(), Some("relationships"));
let wait = retry_after.expect("Retry-After is read") - before;
assert!((29..=31).contains(&wait.num_seconds()), "wait {wait}");
assert_eq!(
url.as_deref(),
Some("https://vtc.example.com/v1/relationships")
);
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[tokio::test]
async fn a_key_extraction_failure_is_not_dressed_as_a_rate_limit() {
let res = governor_error_response(GovernorError::UnableToExtractKey);
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(!res.headers().contains_key(SOURCE_HEADER));
}
}