send rate-limiting errors as JSON

This commit is contained in:
Moritz Hölting 2025-07-02 20:19:49 +02:00
parent 5cdb9a417d
commit 98775d88b3
7 changed files with 124 additions and 14 deletions

19
Cargo.lock generated
View File

@ -1362,6 +1362,17 @@ dependencies = [
"hashbrown 0.15.4",
]
[[package]]
name = "io-uring"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
dependencies = [
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@ -1553,7 +1564,7 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "mensa-upb-api"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"actix-cors",
"actix-governor",
@ -2866,17 +2877,19 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.45.1"
version = "1.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
"windows-sys 0.52.0",

View File

@ -20,6 +20,6 @@ dotenvy = "0.15.7"
itertools = "0.14.0"
sqlx = "0.8.2"
strum = "0.27.1"
tokio = "1.41.1"
tokio = "1.46.0"
tracing = "0.1.40"
tracing-subscriber = "0.3.18"

View File

@ -5,7 +5,7 @@ license.workspace = true
authors.workspace = true
repository.workspace = true
readme.workspace = true
version = "0.2.0"
version = "0.3.0"
edition = "2021"
publish = false

View File

@ -45,7 +45,7 @@ impl FromStr for Canteen {
"zm2" => Ok(Self::ZM2),
"basilica" => Ok(Self::Basilica),
"atrium" => Ok(Self::Atrium),
invalid => Err(format!("Invalid canteen identifier: {}", invalid)),
invalid => Err(format!("Invalid canteen identifier: {invalid}")),
}
}
}

92
web-api/src/governor.rs Normal file
View File

@ -0,0 +1,92 @@
use std::{net::IpAddr, str::FromStr};
use actix_governor::{
governor::{
clock::{Clock, DefaultClock, QuantaInstant},
middleware::NoOpMiddleware,
},
GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError,
};
use actix_web::{
dev::ServiceRequest,
http::{header::ContentType, StatusCode},
HttpResponse, HttpResponseBuilder,
};
use serde::{Deserialize, Serialize};
use crate::USE_X_FORWARDED_HOST;
pub fn get_governor(
seconds_replenish: u64,
burst_size: u32,
) -> GovernorConfig<UserToken, NoOpMiddleware<QuantaInstant>> {
GovernorConfigBuilder::default()
.seconds_per_request(seconds_replenish)
.burst_size(burst_size)
.key_extractor(UserToken)
.finish()
.unwrap()
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
pub struct UserToken;
impl KeyExtractor for UserToken {
type Key = IpAddr;
type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
fn name(&self) -> &'static str {
"Bearer token"
}
fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
let mut ip = USE_X_FORWARDED_HOST
.then(|| {
req.headers()
.get("X-Forwarded-Host")
.and_then(|header| IpAddr::from_str(header.to_str().unwrap_or_default()).ok())
})
.flatten()
.or_else(|| req.peer_addr().map(|socket| socket.ip()))
.ok_or_else(|| {
Self::KeyExtractionError::new(
r#"{ "code": 500, "msg": "Could not extract peer IP address from request"}"#,
)
.set_content_type(ContentType::json())
.set_status_code(StatusCode::INTERNAL_SERVER_ERROR)
})?;
// customers often get their own /56 prefix, apply rate-limiting per prefix instead of per
// address for IPv6
if let IpAddr::V6(ipv6) = ip {
let mut octets = ipv6.octets();
octets[7..16].fill(0);
ip = IpAddr::V6(octets.into());
}
Ok(ip)
}
fn exceed_rate_limit_response(
&self,
negative: &actix_governor::governor::NotUntil<QuantaInstant>,
mut response: HttpResponseBuilder,
) -> HttpResponse {
let wait_time = negative
.wait_time_from(DefaultClock::default().now())
.as_secs();
response.content_type(ContentType::json())
.body(
format!(
r#"{{"code":429, "error": "TooManyRequests", "message": "Too many requests, try again after {wait_time} seconds", "after": {wait_time}}}"#
)
)
}
fn whitelisted_keys(&self) -> Vec<Self::Key> {
Vec::new()
}
fn key_name(&self, _key: &Self::Key) -> Option<String> {
None
}
}

View File

@ -1,14 +1,22 @@
mod canteen;
mod dish;
pub mod endpoints;
mod governor;
mod menu;
use std::{error::Error, fmt::Display};
use std::{error::Error, fmt::Display, sync::LazyLock};
pub use canteen::Canteen;
pub use dish::{Dish, DishPrices};
pub use governor::get_governor;
pub use menu::Menu;
pub(crate) static USE_X_FORWARDED_HOST: LazyLock<bool> = LazyLock::new(|| {
std::env::var("API_USE_X_FORWARDED_HOST")
.map(|val| val == "true")
.unwrap_or(false)
});
#[derive(Debug, Clone)]
struct CustomError(String);

View File

@ -1,10 +1,11 @@
use std::env;
use actix_cors::Cors;
use actix_governor::{Governor, GovernorConfigBuilder};
use actix_governor::Governor;
use actix_web::{web, App, HttpServer};
use anyhow::Result;
use itertools::Itertools;
use mensa_upb_api::get_governor;
use sqlx::postgres::PgPoolOptions;
use tracing::{debug, error, info, level_filters::LevelFilter};
use tracing_subscriber::EnvFilter;
@ -41,7 +42,7 @@ async fn main() -> Result<()> {
let burst_size = env::var("API_RATE_LIMIT_BURST")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(5);
.unwrap_or(20);
let allowed_cors = env::var("API_CORS_ALLOWED")
.map(|val| {
@ -52,11 +53,7 @@ async fn main() -> Result<()> {
.ok()
.unwrap_or_default();
let governor_conf = GovernorConfigBuilder::default()
.seconds_per_request(seconds_replenish)
.burst_size(burst_size)
.finish()
.unwrap();
let governor_conf = get_governor(seconds_replenish, burst_size);
info!("Starting server on {}:{}", interface, port);