Danh sách bài viết

Bài 52: Rate Limiting — bảo vệ API khỏi abuse

Rate limiting bảo vệ AI API khỏi abuse và kiểm soát chi phí LLM. Bài này trình bày 4 thuật toán phổ biến, cài đặt slowapi với FastAPI, Redis backend cho multi-instance, tier-based limit, token-based limit cho LLM, HTTP 429 response chuẩn, nginx rate limit, xử lý provider-side limit, và các common pitfalls cần tránh trong production.

27/05/2026
0 lượt xem
1

Mục tiêu bài học

Sau bài này bạn sẽ:

  • ✅ Phân biệt được 4 thuật toán rate limiting và khi nào dùng cái nào
  • ✅ Cài slowapi vào FastAPI, dùng Redis backend cho multi-instance
  • ✅ Tự implement token bucket với Redis + asyncio
  • ✅ Thiết kế tier-based limit và token-based limit cho LLM
  • ✅ Trả HTTP 429 response đúng headers chuẩn
  • ✅ Biết giới hạn của từng approach và common pitfalls
2

Vì sao cần rate limiting với AI API

API thông thường không rate limit vẫn sống được (vì request rẻ). AI API thì khác — mỗi LLM call tốn tiền thật. Các lý do cụ thể:

Abuse prevention

Bot scrape API, script spam request, DDoS. Không có limit thì toàn bộ budget đi hết trong vài phút. Với AI inference endpoint, 1 bad actor có thể tiêu $500 OpenAI credit trước khi bạn phát hiện.

Cost control

User hợp lệ nhưng vô tình spam — ví dụ frontend gọi lại liên tục do bug. Mỗi LLM request gpt-4o tốn ~$0.005-0.015 tùy token count. 10,000 request/giờ = $50-150 chỉ từ 1 user. Rate limit cắt điều này sớm.

Fair use giữa nhiều user

Khi nhiều user share cùng resource (cùng API key OpenAI, cùng GPU), không có limit thì 1 user heavy có thể chiếm toàn bộ capacity, làm user khác timeout.

SLA enforcement theo tier

Free tier: 10 req/phút. Pro tier: 100 req/phút. Enterprise: 1000 req/phút. Đây là business logic cần enforce programmatically, không thể dựa vào user tự giác.

Bảo vệ trước provider-side limit

OpenAI, Anthropic đều có rate limit downstream (ví dụ 10,000 RPM với tier 1). Nếu để user gọi thẳng, bạn có thể vượt limit của provider và toàn bộ service bị 429 từ OpenAI. Limit user trước để tổng không bao giờ vượt quota của provider.

3

4 thuật toán rate limiting

Fixed Window

Đếm số request trong cửa sổ cố định. Mỗi đầu cửa sổ reset counter về 0.

Window 60s: [request 1..100] → reset
                                              [request 1..100] → reset

Vấn đề: Burst tại biên cửa sổ. User gửi 100 request cuối giây 59, rồi 100 request đầu giây 60 = 200 request trong vòng 2 giây, nhưng cả hai window đều hợp lệ.

Ưu điểm: Đơn giản nhất, implement bằng INCR + EXPIRE trong Redis.

Sliding Window

Cửa sổ trượt theo thời gian thực, không reset tại mốc cố định. Mỗi lúc đếm, nhìn ngược lại đúng N giây.

Thời điểm T: đếm request từ [T-60s, T]
T+1s:        đếm request từ [T-59s, T+1s]  ← cửa sổ trượt

Ưu điểm: Không có burst tại biên.

Implementation: Lưu timestamp của từng request trong Redis sorted set, query theo range. Tốn memory hơn fixed window.

Token Bucket

Bucket có sức chứa tối đa N token. Token được nạp với tốc độ R token/giây. Mỗi request tiêu thụ 1 (hoặc nhiều hơn) token. Khi bucket rỗng → từ chối request.

Capacity: 50 token
Refill:   10 token/giây
Trạng thái: [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■] 50
Burst 30 req: [■■■■■■■■■■■■■■■■■■■■] 20 còn lại
Chờ 3s → nạp thêm 30 → [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■] 50

Đặc tính: Cho phép burst tối đa N request sau đó throttle về tốc độ R. Phổ biến nhất — AWS API Gateway, Stripe, Cloudflare đều dùng.

