What rate limiting does(What/When/Why/Flow of a rate-limited request)
Request flow (middleware → handler → DB → middleware)
Step 1: Decide which headers to set
Step 2: Create rate limiter middleware
Step 3: Wire middleware in main.rs
Step 4: Handler example (login) showing DB flow
Step 5: Make rate limiting stricter only for login (best practice)
Production notes (very important)
Rate limiting means “don’t allow one client to hit the same API too many times in a short time.” It protects your server from brute-force login attempts, abusive bots, and accidental traffic spikes. In session-based apps, rate limiting is especially useful for endpoints like /login, /register, /otp, /search, and any expensive DB query route.
In this blog, we’ll build a custom rate limiting middleware (no external service needed), wire it in main.rs, and show how it blocks requests before they reach handler + DB.
What rate limiting does
What
It limits requests per key (IP, user_id, or API token) per time window.
Why
Stops brute-force attacks on /login
Reduces DB load
Avoids abuse (spam, scraping)
Improves uptime
When
Always on login and OTP routes
On public listing endpoints that can be scraped
On file upload endpoints (to avoid storage abuse)
Flow of a rate-limited request
Request flow
Client hits /api/login
Middleware runs first
Middleware checks “how many requests from this key in last X seconds”
If allowed → request goes to handler → handler calls DB
If blocked → middleware returns 429 Too Many Requests and handler/DB never runs
3) Step 1: Add required dependencies
In Cargo.toml:
# For time + thread-safe shared state
chrono = "0.4"
tokio = { version = "1", features = ["sync"] }
(You already use tokio and chrono; if yes, no changes.)
4) Step 2: Create rate limiter middleware
Create file:
src/middleware/rate_limit.rs
This version implements a fixed window counter:
window: 60 seconds
max requests: 10 per window per IP per route (customizable)
use actix_service::{Service, Transform};
use actix_web::{
dev::{ServiceRequest, ServiceResponse},
error,
http::StatusCode,
Error, HttpResponse,
};
use chrono::Utc;
use futures_util::future::{ready, LocalBoxFuture, Ready};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
/// Simple in-memory rate limiter state.
/// Key example: "IP|METHOD|PATH"
#[derive(Clone)]
pub struct RateLimiter {
pub state: Arc<Mutex<HashMap<String, Counter>>>,
pub window_secs: i64,
pub max_requests: u32,
}
#[derive(Debug, Clone)]
pub struct Counter {
pub window_start: i64,
pub count: u32,
}
impl RateLimiter {
pub fn new(window_secs: i64, max_requests: u32) -> Self {
Self {
state: Arc::new(Mutex::new(HashMap::new())),
window_secs,
max_requests,
}
}
}
/// Middleware wrapper object
pub struct RateLimitMiddleware {
limiter: RateLimiter,
}
impl RateLimitMiddleware {
pub fn new(limiter: RateLimiter) -> Self {
Self { limiter }
}
}
impl<S, B> Transform<S, ServiceRequest> for RateLimitMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Transform = RateLimitMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(RateLimitMiddlewareService {
service,
limiter: self.limiter.clone(),
}))
}
}
pub struct RateLimitMiddlewareService<S> {
service: S,
limiter: RateLimiter,
}
impl<S, B> Service<ServiceRequest> for RateLimitMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn call(&self, req: ServiceRequest) -> Self::Future {
let limiter = self.limiter.clone();
// Identify client (basic: IP). If behind proxy, you’ll read X-Forwarded-For safely.
let ip = req
.connection_info()
.realip_remote_addr()
.unwrap_or("unknown")
.to_string();
// Per endpoint key
let key = format!("{}|{}|{}", ip, req.method(), req.path());
let fut = self.service.call(req);
Box::pin(async move {
let now = Utc::now().timestamp();
// Check + update counter
let mut map = limiter.state.lock().await;
let counter = map.entry(key).or_insert(Counter {
window_start: now,
count: 0,
});
// Reset window if expired
if now - counter.window_start >= limiter.window_secs {
counter.window_start = now;
counter.count = 0;
}
// Increment and decide
counter.count += 1;
if counter.count > limiter.max_requests {
// Block request with 429
let res = HttpResponse::build(StatusCode::TOO_MANY_REQUESTS).json(serde_json::json!({
"success": false,
"message": "Too many requests. Please try again later.",
"code": 429
}));
return Err(error::InternalError::from_response("", res).into());
}
// Allowed → continue
fut.await
})
}
}
Notes (simple, practical)
This is in-memory rate limiting. It works well for single server.
For multi-server (multiple instances), you’d store counters in Redis (same logic, shared store).
5) Step 3: Wire middleware in main.rs
Create module file:
src/middleware/mod.rs
pub mod rate_limit;
Now in main.rs:
mod middleware;
use middleware::rate_limit::{RateLimiter, RateLimitMiddleware};
Add it to your app. Example: allow 10 requests per 60 seconds per IP per endpoint.
HttpServer::new(move || {
let limiter = RateLimiter::new(60, 10);
App::new()
.app_data(state.clone())
.wrap(cors)
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), key.clone())
.cookie_secure(false)
.cookie_same_site(SameSite::Lax)
.build(),
)
// Rate limit runs before handlers
.wrap(RateLimitMiddleware::new(limiter))
.service(
web::scope("/api")
.route("/login", web::post().to(handlers::login))
.route("/shops", web::get().to(handlers::list_shops_handler))
)
.service(Files::new("/uploads", "./uploads"))
})
Important middleware order
CORS and Session can be before rate limit.
Rate limit should be early enough that it blocks before heavy work.
6) Step 4: Handler example (login) showing DB flow
Handler
src/handlers/auth.rs (simplified)
use actix_session::Session;
use actix_web::{error, web, HttpResponse, Result};
use crate::db::{find_user_by_username, AppState};
use crate::models::LoginForm;
use super::common::ApiResponse;
pub async fn login(
body: web::Json<LoginForm>,
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse> {
// If rate limit blocks, this handler will never run.
let user = find_user_by_username(&state.pool, &body.username)
.await
.map_err(error::ErrorInternalServerError)?;
if let Some(user) = user {
let valid = user.password == body.password;
if valid {
session.insert("user_id", user.id).map_err(error::ErrorInternalServerError)?;
session.insert("username", user.username.clone()).map_err(error::ErrorInternalServerError)?;
session.insert("role", user.role.clone()).map_err(error::ErrorInternalServerError)?;
return Ok(HttpResponse::Ok().json(ApiResponse {
message: "Login successful".to_string(),
data: true,
}));
}
}
Err(error::ErrorUnauthorized("Invalid credentials"))
}
DB function
src/db/user.rs
use sqlx::MySqlPool;
#[derive(sqlx::FromRow, Debug)]
pub struct DbUser {
pub id: i64,
pub username: String,
pub password: String,
pub role: String,
}
pub async fn find_user_by_username(
pool: &MySqlPool,
username: &str
) -> Result<Option<DbUser>, sqlx::Error> {
let user = sqlx::query_as::<_, DbUser>(
"SELECT id, username, password, role FROM users WHERE username = ? LIMIT 1"
)
.bind(username)
.fetch_optional(pool)
.await?;
Ok(user)
}
7) Step 5: Make rate limiting stricter only for login (best practice)
Instead of limiting every route equally, you usually want:
/login: 5 requests / minute
/uploads: 10 requests / minute
/home/vehicles: higher limit (like 120/min)
Simple way: apply middleware to a specific scope
.service(
web::scope("/api")
// Public routes
.route("/home/vehicles", web::get().to(handlers::list_home_vehicle_cards_handler))
// Login scope rate limited stricter
.service(
web::scope("")
.wrap(RateLimitMiddleware::new(RateLimiter::new(60, 5)))
.route("/login", web::post().to(handlers::login))
)
)
This keeps your site usable while protecting sensitive endpoints.
8) Production notes (very important)
In-memory limiter limitations
Works per server instance only.
If you run 3 servers, each has its own counters (rate limiting becomes weaker).
Production upgrade
Replace HashMap with Redis counter (shared store).
Same “key/window/count” logic, but stored in Redis.
Top comments (0)