Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 23 additions & 29 deletions backend/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
96 changes: 96 additions & 0 deletions backend/src/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
}
84 changes: 60 additions & 24 deletions backend/src/routes/v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config>,
cache: PriceCache,
Expand All @@ -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,
Expand All @@ -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.
Expand Down
54 changes: 4 additions & 50 deletions backend/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down