Leaky Bucket

Request đến → vào queue (capacity N). Queue "rò rỉ" (leak) ra ngoài với rate R cố định. Queue đầy → từ chối.

Request vào →  [■■■■■■■■■■] queue capacity 10
                              ↓ R request/giây (rate cố định)
                           processed

Đặc tính: Output rate hoàn toàn smooth, không có burst. Phù hợp khi downstream không chịu được spike (ví dụ limit chặt từ LLM provider). Nhược điểm: Request trong queue có thể phải đợi lâu.

So sánh nhanh

Thuật toán Burst Smooth output Độ phức tạp Use case
Fixed Window Có (tại biên) Không Thấp Đơn giản, prototype
Sliding Window Không Không Trung bình Cần chính xác cao
Token Bucket Có (đến N) Không Trung bình API phổ biến, cho phép burst
Leaky Bucket Không Trung bình Khi cần output rate tuyệt đối đều
4

slowapi với FastAPI

slowapi (v0.1.9+) là wrapper của limits library cho FastAPI, port từ Flask-Limiter. Dùng sliding window hoặc fixed window tùy backend.

Cài đặt

pip install slowapi
# Redis backend (cần cho multi-instance):
pip install redis

Setup cơ bản — limit theo IP

from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

app = FastAPI()

# key_func xác định "ai" đang bị limit — mặc định là IP
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)


@app.post("/predict")
@limiter.limit("10/minute")
async def predict(request: Request, body: dict):
    # request: Request phải có trong signature dù không dùng trực tiếp
    return {"result": "..."}

Quan trọng: Tham số request: Request phải có trong signature của endpoint được decorate. slowapi đọc metadata từ request object để đếm.

Các format limit string hợp lệ

"10/second"     # 10 request mỗi giây
"100/minute"    # 100 request mỗi phút
"1000/hour"     # 1000 request mỗi giờ
"5000/day"      # 5000 request mỗi ngày
"10 per second" # format thay thế, cùng kết quả

Áp dụng global limit

# Limit áp dụng cho toàn bộ app, không cần decorator từng route
limiter = Limiter(
    key_func=get_remote_address,
    default_limits=["200/minute"],  # mặc định cho mọi route
)

# Route muốn override:
@app.post("/chat")
@limiter.limit("20/minute")  # ghi đè default
async def chat(request: Request, body: dict):
    ...

# Route muốn exempt khỏi limit:
@app.get("/health")
@limiter.exempt
async def health():
    return {"status": "ok"}

Behavior khi vượt limit

_rate_limit_exceeded_handler mặc định trả HTTP 429 với body:

{"error": "Rate limit exceeded: 10 per 1 minute"}

Để tùy chỉnh response format:

from fastapi import Request
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded

async def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
    return JSONResponse(
        status_code=429,
        content={
            "error": "rate_limit_exceeded",
            "message": f"Quota exceeded. Retry after {exc.retry_after} seconds.",
            "retry_after": exc.retry_after,
        },
        headers={"Retry-After": str(exc.retry_after)},
    )

app.add_exception_handler(RateLimitExceeded, custom_rate_limit_handler)
5

Redis backend cho multi-instance

Mặc định slowapi dùng in-memory storage — chỉ hoạt động đúng với 1 process. Khi deploy nhiều instance (Kubernetes, multiple workers), mỗi instance có counter riêng. User bị limit N req/phút nhân số instance.

Cấu hình Redis backend

limiter = Limiter(
    key_func=get_remote_address,
    storage_uri="redis://localhost:6379",
    # Production có auth:
    # storage_uri="redis://:password@redis-host:6379/0",
)

Redis Sentinel / Cluster

# Redis Sentinel (HA)
limiter = Limiter(
    key_func=get_remote_address,
    storage_uri="redis+sentinel://sentinel1:26379,sentinel2:26379/mymaster",
)

Kiểm tra Redis connection khi startup

from contextlib import asynccontextmanager
import redis.asyncio as aioredis

redis_client: aioredis.Redis = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global redis_client
    redis_client = aioredis.from_url("redis://localhost:6379")
    try:
        await redis_client.ping()
    except Exception as e:
        raise RuntimeError(f"Cannot connect to Redis: {e}")
    yield
    await redis_client.aclose()

app = FastAPI(lifespan=lifespan)

