diff --git a/backend/src/constants.rs b/backend/src/constants.rs index 7426e232..914ed0b0 100644 --- a/backend/src/constants.rs +++ b/backend/src/constants.rs @@ -11,45 +11,39 @@ /// replenishment window (see [`RATE_LIMIT_PERIOD_SECS`]) before receiving a /// **429 Too Many Requests** response. /// -/// ## Configuration -/// -/// - **Limit:** 100 requests -/// - **Window:** 15 minutes (900 seconds) -/// - **Rate:** Approximately 1 token replenished every 9 seconds -/// /// The rate limiter uses a **token-bucket algorithm**. Each successful request /// consumes one token; tokens are replenished at a constant rate derived from /// `period / burst`. Once the bucket is empty, subsequent requests are rejected /// with `HTTP 429` until tokens are replenished. -/// -/// ## Use Case -/// -/// This conservative default protects public endpoints from brute-force attacks, -/// credential stuffing, DDoS attempts, and accidental API abuse, without -/// significantly impacting legitimate users. pub const RATE_LIMIT_BURST_SIZE: u32 = 100; /// Replenishment period for the token-bucket rate limiter (15 minutes in seconds). /// -/// This defines the time window over which [`RATE_LIMIT_BURST_SIZE`] tokens are -/// made available. The replenishment rate is calculated as: -/// -/// ```text -/// tokens_per_second = RATE_LIMIT_PERIOD_SECS / RATE_LIMIT_BURST_SIZE -/// = 900 / 100 -/// = 1 token every 9 seconds -/// ``` -/// -/// ## Rationale -/// -/// A 15-minute window strikes a balance between: -/// - **Allowing legitimate bursts** (e.g., a user rapidly navigating the UI) -/// - **Preventing sustained abuse** (e.g., scrapers or DDoS traffic) -/// -/// The window is long enough to prevent false positives from legitimate users -/// while short enough to quickly mitigate attacks. +/// Replenishment rate: `RATE_LIMIT_PERIOD_SECS / RATE_LIMIT_BURST_SIZE` = 1 token every 9 s. pub const RATE_LIMIT_PERIOD_SECS: u64 = 900; +// ── Per-route rate limit tiers ──────────────────────────────────────────────── + +/// **Read tier** — public read endpoints (`/pools`, `/stats`, `/leaderboard`, etc.). +/// 60 requests / 60 s window (~1 req/s sustained, burst up to 60). +pub const RATE_LIMIT_READ_BURST: u32 = 60; +pub const RATE_LIMIT_READ_PERIOD_SECS: u64 = 60; + +/// **Write tier** — indexer ingest endpoints (`/indexer/*`). +/// 20 requests / 60 s window (~1 req/3 s sustained, burst up to 20). +pub const RATE_LIMIT_WRITE_BURST: u32 = 20; +pub const RATE_LIMIT_WRITE_PERIOD_SECS: u64 = 60; + +/// **User tier** — per-user history / predictions endpoints. +/// 30 requests / 60 s window — slightly more permissive than writes. +pub const RATE_LIMIT_USER_BURST: u32 = 30; +pub const RATE_LIMIT_USER_PERIOD_SECS: u64 = 60; + +/// **Light tier** — cheap, stateless endpoints (`/fees`, `/prices`, `/health`). +/// 120 requests / 60 s window — generous for polling-friendly endpoints. +pub const RATE_LIMIT_LIGHT_BURST: u32 = 120; +pub const RATE_LIMIT_LIGHT_PERIOD_SECS: u64 = 60; + // ── Pagination ──────────────────────────────────────────────────────────────── /// Default number of items returned per page when no `limit` is supplied. diff --git a/backend/src/lib.rs b/backend/src/lib.rs index fdb27f8b..6ef7bda9 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -13,6 +13,7 @@ pub mod jwt; pub mod metrics; pub mod openapi; pub mod price_cache; +pub mod rate_limit; pub mod redis_cache; pub mod referrals; pub mod request_logger; diff --git a/backend/src/rate_limit.rs b/backend/src/rate_limit.rs new file mode 100644 index 00000000..bc714f35 --- /dev/null +++ b/backend/src/rate_limit.rs @@ -0,0 +1,96 @@ +//! Per-route rate limiting helpers. +//! +//! Each route group in `routes/v1.rs` wraps its sub-router with the +//! appropriate tier from this module. In test builds the layer is a no-op so +//! parallel tests do not cross-contaminate each other's token buckets. + +/// Rate-limit tier — maps to a `(burst_size, period_secs)` pair. +#[derive(Clone, Copy, Debug)] +pub enum RateLimitTier { + /// Cheap stateless endpoints (`/fees`, `/prices`, `/health`). 120 req / 60 s. + Light, + /// Public read endpoints (`/pools`, `/stats`, `/leaderboard`). 60 req / 60 s. + Read, + /// Per-user history / prediction endpoints. 30 req / 60 s. + User, + /// Indexer ingest endpoints (`/indexer/*`). 20 req / 60 s. + Write, +} + +impl RateLimitTier { + fn burst_and_period(self) -> (u32, u64) { + use crate::constants::*; + match self { + RateLimitTier::Light => (RATE_LIMIT_LIGHT_BURST, RATE_LIMIT_LIGHT_PERIOD_SECS), + RateLimitTier::Read => (RATE_LIMIT_READ_BURST, RATE_LIMIT_READ_PERIOD_SECS), + RateLimitTier::User => (RATE_LIMIT_USER_BURST, RATE_LIMIT_USER_PERIOD_SECS), + RateLimitTier::Write => (RATE_LIMIT_WRITE_BURST, RATE_LIMIT_WRITE_PERIOD_SECS), + } + } +} + +/// Wrap `router` with a `GovernorLayer` configured for `tier`. +/// +/// In `#[cfg(test)]` builds this is a no-op — the router is returned as-is so +/// parallel unit tests do not rate-limit each other. +#[cfg(not(test))] +pub fn with_rate_limit(router: axum::Router, tier: RateLimitTier) -> axum::Router { + use std::sync::Arc; + use tower_governor::governor::GovernorConfigBuilder; + + let (burst_size, period_secs) = tier.burst_and_period(); + + let config = Arc::new( + GovernorConfigBuilder::default() + .period(std::time::Duration::from_secs(period_secs)) + .burst_size(burst_size) + .error_handler(|_| crate::response::rate_limit_error_response()) + .finish() + .expect("invalid governor config"), + ); + + router.layer(tower_governor::GovernorLayer { config }) +} + +#[cfg(test)] +pub fn with_rate_limit(router: axum::Router, _tier: RateLimitTier) -> axum::Router { + router +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tier_burst_and_period_values() { + use crate::constants::*; + + let (b, p) = RateLimitTier::Light.burst_and_period(); + assert_eq!((b, p), (RATE_LIMIT_LIGHT_BURST, RATE_LIMIT_LIGHT_PERIOD_SECS)); + + let (b, p) = RateLimitTier::Read.burst_and_period(); + assert_eq!((b, p), (RATE_LIMIT_READ_BURST, RATE_LIMIT_READ_PERIOD_SECS)); + + let (b, p) = RateLimitTier::User.burst_and_period(); + assert_eq!((b, p), (RATE_LIMIT_USER_BURST, RATE_LIMIT_USER_PERIOD_SECS)); + + let (b, p) = RateLimitTier::Write.burst_and_period(); + assert_eq!((b, p), (RATE_LIMIT_WRITE_BURST, RATE_LIMIT_WRITE_PERIOD_SECS)); + } + + #[test] + fn tiers_have_distinct_configs() { + let configs = [ + RateLimitTier::Light.burst_and_period(), + RateLimitTier::Read.burst_and_period(), + RateLimitTier::User.burst_and_period(), + RateLimitTier::Write.burst_and_period(), + ]; + // Each tier should be unique + for i in 0..configs.len() { + for j in (i + 1)..configs.len() { + assert_ne!(configs[i], configs[j], "tiers {i} and {j} share the same config"); + } + } + } +} diff --git a/backend/src/routes/v1.rs b/backend/src/routes/v1.rs index baaab40e..7259f1bb 100644 --- a/backend/src/routes/v1.rs +++ b/backend/src/routes/v1.rs @@ -645,7 +645,16 @@ pub async fn ingest_prediction_placed( } } -/// Build the version 1 API router. +/// Build the version 1 API router with per-route rate limiting. +/// +/// Routes are grouped into four rate-limit tiers (see [`crate::rate_limit`]): +/// +/// | Tier | Endpoints | Burst / period | +/// |-------|-------------------------------------------------|----------------| +/// | Light | `/`, `/health`, `/fees`, `/prices`, `/ws` | 120 / 60 s | +/// | Read | `/pools`, `/pools/:id`, `/stats`, `/leaderboard`, `/referrals/*` | 60 / 60 s | +/// | User | `/users/*` | 30 / 60 s | +/// | Write | `/indexer/*` | 20 / 60 s | pub fn router( config: Arc, cache: PriceCache, @@ -654,6 +663,8 @@ pub fn router( metrics: SharedMetrics, event_bus: crate::ws::EventBus, ) -> Router { + use crate::rate_limit::{RateLimitTier, with_rate_limit}; + let state = AppState { config, cache, @@ -663,30 +674,55 @@ pub fn router( event_bus, }; + // Light tier — cheap, stateless, polling-friendly endpoints. + let light = with_rate_limit( + Router::new() + .route("/", get(index)) + .route("/health", get(health)) + .route("/fees", get(get_fees)) + .route("/prices", get(crate::price_cache::get_prices)) + .route("/ws", get(crate::ws::ws_handler)) + .with_state(state.clone()), + RateLimitTier::Light, + ); + + // Read tier — public, database-backed read endpoints. + let read = with_rate_limit( + Router::new() + .route("/pools", get(get_pools)) + .route("/pools/:id", get(get_pool_by_id_handler)) + .route("/stats", get(get_stats)) + .route("/leaderboard", get(get_leaderboard)) + .route("/referrals/{address}", get(referrals_handler)) + .route("/referrals/{address}/estimate", get(referral_estimate_handler)) + .with_state(state.clone()), + RateLimitTier::Read, + ); + + // User tier — per-user history and predictions. + let user = with_rate_limit( + Router::new() + .route("/users/{address}/history", get(get_user_history)) + .route("/users/{address}/predictions", get(get_user_predictions)) + .route("/users/{address}/referrals", get(user_referral_earnings_handler)) + .with_state(state.clone()), + RateLimitTier::User, + ); + + // Write tier — indexer ingest (typically internal, strictest limit). + let write = with_rate_limit( + Router::new() + .route("/indexer/pool-created", post(ingest_pool_created)) + .route("/indexer/prediction-placed", post(ingest_prediction_placed)) + .with_state(state), + RateLimitTier::Write, + ); + Router::new() - .route("/", get(index)) - .route("/health", get(health)) - .route("/pools", get(get_pools)) - .route("/pools/:id", get(get_pool_by_id_handler)) - .route("/stats", get(get_stats)) - .route("/leaderboard", get(get_leaderboard)) - .route("/fees", get(get_fees)) - .route("/prices", get(crate::price_cache::get_prices)) - .route("/referrals/{address}", get(referrals_handler)) - .route( - "/referrals/{address}/estimate", - get(referral_estimate_handler), - ) - .route( - "/users/{address}/referrals", - get(user_referral_earnings_handler), - ) - .route("/users/{address}/history", get(get_user_history)) - .route("/users/{address}/predictions", get(get_user_predictions)) - .route("/indexer/pool-created", post(ingest_pool_created)) - .route("/indexer/prediction-placed", post(ingest_prediction_placed)) - .route("/ws", get(crate::ws::ws_handler)) - .with_state(state) + .merge(light) + .merge(read) + .merge(user) + .merge(write) } /// `GET /api/v1/fees` — reads fee config from the shared AppState. diff --git a/backend/src/server.rs b/backend/src/server.rs index dfbd21c1..6cfa424f 100644 --- a/backend/src/server.rs +++ b/backend/src/server.rs @@ -23,8 +23,6 @@ use std::sync::Arc; use std::time::Duration; use tokio::time::{sleep, Duration as TokioDuration}; use tokio::task::JoinHandle; -#[cfg(not(test))] -use tower_governor::governor::GovernorConfigBuilder; use tower_http::cors::{AllowOrigin, CorsLayer}; use tracing::{error, info, warn}; @@ -387,30 +385,8 @@ fn build_router_with_rate_period( .layer(build_cors(&config)) .layer(LoggingLayer::with_metrics(prometheus_metrics.clone())); - #[cfg(not(test))] - let router = { - let governor_conf = Arc::new( - GovernorConfigBuilder::default() - .period(period) - .burst_size(burst_size) - .error_handler(|_| { - use crate::response::ApiResponse; - use crate::response::error_codes; - ApiResponse::<()>::error( - axum::http::StatusCode::TOO_MANY_REQUESTS, - error_codes::RATE_LIMIT_EXCEEDED, - "Too Many Requests" - ).into_response() - }) - .error_handler(|_| crate::response::rate_limit_error_response()) - .finish() - .unwrap(), - ); - router.layer(tower_governor::GovernorLayer { - config: governor_conf, - }) - }; - + // Per-route rate limiting is applied inside `routes/v1.rs` via + // `crate::rate_limit::with_rate_limit`. No global GovernorLayer here. router } @@ -459,30 +435,8 @@ fn build_router_with_db( .layer(build_cors(&config)) .layer(LoggingLayer::with_metrics(prometheus_metrics.clone())); - #[cfg(not(test))] - let router = { - let governor_conf = Arc::new( - GovernorConfigBuilder::default() - .per_second(crate::constants::RATE_LIMIT_PERIOD_SECS) - .burst_size(crate::constants::RATE_LIMIT_BURST_SIZE) - .error_handler(|_| { - use crate::response::ApiResponse; - use crate::response::error_codes; - ApiResponse::<()>::error( - axum::http::StatusCode::TOO_MANY_REQUESTS, - error_codes::RATE_LIMIT_EXCEEDED, - "Too Many Requests" - ).into_response() - }) - .error_handler(|_| crate::response::rate_limit_error_response()) - .finish() - .unwrap(), - ); - router.layer(tower_governor::GovernorLayer { - config: governor_conf, - }) - }; - + // Per-route rate limiting is applied inside `routes/v1.rs` via + // `crate::rate_limit::with_rate_limit`. No global GovernorLayer here. router }