Rate Limiting
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”api/src/middleware/rate_limit.rs: Axum middleware ชื่อ rate_limit ที่นับ request ต่อ client IP ต่อนาทีใน Redis แล้วปฏิเสธ request ที่เกิน limit ที่กำหนดไว้ด้วย 429 Too Many Requests โดย mount ผ่าน axum::middleware::from_fn_with_state เฉพาะบน /auth/* เท่านั้น — register กับ login คือสอง endpoint ที่ผู้โจมตีจะอยากถล่มจริง ๆ ไม่ว่าจะเพื่อ brute-force รหัสผ่าน หรือยิง junk account เข้าตาราง users
มีการเปลี่ยนแปลงเล็ก ๆ อีกสองจุดตามมาด้วย: error.rs ได้ variant ใหม่ AppError::RateLimited { retry_after_secs } พร้อม IntoResponse ของตัวเอง (variant เดียวที่ต้องการ response header ไม่ใช่แค่ JSON body) และ main.rs เปลี่ยนจาก axum::serve(listener, app) เป็น axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()) — การเปลี่ยนที่ทำให้ IP จริงของ client เข้าถึงได้จาก handler หรือ middleware ใด ๆ ผ่าน extractor ConnectInfo<SocketAddr>
การใช้งาน Redis ทุกจุดอื่นใน module นี้ปกป้องข้อมูลที่อยู่หลัง authentication อยู่แล้ว — cache:board:{id} ถูกอ่านหลัง assert_member ผ่านเท่านั้น Rate limiting ปกป้องจุดเดียวใน API ที่รันก่อนที่จะมี authentication ใด ๆ ให้เช็คด้วยซ้ำ: register กับ login ทั้งคู่จำเป็นต้องเปิดให้ทุกคนเข้าถึงได้โดยตั้งใจ นี่แหละคือสิ่งที่ทำให้สอง endpoint นี้คุ้มค่าที่จะปกป้องจากการโจมตีอีกแบบหนึ่ง — ไม่ใช่ “request นี้มีสิทธิ์เห็นข้อมูลนี้ไหม” แต่เป็น “client นี้ส่ง request บ่อยผิดปกติหรือเปล่า” เต็ม ๆ
INCR แล้วตามด้วย EXPIRE (เฉพาะครั้งแรกที่ hit) คือกลไกทั้งหมด: ratelimit:{ip}:{unix_minute} เป็น key ที่มีอยู่แค่สำหรับหน้าต่างเวลาหนึ่งนาทีที่เจาะจงหนึ่งช่วง สำหรับ IP หนึ่งตัวที่เจาะจง request แรกในหน้าต่างใหม่จะสร้าง key ที่ count 1 และตั้งให้หมดอายุใน 60 วินาที ทุก request ถัดไปในหน้าต่างเดียวกันแค่ increment key เดิม ไม่ต้องตั้ง expiry ใหม่อีก เมื่อนาทีเปลี่ยน ชื่อ key เองก็เปลี่ยนไปด้วย (unix_minute ขยับไปข้างหน้า) ดังนั้น key ของหน้าต่างก่อนหน้าก็แค่หมดอายุไปเองตามธรรมชาติ — ไม่มีอะไรต้องเก็บกวาด และไม่มี counter ให้ต้อง reset เอง
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Fixed window (ตัวที่เราใช้) เทียบกับ sliding window และ token bucket
- ข้อดี: fixed window คือกลไกทั้งหมดข้างบนนี้เลย —
INCRหนึ่งครั้ง,EXPIREแบบมีเงื่อนไขหนึ่งครั้ง, การเปรียบเทียบกับ limit หนึ่งครั้ง ไม่มีประวัติต้องเก็บไว้เกินกว่า count เดี่ยว ๆ ของหน้าต่างปัจจุบัน ไม่มี timestamp log ต้อง prune ไม่มี background refill process สำหรับ/auth/*ที่เป้าหมายจริง ๆ คือ “หยุด script ที่ถล่ม/loginเป็น loop รัว ๆ” ความเรียบง่ายของ fixed window มีค่ามากกว่าความแม่นยำที่ทางเลือกอื่นให้ได้มาก - ข้อเสีย: fixed window มีปัญหาที่รู้กันดีคือ boundary-burst — client สามารถส่ง
limitrequest ในวินาทีสุดท้ายของหน้าต่างหนึ่ง แล้วส่งlimitrequest อีกชุดทันทีในวินาทีแรกของหน้าต่างถัดไป รวมเป็น2 × limitrequest ในเวลาประมาณสองวินาที โดยไม่เคยเกิน count ต่อหน้าต่างในทางเทคนิคเลย Sliding window (ถ่วงน้ำหนัก count ของหน้าต่างก่อนหน้าตามสัดส่วนที่ทับกับช่วงเวลาปัจจุบัน หรือ track timestamp ของ request แต่ละอันใน sorted set) ปิดช่องว่างนี้ได้ แลกกับ Redis state ที่มากขึ้นและการเช็คต่อ request ที่แพงขึ้นเล็กน้อย Token bucket (allowance ที่ค่อย ๆ เติมกลับมาช้า ๆ ที่รองรับ burst สั้น ๆ ได้ถึงขนาดของ bucket แล้วค่อยจำกัดเหลือ refill rate) จำลอง “อัตราคงที่ระยะยาว, burst เป็นครั้งคราว” ได้แม่นยำกว่าทั้ง fixed และ sliding window แลกกับการ track จำนวน token และ timestamp การ refill ล่าสุดแทนที่จะเป็น counter ตัวเดียว TaskFlow ยอมรับความเสี่ยง boundary-burst นี้: สำหรับแอประดับคอร์ส fixed window ที่บล็อกการโจมตีต่อเนื่องได้เป็นการปรับปรุงที่ใหญ่มากเมื่อเทียบกับไม่มี rate limiting เลย และช่องว่างที่เหลือไว้ก็แคบและเจาะจง คุ้มค่าที่จะพูดถึงมากกว่าจะเป็นเหตุผลให้ต้องหยิบกลไกที่หนักกว่าซึ่งแอปนี้ยังไม่ต้องการ
INCR แล้วตามด้วย EXPIRE แบบมีเงื่อนไขในครั้งแรกที่ hit (ตัวที่เราใช้) เทียบกับ Lua script แบบ atomic ตัวเดียวที่รวมทั้งสองอย่าง
- ข้อดี: สอง command ธรรมดา ที่
deadpool_redis::redis::AsyncCommandsexpose ไว้ให้อยู่แล้วโดยไม่ต้อง setup อะไรเพิ่ม — ไม่ต้องเขียน Lua script, load เข้า Redis, แล้วคอยดูแลให้ตรงกับโค้ด Rust ฝั่งที่เรียกINCRบน key ที่ยังไม่มีอยู่จะสร้าง key ขึ้นมาที่1แบบ atomic ดังนั้นจึงไม่มี race บน “key นี้มีอยู่ไหม” แบบที่ patternGET-แล้ว-SETตรง ๆ จะมี - ข้อเสีย:
INCRกับEXPIREที่ตามมาเป็นสอง round trip แยกกัน ไม่ใช่ operation แบบ atomic เดียว — ในช่องว่างแคบ ๆ ระหว่างสองคำสั่งนี้ key ที่เพิ่งถูกสร้างโดยINCRในทางเทคนิคยังไม่มี expiry เลย request แรกที่เกิดพร้อมกันสองอันในหน้าต่างใหม่เดียวกันอาจเห็นcount == 1จากINCRของตัวเองทั้งคู่ แล้วพยายามEXPIREทั้งคู่ ซึ่งไม่เป็นอันตราย (การตั้ง expiry เดิมซ้ำสองครั้งเป็น no-op ไม่ใช่ bug) แต่ request ที่อ่าน key ในช่องว่างนั้นพอดีจะเจอ key ที่ไม่มี TTL ซึ่งจะอยู่ตลอดไปถ้า process crash ระหว่างสองคำสั่งนั้น Lua script แบบEVALเดียวที่รันทั้งสอง command แบบ atomic จะปิดช่องว่างนี้ได้สนิท TaskFlow ยอมรับความเสี่ยงเล็ก ๆ ที่ self-healing นี้ (ขาดEXPIREตรงนี้แค่หมายความว่า count key อยู่นานเกินไปนิดหน่อย ไม่ใช่ rate-limiting behavior ที่ผิดพลาด) แทนที่จะเพิ่ม Lua script เพื่อสิ่งนี้
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”1. middleware/mod.rs
หัวข้อที่มีชื่อว่า “1. middleware/mod.rs”สร้าง taskflow/backend/api/src/middleware/mod.rs:
pub mod rate_limit;2. middleware/rate_limit.rs
หัวข้อที่มีชื่อว่า “2. middleware/rate_limit.rs”สร้าง taskflow/backend/api/src/middleware/rate_limit.rs:
use std::net::SocketAddr;use std::time::{SystemTime, UNIX_EPOCH};
use axum::{ extract::{ConnectInfo, Request, State}, middleware::Next, response::Response,};use deadpool_redis::redis::AsyncCommands;
use crate::{error::AppError, state::AppState};
const LIMIT: i64 = 10;const WINDOW_SECS: u64 = 60;
pub async fn rate_limit( State(state): State<AppState>, ConnectInfo(addr): ConnectInfo<SocketAddr>, request: Request, next: Next,) -> Result<Response, AppError> { let mut conn = state .redis .get() .await .map_err(|err| AppError::Internal(err.into()))?;
let unix_minute = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() / WINDOW_SECS;
let key = format!("ratelimit:{}:{unix_minute}", addr.ip());
let count: i64 = conn .incr(&key, 1) .await .map_err(|err| AppError::Internal(err.into()))?;
if count == 1 { conn.expire::<_, ()>(&key, WINDOW_SECS as i64) .await .map_err(|err| AppError::Internal(err.into()))?; }
if count > LIMIT { return Err(AppError::RateLimited { retry_after_secs: WINDOW_SECS, }); }
Ok(next.run(request).await)}ไล่ดูตามลำดับ:
- ขอ Redis connection: pattern
state.redis.get().awaitเดียวกับที่ function อื่นทุกตัวที่แตะ Redis ในคอร์สนี้ใช้อยู่แล้ว - คำนวณ
unix_minute: Unix timestamp ปัจจุบันเป็นวินาที หารด้วย 60 การหารแบบ integer ทำให้ timestamp ใด ๆ ในหน้าต่าง 60 วินาทีเดียวกันได้ค่าunix_minuteเดียวกัน — นี่คือขอบเขตทั้งหมดของ “fixed window” เขียนเป็นบรรทัดเดียว - สร้าง key:
ratelimit:{ip}:{unix_minute}— key ต่างกันสำหรับทุกคู่ (client, หน้าต่าง) ตรงตามที่ design ต้องการเป๊ะ INCR: increment count ของ key นี้แบบ atomic และสร้างที่1ถ้ายังไม่มี ค่าcountคือค่า หลัง increment ดังนั้น request แรกในหน้าต่างจะเห็นcount == 1ไม่ใช่0EXPIREเฉพาะตอนcount == 1: เฉพาะ request ที่เพิ่งสร้าง key เท่านั้นที่ตั้ง expiry — request ที่มาทีหลังในหน้าต่างเดียวกันแค่ increment key ที่จะหมดอายุตามกำหนดอยู่แล้ว ไม่ต้องแตะ TTL อีก- เทียบกับ
LIMIT: ถ้า request นี้ดัน count เกิน10ให้ปฏิเสธด้วยAppError::RateLimitedก่อนnext.run(...)— handler ปลายทาง (registerหรือlogin) จะไม่รันเลยสำหรับ request ที่ถูกปฏิเสธ - ไม่งั้นก็ call ต่อไป:
next.run(request).awaitส่ง request ต่อไปยังสิ่งที่ middleware ห่ออยู่ — ตามรูปแบบเดียวกับ middlewarefrom_fn/from_fn_with_stateทุกตัวใน Axum
3. error.rs — variant RateLimited
หัวข้อที่มีชื่อว่า “3. error.rs — variant RateLimited”เพิ่ม variant ใหม่ให้ enum AppError ใน taskflow/backend/api/src/error.rs:
#[derive(Debug, thiserror::Error)]pub enum AppError { #[error("not found")] NotFound, #[error("unauthorized")] Unauthorized, #[error("forbidden")] Forbidden, #[error("{0}")] Conflict(String), #[error("{0}")] Validation(String), #[error(transparent)] Db(#[from] sqlx::Error), #[error("internal error")] Internal(#[from] anyhow::Error), #[error("too many requests")] RateLimited { retry_after_secs: u64 },}อัปเดต impl IntoResponse for AppError ให้ RateLimited มี response ของตัวเอง ก่อนถึง match เดิม:
impl IntoResponse for AppError { fn into_response(self) -> Response { if let AppError::RateLimited { retry_after_secs } = self { return ( StatusCode::TOO_MANY_REQUESTS, [( axum::http::header::RETRY_AFTER, retry_after_secs.to_string(), )], Json(json!({ "error": "rate_limited", "message": "too many requests" })), ) .into_response(); }
let (status, code) = match &self { AppError::NotFound => (StatusCode::NOT_FOUND, "not_found"), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"), AppError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"), AppError::Db(err) => { tracing::error!(error = %err, "database error"); (StatusCode::INTERNAL_SERVER_ERROR, "internal") } AppError::Internal(err) => { tracing::error!(error = %err, "internal error"); (StatusCode::INTERNAL_SERVER_ERROR, "internal") } AppError::RateLimited { .. } => unreachable!("handled above"), };
let message = match &self { AppError::Db(_) | AppError::Internal(_) => "internal error".to_string(), _ => self.to_string(), };
(status, Json(json!({ "error": code, "message": message }))).into_response() }}RateLimited ถูกจัดการด้วย return แต่เนิ่น ๆ ของตัวเอง ไม่ใช่อีกกิ่งหนึ่งของ match หลัก เพราะเป็น variant เดียวที่ต้องการ response header (Retry-After) เพิ่มจาก JSON body — variant อื่นต้องการแค่ status code กับข้อความเท่านั้น [(axum::http::header::RETRY_AFTER, retry_after_secs.to_string())] คือรูปแบบ array-of-tuples ของ header ใน Axum: tuple (StatusCode, headers, body) implement IntoResponse ตรง ๆ เป็น pattern เดียวกับที่ใช้ทั่ว Axum สำหรับเพิ่ม header โดยไม่ต้องสร้าง HeaderMap เอง กิ่ง AppError::RateLimited { .. } => unreachable!(...) มีไว้แค่ให้ match ยัง exhaustive อยู่ — return แต่เนิ่น ๆ ข้างบนรับประกันว่ากิ่งนี้ไม่มีทางรันถึงจริง ๆ แต่ compiler รู้แบบนั้นไม่ได้ถ้าไม่มีกิ่งนี้อยู่
4. ต่อสายใน main.rs
หัวข้อที่มีชื่อว่า “4. ต่อสายใน main.rs”mod auth;mod boards;mod cache;mod cards;mod columns;mod config;mod db;mod error;mod labels;mod middleware;mod state;
use std::net::SocketAddr;
use axum::{middleware::from_fn_with_state, routing::get, Json, Router};use middleware::rate_limit::rate_limit;
let app = Router::new() .route("/health", get(health)) .nest( "/auth", auth::routes().route_layer(from_fn_with_state(state.clone(), rate_limit)), ) .merge(boards::routes()) .merge(columns::routes()) .merge(cards::routes()) .merge(labels::routes()) .layer(cors) .with_state(state);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{app_port}")).await?;tracing::info!("listening on {}", listener.local_addr()?);
axum::serve( listener, app.into_make_service_with_connect_info::<SocketAddr>(),).await?;มีสามจุดที่เปลี่ยนไปจากเวอร์ชัน main.rs ของ labels:
.route_layer(from_fn_with_state(state.clone(), rate_limit))ถูก chain ตรงเข้ากับauth::routes()ก่อน ที่.nest("/auth", ...)จะห่ออีกชั้น — วิธีนี้จำกัด scope ของ middleware ให้อยู่แค่/auth/*เท่านั้น ไม่ลามไปที่อื่น ส่วนstate.clone()ราคาถูก (Arc-backed clone แบบเดียวกับที่AppStateออกแบบไว้ตั้งแต่ db-pool) ส่วนstateตัวเดิมยังถูก move เข้า.with_state(state)ต่อไปตามปกติmod middleware;เป็นการประกาศ module ระดับบนสุดตัวใหม่- argument ตัวที่สองของ
axum::serveเปลี่ยนเป็นapp.into_make_service_with_connect_info::<SocketAddr>()แทนที่จะเป็นappเปล่า ๆ — นี่คือสิ่งที่ทำให้ConnectInfo<SocketAddr>extract ได้เลย ถ้าไม่มีอันนี้ extractorConnectInfo<SocketAddr>ของrate_limitจะ fail ทุก request
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”cargo check -p apiเปิด stack ขึ้นมาแล้วถล่ม /auth/login เกิน limit — 12 request ใช้ email ที่ไม่จำเป็นต้องมีอยู่จริง (401 จาก credential ผิดก็ยังนับรวมใน limit เพราะ middleware รันก่อนhandler):
cd taskflow/infra && docker compose up -d db rediscd ../backend && cargo run -p api &
for i in $(seq 1 12); do curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"nobody@example.com","password":"wrong password"}'done10 request แรกคืน 401 (credential ผิด แต่ยังปล่อยผ่าน) request ที่ 11 กับ 12 คืน 429:
401401401401401401401401401401429429ยืนยันว่า response 429 มี Retry-After ติดมาด้วย:
curl -s -i -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"nobody@example.com","password":"wrong password"}' | grep -i retry-afterretry-after: 60ยืนยันว่า /boards ซึ่งอยู่นอก /auth/* ไม่โดนผลกระทบจาก client เดียวกันที่กำลังถล่ม /auth อยู่:
for i in $(seq 1 12); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/boards \ -H "Authorization: Bearer $TOKEN"doneทั้ง 12 request คืน 200 เพราะ rate limiter ถูก mount ไว้แค่บน /auth nest เท่านั้น ไม่ใช่ router ชั้นนอก
คุณสร้าง rate_limit ที่เป็น Axum middleware แบบ fixed-window ที่นับ request ต่อ IP ต่อนาทีใน Redis (ratelimit:{ip}:{unix_minute}, INCR แล้วตามด้วย EXPIRE ในครั้งแรกที่ hit) แล้วปฏิเสธ request ที่ 11 เป็นต้นไปในหน้าต่างใด ๆ ด้วย 429 พร้อม header Retry-After — mount ด้วย .route_layer(from_fn_with_state(...)) บน auth::routes() เท่านั้น เพื่อให้ปกป้องแค่ /auth/* ไม่ลามไปที่อื่น คุณเปรียบเทียบ fixed window กับ sliding window และ token bucket แล้วชี้ช่องว่าง boundary-burst ที่เจาะจงซึ่ง fixed window ยอมแลกมาเพื่อความเรียบง่าย นี่คือการปิด Module 6: Redis instance ของ TaskFlow ตอนนี้ทำงานสามอย่างที่แยกกันชัดเจน — เก็บ session (จาก Authentication), cache response (สองบทแรกของ module นี้), และนับ request (บทนี้) — แต่ละอย่างอยู่ใน key namespace ของตัวเอง ไม่มีใครไปเหยียบใคร บทถัดไป Realtime จะให้ Redis ทำงานที่สี่ — pub/sub สำหรับ broadcast การอัปเดต board แบบสดผ่าน WebSocket