Lưu ý: Khi Redis unavailable, slowapi mặc định fail open (cho phép request) để không block toàn bộ service. Behavior này configurable nhưng fail open thường đúng hơn fail close cho production.

6

Limit theo user / API key

Limit theo IP không đủ khi user dùng VPN hoặc nhiều user share 1 IP (công ty, university). Giải pháp đúng là limit theo user ID hoặc API key.

key_func đọc API key từ header

def get_api_key(request: Request) -> str:
    api_key = request.headers.get("X-API-Key")
    if not api_key:
        # Fallback về IP nếu không có API key
        # Hoặc raise để force authentication
        return get_remote_address(request)
    return api_key


limiter = Limiter(
    key_func=get_api_key,
    storage_uri="redis://localhost:6379",
)


@app.post("/chat")
@limiter.limit("100/hour")
async def chat(request: Request, body: dict):
    ...

key_func đọc user_id từ JWT

from jose import jwt, JWTError

SECRET_KEY = "..."  # Trong thực tế đọc từ env

def get_user_id_from_token(request: Request) -> str:
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return get_remote_address(request)
    token = auth.removeprefix("Bearer ")
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        return payload.get("sub", get_remote_address(request))
    except JWTError:
        return get_remote_address(request)


limiter = Limiter(
    key_func=get_user_id_from_token,
    storage_uri="redis://localhost:6379",
)
7

Tier-based limit (free / pro / enterprise)

slowapi hỗ trợ dynamic limit string — limit có thể thay đổi theo user thay vì hardcode một giá trị.

from fastapi import Request
from slowapi import Limiter
from slowapi.util import get_remote_address

# Giả sử có hàm lookup user từ DB/cache
async def get_user_tier(user_id: str) -> str:
    # Thực tế: query DB hoặc Redis cache
    user = await db.users.find_one({"_id": user_id})
    return user["tier"] if user else "free"


def get_user_id(request: Request) -> str:
    return request.headers.get("X-API-Key", get_remote_address(request))


def dynamic_limit(key: str) -> str:
    """
    dynamic_limit nhận key (kết quả của key_func) không phải Request.
    Phải sync nếu dùng trực tiếp với slowapi.
    Cách an toàn: cache tier trong Redis, lookup sync.
    """
    import redis as sync_redis
    r = sync_redis.from_url("redis://localhost:6379")
    tier = r.get(f"user_tier:{key}")
    tier = tier.decode() if tier else "free"

    limits = {
        "free": "10/minute",
        "pro": "100/minute",
        "enterprise": "1000/minute",
    }
    return limits.get(tier, "10/minute")


limiter = Limiter(
    key_func=get_user_id,
    storage_uri="redis://localhost:6379",
)


@app.post("/chat")
@limiter.limit(dynamic_limit)
async def chat(request: Request, body: dict):
    ...

Cập nhật tier trong Redis khi user upgrade

@app.post("/admin/users/{user_id}/upgrade")
async def upgrade_user(user_id: str, new_tier: str):
    await redis_client.set(
        f"user_tier:{user_id}",
        new_tier,
        ex=86400,  # 24h TTL, refresh từ DB định kỳ
    )
    return {"upgraded": True}

Cách khác: dùng limits.storage.RedisStorage trực tiếp và viết middleware custom thay vì slowapi decorator — linh hoạt hơn nhưng code nhiều hơn.

8

Token bucket tự implement với Redis

Khi cần control chi tiết hơn slowapi cho phép, tự implement token bucket. Ví dụ dưới đây dùng redis.asyncio, phù hợp với FastAPI async.

Implementation đơn giản (có race condition)

import time
import redis.asyncio as aioredis

redis_client: aioredis.Redis = None  # khởi tạo trong lifespan


