diff --git a/src/banner/api.rs b/src/banner/api.rs index 2184dbc..5642611 100644 --- a/src/banner/api.rs +++ b/src/banner/api.rs @@ -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::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 { + 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 { diff --git a/src/banner/mod.rs b/src/banner/mod.rs index 1ef50be..88d3874 100644 --- a/src/banner/mod.rs +++ b/src/banner/mod.rs @@ -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::*; diff --git a/src/banner/rate_limit_middleware.rs b/src/banner/rate_limit_middleware.rs new file mode 100644 index 0000000..e114564 --- /dev/null +++ b/src/banner/rate_limit_middleware.rs @@ -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 { + 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) + } + } + } +} diff --git a/src/banner/rate_limiter.rs b/src/banner/rate_limiter.rs new file mode 100644 index 0000000..d35af18 --- /dev/null +++ b/src/banner/rate_limiter.rs @@ -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, + search_limiter: RateLimiter, + metadata_limiter: RateLimiter, + reset_limiter: RateLimiter, + 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; + +/// 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 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, + } + } +} diff --git a/src/bin/search.rs b/src/bin/search.rs index 1ca5136..e60cfd5 100644 --- a/src/bin/search.rs +++ b/src/bin/search.rs @@ -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(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 67ecf7f..e19ffde 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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: diff --git a/src/main.rs b/src/main.rs index 398bc51..201c23e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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)