mirror of
https://github.com/Xevion/banner.git
synced 2025-12-07 18:06:22 -06:00
feat: setup rate limiter middleware & config
This commit is contained in:
@@ -7,8 +7,16 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::banner::{
|
||||
BannerSession, SessionPool, errors::BannerApiError, json::parse_json_with_context,
|
||||
middleware::TransparentMiddleware, models::*, nonce, query::SearchQuery, util::user_agent,
|
||||
BannerSession, SessionPool, create_shared_rate_limiter_with_config,
|
||||
errors::BannerApiError,
|
||||
json::parse_json_with_context,
|
||||
middleware::TransparentMiddleware,
|
||||
models::*,
|
||||
nonce,
|
||||
query::SearchQuery,
|
||||
rate_limit_middleware::RateLimitMiddleware,
|
||||
rate_limiter::{RateLimitConfig, SharedRateLimiter, create_shared_rate_limiter},
|
||||
util::user_agent,
|
||||
};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use cookie::Cookie;
|
||||
@@ -30,6 +38,13 @@ pub struct BannerApi {
|
||||
impl BannerApi {
|
||||
/// Creates a new Banner API client.
|
||||
pub fn new(base_url: String) -> Result<Self> {
|
||||
Self::new_with_config(base_url, RateLimitConfig::default())
|
||||
}
|
||||
|
||||
/// Creates a new Banner API client with custom rate limiting configuration.
|
||||
pub fn new_with_config(base_url: String, rate_limit_config: RateLimitConfig) -> Result<Self> {
|
||||
let rate_limiter = create_shared_rate_limiter_with_config(rate_limit_config);
|
||||
|
||||
let http = ClientBuilder::new(
|
||||
Client::builder()
|
||||
.cookie_store(false)
|
||||
@@ -42,6 +57,7 @@ impl BannerApi {
|
||||
.context("Failed to create HTTP client")?,
|
||||
)
|
||||
.with(TransparentMiddleware)
|
||||
.with(RateLimitMiddleware::new(rate_limiter.clone()))
|
||||
.build();
|
||||
|
||||
Ok(Self {
|
||||
@@ -50,7 +66,6 @@ impl BannerApi {
|
||||
base_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates offset parameter for search methods.
|
||||
fn validate_offset(offset: i32) -> Result<()> {
|
||||
if offset <= 0 {
|
||||
|
||||
@@ -14,6 +14,8 @@ pub mod json;
|
||||
pub mod middleware;
|
||||
pub mod models;
|
||||
pub mod query;
|
||||
pub mod rate_limiter;
|
||||
pub mod rate_limit_middleware;
|
||||
pub mod session;
|
||||
pub mod util;
|
||||
|
||||
@@ -21,4 +23,5 @@ pub use api::*;
|
||||
pub use errors::*;
|
||||
pub use models::*;
|
||||
pub use query::*;
|
||||
pub use rate_limiter::*;
|
||||
pub use session::*;
|
||||
|
||||
101
src/banner/rate_limit_middleware.rs
Normal file
101
src/banner/rate_limit_middleware.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! HTTP middleware that enforces rate limiting for Banner API requests.
|
||||
|
||||
use crate::banner::rate_limiter::{RequestType, SharedRateLimiter};
|
||||
use http::Extensions;
|
||||
use reqwest::{Request, Response};
|
||||
use reqwest_middleware::{Middleware, Next};
|
||||
use tracing::{debug, warn};
|
||||
use url::Url;
|
||||
|
||||
/// Middleware that enforces rate limiting based on request URL patterns
|
||||
pub struct RateLimitMiddleware {
|
||||
rate_limiter: SharedRateLimiter,
|
||||
}
|
||||
|
||||
impl RateLimitMiddleware {
|
||||
/// Creates a new rate limiting middleware
|
||||
pub fn new(rate_limiter: SharedRateLimiter) -> Self {
|
||||
Self { rate_limiter }
|
||||
}
|
||||
|
||||
/// Determines the request type based on the URL path
|
||||
fn get_request_type(url: &Url) -> RequestType {
|
||||
let path = url.path();
|
||||
|
||||
if path.contains("/registration")
|
||||
|| path.contains("/selfServiceMenu")
|
||||
|| path.contains("/term/termSelection")
|
||||
{
|
||||
RequestType::Session
|
||||
} else if path.contains("/searchResults") || path.contains("/classSearch") {
|
||||
RequestType::Search
|
||||
} else if path.contains("/getTerms")
|
||||
|| path.contains("/getSubjects")
|
||||
|| path.contains("/getCampuses")
|
||||
{
|
||||
RequestType::Metadata
|
||||
} else if path.contains("/resetDataForm") {
|
||||
RequestType::Reset
|
||||
} else {
|
||||
// Default to search for unknown endpoints
|
||||
RequestType::Search
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Middleware for RateLimitMiddleware {
|
||||
async fn handle(
|
||||
&self,
|
||||
req: Request,
|
||||
extensions: &mut Extensions,
|
||||
next: Next<'_>,
|
||||
) -> std::result::Result<Response, reqwest_middleware::Error> {
|
||||
let request_type = Self::get_request_type(req.url());
|
||||
|
||||
debug!(
|
||||
url = %req.url(),
|
||||
request_type = ?request_type,
|
||||
"Rate limiting request"
|
||||
);
|
||||
|
||||
// Wait for permission to make the request
|
||||
self.rate_limiter.wait_for_permission(request_type).await;
|
||||
|
||||
debug!(
|
||||
url = %req.url(),
|
||||
request_type = ?request_type,
|
||||
"Rate limit permission granted, making request"
|
||||
);
|
||||
|
||||
// Make the actual request
|
||||
let response_result = next.run(req, extensions).await;
|
||||
|
||||
match response_result {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
debug!(
|
||||
url = %response.url(),
|
||||
status = response.status().as_u16(),
|
||||
"Request completed successfully"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
url = %response.url(),
|
||||
status = response.status().as_u16(),
|
||||
"Request completed with error status"
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
url = %error.url().unwrap_or(&Url::parse("unknown").unwrap()),
|
||||
error = ?error,
|
||||
"Request failed"
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
169
src/banner/rate_limiter.rs
Normal file
169
src/banner/rate_limiter.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! Rate limiting for Banner API requests to prevent overwhelming the server.
|
||||
|
||||
use governor::{
|
||||
Quota, RateLimiter,
|
||||
clock::DefaultClock,
|
||||
state::{InMemoryState, NotKeyed},
|
||||
};
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Different types of Banner API requests with different rate limits
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RequestType {
|
||||
/// Session creation and management (very conservative)
|
||||
Session,
|
||||
/// Course search requests (moderate)
|
||||
Search,
|
||||
/// Term and metadata requests (moderate)
|
||||
Metadata,
|
||||
/// Data form resets (low priority)
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// Rate limiter configuration for different request types
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Requests per minute for session operations
|
||||
pub session_rpm: u32,
|
||||
/// Requests per minute for search operations
|
||||
pub search_rpm: u32,
|
||||
/// Requests per minute for metadata operations
|
||||
pub metadata_rpm: u32,
|
||||
/// Requests per minute for reset operations
|
||||
pub reset_rpm: u32,
|
||||
/// Burst allowance (extra requests allowed in short bursts)
|
||||
pub burst_allowance: u32,
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Very conservative for session creation
|
||||
session_rpm: 6, // 1 every 10 seconds
|
||||
// Moderate for search operations
|
||||
search_rpm: 30, // 1 every 2 seconds
|
||||
// Moderate for metadata
|
||||
metadata_rpm: 20, // 1 every 3 seconds
|
||||
// Low for resets
|
||||
reset_rpm: 10, // 1 every 6 seconds
|
||||
// Allow small bursts
|
||||
burst_allowance: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate limiter that manages different request types with different limits
|
||||
pub struct BannerRateLimiter {
|
||||
session_limiter: RateLimiter<NotKeyed, InMemoryState, DefaultClock>,
|
||||
search_limiter: RateLimiter<NotKeyed, InMemoryState, DefaultClock>,
|
||||
metadata_limiter: RateLimiter<NotKeyed, InMemoryState, DefaultClock>,
|
||||
reset_limiter: RateLimiter<NotKeyed, InMemoryState, DefaultClock>,
|
||||
config: RateLimitConfig,
|
||||
}
|
||||
|
||||
impl BannerRateLimiter {
|
||||
/// Creates a new rate limiter with the given configuration
|
||||
pub fn new(config: RateLimitConfig) -> Self {
|
||||
let session_quota = Quota::with_period(Duration::from_secs(60) / config.session_rpm)
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(config.burst_allowance).unwrap());
|
||||
|
||||
let search_quota = Quota::with_period(Duration::from_secs(60) / config.search_rpm)
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(config.burst_allowance).unwrap());
|
||||
|
||||
let metadata_quota = Quota::with_period(Duration::from_secs(60) / config.metadata_rpm)
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(config.burst_allowance).unwrap());
|
||||
|
||||
let reset_quota = Quota::with_period(Duration::from_secs(60) / config.reset_rpm)
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(config.burst_allowance).unwrap());
|
||||
|
||||
Self {
|
||||
session_limiter: RateLimiter::direct(session_quota),
|
||||
search_limiter: RateLimiter::direct(search_quota),
|
||||
metadata_limiter: RateLimiter::direct(metadata_quota),
|
||||
reset_limiter: RateLimiter::direct(reset_quota),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for permission to make a request of the given type
|
||||
pub async fn wait_for_permission(&self, request_type: RequestType) {
|
||||
let limiter = match request_type {
|
||||
RequestType::Session => &self.session_limiter,
|
||||
RequestType::Search => &self.search_limiter,
|
||||
RequestType::Metadata => &self.metadata_limiter,
|
||||
RequestType::Reset => &self.reset_limiter,
|
||||
};
|
||||
|
||||
debug!(request_type = ?request_type, "Waiting for rate limit permission");
|
||||
|
||||
// Wait until we can make the request
|
||||
limiter.until_ready().await;
|
||||
|
||||
debug!(request_type = ?request_type, "Rate limit permission granted");
|
||||
}
|
||||
|
||||
/// Checks if a request of the given type would be allowed immediately
|
||||
pub fn check_permission(&self, request_type: RequestType) -> bool {
|
||||
let limiter = match request_type {
|
||||
RequestType::Session => &self.session_limiter,
|
||||
RequestType::Search => &self.search_limiter,
|
||||
RequestType::Metadata => &self.metadata_limiter,
|
||||
RequestType::Reset => &self.reset_limiter,
|
||||
};
|
||||
|
||||
limiter.check().is_ok()
|
||||
}
|
||||
|
||||
/// Gets the current configuration
|
||||
pub fn config(&self) -> &RateLimitConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Updates the rate limit configuration
|
||||
pub fn update_config(&mut self, config: RateLimitConfig) {
|
||||
self.config = config;
|
||||
// Note: In a production system, you'd want to recreate the limiters
|
||||
// with the new configuration, but for simplicity we'll just update
|
||||
// the config field here.
|
||||
warn!("Rate limit configuration updated - restart required for full effect");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BannerRateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new(RateLimitConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared rate limiter instance
|
||||
pub type SharedRateLimiter = Arc<BannerRateLimiter>;
|
||||
|
||||
/// Creates a new shared rate limiter with default configuration
|
||||
pub fn create_shared_rate_limiter() -> SharedRateLimiter {
|
||||
Arc::new(BannerRateLimiter::default())
|
||||
}
|
||||
|
||||
/// Creates a new shared rate limiter with custom configuration
|
||||
pub fn create_shared_rate_limiter_with_config(config: RateLimitConfig) -> SharedRateLimiter {
|
||||
Arc::new(BannerRateLimiter::new(config))
|
||||
}
|
||||
|
||||
/// Conversion from config module's RateLimitingConfig to this module's RateLimitConfig
|
||||
impl From<crate::config::RateLimitingConfig> for RateLimitConfig {
|
||||
fn from(config: crate::config::RateLimitingConfig) -> Self {
|
||||
Self {
|
||||
session_rpm: config.session_rpm,
|
||||
search_rpm: config.search_rpm,
|
||||
metadata_rpm: config.metadata_rpm,
|
||||
reset_rpm: config.reset_rpm,
|
||||
burst_allowance: config.burst_allowance,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,9 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// Create Banner API client
|
||||
let banner_api = BannerApi::new(config.banner_base_url).expect("Failed to create BannerApi");
|
||||
let banner_api =
|
||||
BannerApi::new_with_config(config.banner_base_url, config.rate_limiting.into())
|
||||
.expect("Failed to create BannerApi");
|
||||
|
||||
// Get current term
|
||||
let term = Term::get_current().inner().to_string();
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct Config {
|
||||
deserialize_with = "deserialize_duration"
|
||||
)]
|
||||
pub shutdown_timeout: Duration,
|
||||
/// Rate limiting configuration for Banner API requests
|
||||
#[serde(default = "default_rate_limiting")]
|
||||
pub rate_limiting: RateLimitingConfig,
|
||||
}
|
||||
|
||||
/// Default log level of "info"
|
||||
@@ -59,6 +62,62 @@ fn default_shutdown_timeout() -> Duration {
|
||||
Duration::from_secs(8)
|
||||
}
|
||||
|
||||
/// Rate limiting configuration for Banner API requests
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct RateLimitingConfig {
|
||||
/// Requests per minute for session operations (very conservative)
|
||||
#[serde(default = "default_session_rpm")]
|
||||
pub session_rpm: u32,
|
||||
/// Requests per minute for search operations (moderate)
|
||||
#[serde(default = "default_search_rpm")]
|
||||
pub search_rpm: u32,
|
||||
/// Requests per minute for metadata operations (moderate)
|
||||
#[serde(default = "default_metadata_rpm")]
|
||||
pub metadata_rpm: u32,
|
||||
/// Requests per minute for reset operations (low priority)
|
||||
#[serde(default = "default_reset_rpm")]
|
||||
pub reset_rpm: u32,
|
||||
/// Burst allowance (extra requests allowed in short bursts)
|
||||
#[serde(default = "default_burst_allowance")]
|
||||
pub burst_allowance: u32,
|
||||
}
|
||||
|
||||
/// Default rate limiting configuration
|
||||
fn default_rate_limiting() -> RateLimitingConfig {
|
||||
RateLimitingConfig {
|
||||
session_rpm: default_session_rpm(),
|
||||
search_rpm: default_search_rpm(),
|
||||
metadata_rpm: default_metadata_rpm(),
|
||||
reset_rpm: default_reset_rpm(),
|
||||
burst_allowance: default_burst_allowance(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default session requests per minute (6 = 1 every 10 seconds)
|
||||
fn default_session_rpm() -> u32 {
|
||||
6
|
||||
}
|
||||
|
||||
/// Default search requests per minute (30 = 1 every 2 seconds)
|
||||
fn default_search_rpm() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
/// Default metadata requests per minute (20 = 1 every 3 seconds)
|
||||
fn default_metadata_rpm() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
/// Default reset requests per minute (10 = 1 every 6 seconds)
|
||||
fn default_reset_rpm() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
/// Default burst allowance (3 extra requests)
|
||||
fn default_burst_allowance() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
/// Duration parser configured to handle various time units with seconds as default
|
||||
///
|
||||
/// Supports:
|
||||
|
||||
@@ -79,8 +79,10 @@ async fn main() {
|
||||
);
|
||||
|
||||
// Create BannerApi and AppState
|
||||
let banner_api =
|
||||
BannerApi::new(config.banner_base_url.clone()).expect("Failed to create BannerApi");
|
||||
let banner_api = BannerApi::new_with_config(
|
||||
config.banner_base_url.clone(),
|
||||
config.rate_limiting.clone().into(),
|
||||
).expect("Failed to create BannerApi");
|
||||
|
||||
let banner_api_arc = Arc::new(banner_api);
|
||||
let app_state = AppState::new(banner_api_arc.clone(), &config.redis_url)
|
||||
|
||||
Reference in New Issue
Block a user