async def check_token_bucket(
    key: str,
    capacity: int,        # tối đa N token
    refill_per_sec: float # nạp R token mỗi giây
) -> bool:
    """
    Trả True nếu request được phép, False nếu bị rate limit.
    """
    redis_key = f"ratelimit:bucket:{key}"
    now = time.time()

    # Đọc trạng thái hiện tại
    async with redis_client.pipeline() as pipe:
        pipe.hget(redis_key, "tokens")
        pipe.hget(redis_key, "last_refill")
        tokens_str, last_refill_str = await pipe.execute()

    tokens = float(tokens_str) if tokens_str else float(capacity)
    last_refill = float(last_refill_str) if last_refill_str else now

    # Tính token được nạp thêm từ lần cuối
    elapsed = now - last_refill
    tokens = min(float(capacity), tokens + elapsed * refill_per_sec)

    if tokens < 1.0:
        return False  # bucket rỗng, từ chối

    tokens -= 1.0

    # Ghi lại trạng thái
    async with redis_client.pipeline() as pipe:
        pipe.hset(redis_key, mapping={"tokens": tokens, "last_refill": now})
        pipe.expire(redis_key, 3600)  # tự xóa sau 1h không dùng
        await pipe.execute()

    return True

Dùng trong endpoint

from fastapi import HTTPException

@app.post("/generate")
async def generate(request: Request, body: dict):
    api_key = request.headers.get("X-API-Key", request.client.host)

    allowed = await check_token_bucket(
        key=api_key,
        capacity=50,          # burst tối đa 50
        refill_per_sec=10.0,  # 10 req/giây trung bình
    )
    if not allowed:
        raise HTTPException(
            status_code=429,
            detail="Rate limit exceeded",
            headers={"Retry-After": "1"},
        )

    # ... xử lý request

Vấn đề race condition và Lua script

Implementation trên có race condition: hai request đồng thời có thể đọc cùng một giá trị tokens trước khi cái nào ghi. Trong production, dùng Lua script để đảm bảo atomicity:

TOKEN_BUCKET_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * refill_rate)

if tokens < 1 then
    return 0
end

tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 1
"""

# Đăng ký script khi startup
token_bucket_sha = None

async def init_lua_script():
    global token_bucket_sha
    token_bucket_sha = await redis_client.script_load(TOKEN_BUCKET_SCRIPT)


async def check_token_bucket_atomic(key: str, capacity: int, refill_per_sec: float) -> bool:
    now = time.time()
    result = await redis_client.evalsha(
        token_bucket_sha,
        1,  # số KEYS
        f"ratelimit:bucket:{key}",
        capacity,
        refill_per_sec,
        now,
    )
    return bool(result)

Lua script chạy atomic trong Redis — không có race condition dù nhiều process gọi đồng thời.

9

HTTP 429 response chuẩn

RFC 6585 định nghĩa status code 429 và các headers khuyến nghị. Client cần headers này để retry đúng cách.

Headers chuẩn

Header Ý nghĩa Ví dụ
X-RateLimit-Limit Quota tổng trong window 100
X-RateLimit-Remaining Số request còn lại 0
X-RateLimit-Reset Unix timestamp khi quota reset 1716825600
Retry-After Số giây client nên chờ trước khi retry 30

Trả headers trên mọi response (không chỉ 429)

Client cần biết remaining quota trước khi bị limit để điều chỉnh tốc độ gửi. Thêm headers vào mọi response:

from fastapi import Response

@app.post("/chat")
async def chat(request: Request, response: Response, body: dict):
    api_key = request.headers.get("X-API-Key", request.client.host)

    # Lấy trạng thái bucket hiện tại
    bucket_info = await get_bucket_info(api_key)

    response.headers["X-RateLimit-Limit"] = str(bucket_info["capacity"])
    response.headers["X-RateLimit-Remaining"] = str(int(bucket_info["tokens"]))
    response.headers["X-RateLimit-Reset"] = str(bucket_info["reset_at"])

    if bucket_info["tokens"] < 1:
        response.headers["Retry-After"] = str(bucket_info["retry_after"])
        raise HTTPException(status_code=429, detail="Rate limit exceeded")

    # ... xử lý

Response body chuẩn khi 429

{
  "error": "rate_limit_exceeded",
  "message": "You have exceeded the rate limit of 100 requests per minute.",
  "limit": 100,
  "window": "60s",
  "retry_after": 30
}
10

Nginx rate limit ở reverse proxy

Rate limit ở tầng application (FastAPI) xử lý sau khi request đã vào process. Limit ở nginx xử lý trước — rẻ hơn nhiều, không tốn CPU của Python process.

Cấu hình nginx

http {
    # Định nghĩa zone: lưu state cho 10MB, rate 10 req/giây theo IP
    limit_req_zone $binary_remote_addr zone=api_zone:10m rate=10r/s;

    server {
        listen 80;

        location /api/ {
            # burst=20: cho phép burst 20 req, nodelay: không delay burst
            limit_req zone=api_zone burst=20 nodelay;

            # Trả 429 thay vì default 503
            limit_req_status 429;

            proxy_pass http://backend:8000;
        }

        # Endpoint không cần limit:
        location /health {
            proxy_pass http://backend:8000;
        }
    }
}

Giải thích tham số

  • rate=10r/s: tốc độ tối đa 10 request/giây (leaky bucket algorithm bên trong nginx).
  • burst=20: cho phép queue 20 request vượt rate. Khi queue đầy → từ chối.
  • nodelay: xử lý burst ngay lập tức thay vì delay từng request xuống 10r/s. Không có nodelay thì burst sẽ được delay dần → user thấy latency cao.
  • $binary_remote_addr: key theo IP dạng binary (compact hơn string).

Trade-off của nginx rate limit

  • Ưu điểm: Không cần Redis, không tốn app process, bảo vệ trước cả khi app overload.
  • Nhược điểm: Chỉ limit theo IP, không biết user_id hay API key. Nhiều user sau 1 reverse proxy hay load balancer có thể bị limit oan. VPN users thường share IP.

Trong practice, kết hợp cả hai: nginx limit theo IP để chặn bot/DDoS, app limit theo user_id để enforce business rule.

11

Cloud-managed rate limiting

Khi không muốn tự quản lý Redis và nginx config, có thể dùng managed solutions:

Service Granularity Đặc điểm
AWS API Gateway Per API key, per stage Tích hợp với IAM, usage plan. Rate + burst limit.
GCP Cloud Endpoints Per consumer Tích hợp với GCP IAM và API key service.
Cloudflare Rate Limiting Per IP, per user agent Edge-level, gần user nhất. Mạnh cho DDoS. Có thể limit theo request body nếu dùng Workers.
Kong (open source) Per consumer, per route API gateway, nhiều plugin. Self-hosted.
Tyk (open source) Per key, per policy Tương tự Kong, có dashboard.

AWS API Gateway + usage plan là lựa chọn phổ biến khi đã deploy trên AWS. Không cần thêm infrastructure, rate limit + quota tự động trả 429 với Retry-After.

12

AI-specific limit — token-based và cost-based

Request count không đủ cho AI API. 1 request 10 token và 1 request 100,000 token có chi phí chênh nhau 10,000 lần. Giải pháp: limit theo token count hoặc dollar cost.

Token-based limit

import datetime
import tiktoken  # pip install tiktoken

encoder = tiktoken.encoding_for_model("gpt-4o")

TOKEN_LIMIT_PER_HOUR = {
    "free": 50_000,
    "pro": 500_000,
    "enterprise": 5_000_000,
}


async def check_token_limit(user_id: str, tier: str, estimated_tokens: int) -> bool:
    now = datetime.datetime.utcnow()
    hour_key = now.strftime("%Y%m%d%H")
    redis_key = f"token_usage:{user_id}:{hour_key}"

    current_usage = await redis_client.get(redis_key)
    current_usage = int(current_usage) if current_usage else 0

    limit = TOKEN_LIMIT_PER_HOUR.get(tier, TOKEN_LIMIT_PER_HOUR["free"])

    if current_usage + estimated_tokens > limit:
        return False
    return True


async def record_token_usage(user_id: str, actual_tokens: int) -> None:
    now = datetime.datetime.utcnow()
    hour_key = now.strftime("%Y%m%d%H")
    redis_key = f"token_usage:{user_id}:{hour_key}"

    await redis_client.incrby(redis_key, actual_tokens)
    await redis_client.expire(redis_key, 7200)  # TTL 2h


@app.post("/chat")
async def chat(request: Request, body: dict):
    user_id = request.headers.get("X-API-Key", request.client.host)
    tier = await get_user_tier(user_id)

    # Estimate token trước khi gọi LLM
    messages = body.get("messages", [])
    prompt_text = " ".join(m["content"] for m in messages)
    estimated = len(encoder.encode(prompt_text)) + 500  # buffer cho completion

    if not await check_token_limit(user_id, tier, estimated):
        raise HTTPException(
            status_code=429,
            detail="Hourly token limit exceeded",
            headers={"Retry-After": str(3600 - datetime.datetime.utcnow().minute * 60)},
        )

    # Gọi LLM
    response = await call_llm(messages)
    actual_tokens = response.usage.total_tokens

    # Ghi actual usage sau khi có kết quả
    await record_token_usage(user_id, actual_tokens)

    return {"content": response.choices[0].message.content}

Cost-based limit

Tương tự nhưng track bằng đơn vị dollar thay vì token. Hữu ích khi mix nhiều model có giá khác nhau:

MODEL_COST_PER_1K_TOKENS = {
    "gpt-4o": 0.005,          # $5/1M input tokens
    "gpt-4o-mini": 0.00015,   # $0.15/1M input tokens
    "claude-3-5-sonnet": 0.003,
}

async def record_cost(user_id: str, model: str, tokens: int) -> float:
    cost = (tokens / 1000) * MODEL_COST_PER_1K_TOKENS.get(model, 0.005)
    today = datetime.datetime.utcnow().strftime("%Y%m%d")
    redis_key = f"cost_usage:{user_id}:{today}"

    # Lưu dạng float nguyên cent để tránh float precision issue
    cost_cents = int(cost * 100)
    await redis_client.incrby(redis_key, cost_cents)
    await redis_client.expire(redis_key, 172800)  # 2 ngày

    return cost
13

Concurrent request limit

Rate limit đếm request theo thời gian (req/phút). Concurrent limit đếm số request đang xử lý cùng lúc. Hai loại này bổ sung nhau.

Use case: LLM streaming response mất 10-30 giây. Một user mở 100 connection đồng thời → 100 connection giữ 100 slot thread/goroutine, block user khác. Rate limit không bắt được điều này vì đó là 100 request khác nhau.

Semaphore per user với Redis

MAX_CONCURRENT = {"free": 2, "pro": 10, "enterprise": 50}


async def acquire_concurrent_slot(user_id: str, tier: str) -> bool:
    """Trả True nếu còn slot, False nếu đã đủ concurrent."""
    max_slots = MAX_CONCURRENT.get(tier, 2)
    redis_key = f"concurrent:{user_id}"

    # Atomic increment + check
    current = await redis_client.incr(redis_key)
    if current == 1:
        # Key mới tạo, set TTL phòng crash không release
        await redis_client.expire(redis_key, 120)

    if current > max_slots:
        await redis_client.decr(redis_key)
        return False
    return True


async def release_concurrent_slot(user_id: str) -> None:
    redis_key = f"concurrent:{user_id}"
    count = await redis_client.decr(redis_key)
    if count <= 0:
        await redis_client.delete(redis_key)


@app.post("/stream-chat")
async def stream_chat(request: Request, body: dict):
    user_id = request.headers.get("X-API-Key", request.client.host)
    tier = await get_user_tier(user_id)

    if not await acquire_concurrent_slot(user_id, tier):
        raise HTTPException(
            status_code=429,
            detail=f"Too many concurrent requests. Max {MAX_CONCURRENT[tier]}.",
        )

    try:
        # ... streaming LLM call
        pass
    finally:
        # PHẢI release trong finally để không bị leak khi exception
        await release_concurrent_slot(user_id)
14

Xử lý provider-side rate limit (OpenAI 429)

Dù đã limit phía user, vẫn có thể nhận 429 từ OpenAI/Anthropic do nhiều nguyên nhân: traffic spike đồng thời, tier RPM thấp, hoặc provider tạm thời giảm limit. Cần retry với exponential backoff.

from tenacity import (
    retry,
    retry_if_exception_type,
    wait_exponential,
    stop_after_attempt,
    before_sleep_log,
)
import openai
import logging

logger = logging.getLogger(__name__)


@retry(
    retry=retry_if_exception_type(openai.RateLimitError),
    wait=wait_exponential(multiplier=1, min=4, max=60),
    stop=stop_after_attempt(5),
    before_sleep=before_sleep_log(logger, logging.WARNING),
)
async def call_openai_with_retry(messages: list) -> openai.types.chat.ChatCompletion:
    client = openai.AsyncOpenAI()
    return await client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
    )

Giải thích tham số wait_exponential:

  • multiplier=1: hệ số nhân.
  • min=4: chờ tối thiểu 4 giây lần đầu.
  • max=60: chờ tối đa 60 giây.
  • Sequence: 4s → 8s → 16s → 32s → 60s.

Đọc Retry-After từ response header của provider

OpenAI trả Retry-After trong header khi 429. tenacity không tự đọc header này. Nếu cần chính xác hơn:

import asyncio

async def call_openai_respecting_header(messages: list):
    client = openai.AsyncOpenAI()
    for attempt in range(5):
        try:
            return await client.chat.completions.create(
                model="gpt-4o",
                messages=messages,
            )
        except openai.RateLimitError as e:
            if attempt == 4:
                raise
            retry_after = int(e.response.headers.get("Retry-After", 4))
            await asyncio.sleep(retry_after)
15

Monitoring rate limit

Rate limit event cần được track để phân biệt: user bị limit hợp lệ (heavy use) hay đang bị tấn công, hay limit của bạn quá chặt.

Prometheus metrics

from prometheus_client import Counter, Histogram

rate_limit_hits = Counter(
    "ratelimit_hits_total",
    "Number of requests rejected by rate limiter",
    labelnames=["user_tier", "endpoint", "limit_type"],
)

rate_limit_remaining = Histogram(
    "ratelimit_remaining_tokens",
    "Remaining tokens in rate limit bucket at request time",
    labelnames=["user_tier"],
    buckets=[0, 1, 5, 10, 25, 50, 100, 250, 500],
)


# Trong exception handler hoặc middleware:
async def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
    tier = request.state.user_tier if hasattr(request.state, "user_tier") else "unknown"
    rate_limit_hits.labels(
        user_tier=tier,
        endpoint=request.url.path,
        limit_type="request_count",
    ).inc()
    return JSONResponse(status_code=429, content={"error": "rate_limit_exceeded"})

Alerts cần thiết

  • High 429 rate: >10% request trả 429 trong 5 phút → có thể đang bị attack hoặc limit quá chặt.
  • Spike từ 1 user_id: 1 user tạo ra >50% tổng 429 → suspicious, có thể là bot.
  • Provider 429: Nhận 429 từ OpenAI/Anthropic → cần review tier hoặc reduce traffic.

Grafana dashboard

Metrics hữu ích để visualize:

  • rate(ratelimit_hits_total[5m]) — rate limit hit per second.
  • ratelimit_hits_total group by user_tier — xem tier nào bị nhiều nhất.
  • histogram_quantile(0.95, ratelimit_remaining_tokens) — P95 remaining tokens cho thấy buffer còn bao nhiêu.
16

Common pitfalls

1. In-memory limit khi chạy nhiều instance

Triệu chứng: user gửi 100 req/phút nhưng bị limit ở 10 req/phút trên mỗi instance. Với 5 instance, limit thực tế là 50 req/phút. Giải pháp: luôn dùng Redis backend khi deploy hơn 1 process.

2. Rate limit chỉ ở app, không ở proxy

Traffic đến load balancer, được phân phối đều. Nếu 1 instance crash, traffic dồn sang instance còn lại — instance đó chưa biết limit cũ. Redis backend giải quyết vì state tập trung.

3. Quên handle 429 ở client

Frontend nhận 429 nhưng không đọc Retry-After, retry ngay lập tức → vòng lặp 429. Cần implement exponential backoff ở client khi nhận 429.

4. Limit theo IP với nhiều user share 1 IP

Office, university, VPN — nhiều user share 1 IP. Limit theo IP sẽ limit tất cả cùng lúc. Giải pháp: luôn limit theo user_id/API key ở application layer; IP limit chỉ dùng ở nginx layer để chặn bot.

5. Token estimate không chính xác

tiktoken estimate tốt với OpenAI models nhưng không chính xác 100% với special tokens, formatting, v.v. Nên add buffer 10-20% và reconcile sau khi có actual usage từ response. Có thể dẫn đến over-limit (cho phép request nhưng thực tế vượt quota) hoặc under-limit (reject request hợp lệ).

6. Concurrent slot không được release khi exception

Nếu không dùng try/finally trong concurrent limit implementation, exception làm slot bị chiếm mãi mãi (cho đến khi TTL expire). Luôn release trong finally.

7. Limit tập trung ở tầng quá thấp hoặc quá cao

Chỉ nginx limit → không phân biệt được user, không enforce business tier. Chỉ app limit → tốn process, không chặn được DDoS. Tốt nhất là cả hai: nginx chặn IP abuse ở edge, app enforce business rule theo user_id.