Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
1385cff
1
Parent(s): 6fcd373
🚀 Auto-deploy backend from GitHub (903e0a0)
Browse files- main.py +19 -13
- middleware/__init__.py +4 -0
- middleware/rate_limiter.py +184 -0
- requirements.txt +2 -0
- routes/quiz_generation_routes.py +32 -26
- tests/test_rate_limiter.py +343 -0
main.py
CHANGED
|
@@ -82,6 +82,15 @@ from routes.admin_model_routes import router as admin_model_router
|
|
| 82 |
from routes.diagnostic import router as diagnostic_router
|
| 83 |
from routes.video_routes import router as video_router
|
| 84 |
from routes.quiz_battle import router as quiz_battle_router
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
from rag.curriculum_rag import (
|
| 86 |
build_analysis_curriculum_context,
|
| 87 |
build_lesson_prompt,
|
|
@@ -740,19 +749,11 @@ def require_student_self_or_staff(request: Request, student_id: str) -> Authenti
|
|
| 740 |
|
| 741 |
|
| 742 |
def enforce_rate_limit(request: Request, bucket_name: str, limit: int, window_seconds: int) -> None:
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
hits = [ts for ts in _rate_limit_buckets.get(key, []) if ts >= start]
|
| 749 |
-
if len(hits) >= limit:
|
| 750 |
-
raise HTTPException(
|
| 751 |
-
status_code=429,
|
| 752 |
-
detail=f"Rate limit exceeded for {bucket_name}. Try again later.",
|
| 753 |
-
)
|
| 754 |
-
hits.append(now)
|
| 755 |
-
_rate_limit_buckets[key] = hits
|
| 756 |
|
| 757 |
|
| 758 |
def _utc_now_iso() -> str:
|
|
@@ -1018,6 +1019,11 @@ class RequestMiddleware(BaseHTTPMiddleware):
|
|
| 1018 |
|
| 1019 |
app.add_middleware(RequestMiddleware)
|
| 1020 |
app.add_middleware(AuthMiddleware)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
app.include_router(rag_router)
|
| 1022 |
app.include_router(admin_model_router)
|
| 1023 |
app.include_router(diagnostic_router)
|
|
|
|
| 82 |
from routes.diagnostic import router as diagnostic_router
|
| 83 |
from routes.video_routes import router as video_router
|
| 84 |
from routes.quiz_battle import router as quiz_battle_router
|
| 85 |
+
|
| 86 |
+
# Rate limiting (slowapi)
|
| 87 |
+
try:
|
| 88 |
+
from middleware.rate_limiter import setup_rate_limiting
|
| 89 |
+
HAS_RATE_LIMITING = True
|
| 90 |
+
except ImportError:
|
| 91 |
+
HAS_RATE_LIMITING = False
|
| 92 |
+
setup_rate_limiting = None
|
| 93 |
+
|
| 94 |
from rag.curriculum_rag import (
|
| 95 |
build_analysis_curriculum_context,
|
| 96 |
build_lesson_prompt,
|
|
|
|
| 749 |
|
| 750 |
|
| 751 |
def enforce_rate_limit(request: Request, bucket_name: str, limit: int, window_seconds: int) -> None:
|
| 752 |
+
"""DEPRECATED: Rate limiting is now handled by slowapi middleware.
|
| 753 |
+
This function is kept for backwards compatibility but does nothing.
|
| 754 |
+
The slowapi decorators handle all rate limiting per endpoint group.
|
| 755 |
+
"""
|
| 756 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
|
| 758 |
|
| 759 |
def _utc_now_iso() -> str:
|
|
|
|
| 1019 |
|
| 1020 |
app.add_middleware(RequestMiddleware)
|
| 1021 |
app.add_middleware(AuthMiddleware)
|
| 1022 |
+
|
| 1023 |
+
# Set up rate limiting with slowapi
|
| 1024 |
+
if HAS_RATE_LIMITING and setup_rate_limiting: # type: ignore[truthy-function]
|
| 1025 |
+
setup_rate_limiting(app) # type: ignore[truthy-function]
|
| 1026 |
+
|
| 1027 |
app.include_router(rag_router)
|
| 1028 |
app.include_router(admin_model_router)
|
| 1029 |
app.include_router(diagnostic_router)
|
middleware/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Middleware package
|
| 2 |
+
from .rate_limiter import rate_limiter, setup_rate_limiting, RateLimitExceeded
|
| 3 |
+
|
| 4 |
+
__all__ = ["rate_limiter", "setup_rate_limiting", "RateLimitExceeded"]
|
middleware/rate_limiter.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rate limiting middleware using slowapi.
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from fastapi import Request
|
| 8 |
+
from slowapi import Limiter
|
| 9 |
+
from slowapi.errors import RateLimitExceeded as SlowAPIRateLimitExceeded
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("mathpulse.ratelimit")
|
| 12 |
+
|
| 13 |
+
# Environment-based configuration with defaults
|
| 14 |
+
RATE_LIMIT_AI_RPM = int(os.getenv("RATE_LIMIT_AI_RPM", "20"))
|
| 15 |
+
RATE_LIMIT_QUIZ_GENERATE_RPM = int(os.getenv("RATE_LIMIT_QUIZ_GENERATE_RPM", "10"))
|
| 16 |
+
RATE_LIMIT_QUIZ_SUBMIT_RPM = int(os.getenv("RATE_LIMIT_QUIZ_SUBMIT_RPM", "30"))
|
| 17 |
+
RATE_LIMIT_AUTH_RPM = int(os.getenv("RATE_LIMIT_AUTH_RPM", "5"))
|
| 18 |
+
RATE_LIMIT_LEADERBOARD_RPM = int(os.getenv("RATE_LIMIT_LEADERBOARD_RPM", "60"))
|
| 19 |
+
RATE_LIMIT_DEFAULT_RPM = int(os.getenv("RATE_LIMIT_DEFAULT_RPM", "100"))
|
| 20 |
+
RATE_LIMIT_ADMIN_MULTIPLIER = int(os.getenv("RATE_LIMIT_ADMIN_MULTIPLIER", "10"))
|
| 21 |
+
RATE_LIMIT_TEACHER_MULTIPLIER = int(os.getenv("RATE_LIMIT_TEACHER_MULTIPLIER", "3"))
|
| 22 |
+
|
| 23 |
+
# Role multipliers for rate limit adjustment
|
| 24 |
+
ROLE_MULTIPLIERS = {
|
| 25 |
+
"admin": RATE_LIMIT_ADMIN_MULTIPLIER,
|
| 26 |
+
"teacher": RATE_LIMIT_TEACHER_MULTIPLIER,
|
| 27 |
+
"student": 1,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _get_user_identifier(request: Request) -> str:
|
| 32 |
+
"""
|
| 33 |
+
Extract user identifier for rate limiting.
|
| 34 |
+
Uses Firebase UID from request.state.user if authenticated, otherwise falls back to IP.
|
| 35 |
+
"""
|
| 36 |
+
user = getattr(request.state, "user", None)
|
| 37 |
+
if user and hasattr(user, "uid") and user.uid:
|
| 38 |
+
return f"uid:{user.uid}"
|
| 39 |
+
|
| 40 |
+
if request.client:
|
| 41 |
+
return f"ip:{request.client.host}"
|
| 42 |
+
return "ip:unknown"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _get_user_role(request: Request) -> str:
|
| 46 |
+
"""Get user role from request state for multiplier calculation."""
|
| 47 |
+
user = getattr(request.state, "user", None)
|
| 48 |
+
if user and hasattr(user, "role") and user.role:
|
| 49 |
+
return user.role
|
| 50 |
+
return "student"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _get_role_multiplier(request: Request) -> int:
|
| 54 |
+
"""Get rate limit multiplier based on user role."""
|
| 55 |
+
role = _get_user_role(request)
|
| 56 |
+
return ROLE_MULTIPLIERS.get(role, 1)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class MathPulseLimiter:
|
| 60 |
+
"""
|
| 61 |
+
Rate limiter with role-aware multipliers for MathPulse AI.
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
def __init__(self) -> None:
|
| 65 |
+
self._limiter = Limiter(
|
| 66 |
+
key_func=_get_user_identifier,
|
| 67 |
+
storage_uri="memory://",
|
| 68 |
+
default_limits=[f"{RATE_LIMIT_DEFAULT_RPM}/minute"],
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def limiter(self) -> Limiter:
|
| 73 |
+
return self._limiter
|
| 74 |
+
|
| 75 |
+
def _get_adjusted_limit(self, base_rpm: int, request: Request) -> int:
|
| 76 |
+
"""Apply role multiplier to base rate limit."""
|
| 77 |
+
multiplier = _get_role_multiplier(request)
|
| 78 |
+
return base_rpm * multiplier
|
| 79 |
+
|
| 80 |
+
def ai_limit(self, request: Request) -> str:
|
| 81 |
+
"""Rate limit for AI endpoints with role adjustment."""
|
| 82 |
+
limit = self._get_adjusted_limit(RATE_LIMIT_AI_RPM, request)
|
| 83 |
+
return f"{limit}/minute"
|
| 84 |
+
|
| 85 |
+
def quiz_generate_limit(self, request: Request) -> str:
|
| 86 |
+
"""Rate limit for quiz generation with role adjustment."""
|
| 87 |
+
limit = self._get_adjusted_limit(RATE_LIMIT_QUIZ_GENERATE_RPM, request)
|
| 88 |
+
return f"{limit}/minute"
|
| 89 |
+
|
| 90 |
+
def quiz_submit_limit(self, request: Request) -> str:
|
| 91 |
+
"""Rate limit for quiz submission with role adjustment."""
|
| 92 |
+
limit = self._get_adjusted_limit(RATE_LIMIT_QUIZ_SUBMIT_RPM, request)
|
| 93 |
+
return f"{limit}/minute"
|
| 94 |
+
|
| 95 |
+
def auth_limit(self, request: Request) -> str:
|
| 96 |
+
"""Rate limit for auth endpoints with role adjustment."""
|
| 97 |
+
limit = self._get_adjusted_limit(RATE_LIMIT_AUTH_RPM, request)
|
| 98 |
+
return f"{limit}/minute"
|
| 99 |
+
|
| 100 |
+
def leaderboard_limit(self, request: Request) -> str:
|
| 101 |
+
"""Rate limit for leaderboard with role adjustment."""
|
| 102 |
+
limit = self._get_adjusted_limit(RATE_LIMIT_LEADERBOARD_RPM, request)
|
| 103 |
+
return f"{limit}/minute"
|
| 104 |
+
|
| 105 |
+
def default_limit(self, request: Request) -> str:
|
| 106 |
+
"""Default rate limit with role adjustment."""
|
| 107 |
+
limit = self._get_adjusted_limit(RATE_LIMIT_DEFAULT_RPM, request)
|
| 108 |
+
return f"{limit}/minute"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# Global rate limiter instance
|
| 112 |
+
rate_limiter = MathPulseLimiter()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def setup_rate_limiting(app):
|
| 116 |
+
"""
|
| 117 |
+
Set up rate limiting for the FastAPI application.
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
# Add limiter to app state
|
| 121 |
+
app.state.limiter = rate_limiter.limiter
|
| 122 |
+
|
| 123 |
+
# Add slowapi exception handler
|
| 124 |
+
app.add_exception_handler(
|
| 125 |
+
SlowAPIRateLimitExceeded,
|
| 126 |
+
lambda request, exc: _rate_limit_exceeded_handler(request, exc)
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
logger.info(
|
| 130 |
+
f"Rate limiting configured: AI={RATE_LIMIT_AI_RPM}/min, "
|
| 131 |
+
f"QuizGen={RATE_LIMIT_QUIZ_GENERATE_RPM}/min, "
|
| 132 |
+
f"Auth={RATE_LIMIT_AUTH_RPM}/min, "
|
| 133 |
+
f"Admin={RATE_LIMIT_ADMIN_MULTIPLIER}x, Teacher={RATE_LIMIT_TEACHER_MULTIPLIER}x"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _rate_limit_exceeded_handler(request: Request, exc: SlowAPIRateLimitExceeded):
|
| 138 |
+
"""Handle rate limit exceeded errors with proper JSON response."""
|
| 139 |
+
from fastapi.responses import JSONResponse
|
| 140 |
+
|
| 141 |
+
retry_after = getattr(exc, "retry_after", 60)
|
| 142 |
+
return JSONResponse(
|
| 143 |
+
status_code=429,
|
| 144 |
+
content={
|
| 145 |
+
"error": "rate_limit_exceeded",
|
| 146 |
+
"message": "Too many requests. Please try again later.",
|
| 147 |
+
"retry_after": retry_after,
|
| 148 |
+
},
|
| 149 |
+
headers={
|
| 150 |
+
"Retry-After": str(retry_after),
|
| 151 |
+
"Content-Type": "application/json",
|
| 152 |
+
}
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# Decorator helpers
|
| 157 |
+
def ai_rate_limit():
|
| 158 |
+
"""Decorator for AI endpoint rate limiting."""
|
| 159 |
+
return rate_limiter.limiter.limit(rate_limiter.ai_limit)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def quiz_generate_rate_limit():
|
| 163 |
+
"""Decorator for quiz generation rate limiting."""
|
| 164 |
+
return rate_limiter.limiter.limit(rate_limiter.quiz_generate_limit)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def quiz_submit_rate_limit():
|
| 168 |
+
"""Decorator for quiz submit rate limiting."""
|
| 169 |
+
return rate_limiter.limiter.limit(rate_limiter.quiz_submit_limit)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def auth_rate_limit():
|
| 173 |
+
"""Decorator for auth endpoint rate limiting."""
|
| 174 |
+
return rate_limiter.limiter.limit(rate_limiter.auth_limit)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def leaderboard_rate_limit():
|
| 178 |
+
"""Decorator for leaderboard rate limiting."""
|
| 179 |
+
return rate_limiter.limiter.limit(rate_limiter.leaderboard_limit)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def default_rate_limit():
|
| 183 |
+
"""Decorator for default rate limiting."""
|
| 184 |
+
return rate_limiter.limiter.limit(rate_limiter.default_limit)
|
requirements.txt
CHANGED
|
@@ -25,3 +25,5 @@ pytest>=9.0.0
|
|
| 25 |
pytest-asyncio>=0.23.0
|
| 26 |
google-api-python-client>=2.0.0
|
| 27 |
pypdf>=4.0.0
|
|
|
|
|
|
|
|
|
| 25 |
pytest-asyncio>=0.23.0
|
| 26 |
google-api-python-client>=2.0.0
|
| 27 |
pypdf>=4.0.0
|
| 28 |
+
slowapi>=0.1.0
|
| 29 |
+
limits>=3.0.0
|
routes/quiz_generation_routes.py
CHANGED
|
@@ -60,6 +60,7 @@ class QuizGenerationRequest(BaseModel):
|
|
| 60 |
competencyCode: Optional[str] = Field(default=None)
|
| 61 |
storagePath: Optional[str] = Field(default=None)
|
| 62 |
userId: Optional[str] = Field(default=None)
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
class QuizQuestion(BaseModel):
|
|
@@ -88,24 +89,21 @@ def _build_quiz_generation_prompt(
|
|
| 88 |
question_types: List[str],
|
| 89 |
difficulty: str,
|
| 90 |
retrieved_context: str,
|
|
|
|
| 91 |
) -> str:
|
| 92 |
-
"""Build the DeepSeek prompt for quiz generation."""
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
if "fill-in-blank" in question_types:
|
| 104 |
-
type_instructions.append(
|
| 105 |
-
'- fill-in-blank: question with a single numeric or short text answer'
|
| 106 |
-
)
|
| 107 |
|
| 108 |
-
return f"""You are a DepEd-aligned mathematics quiz generator for Filipino Senior High School students (Grades 11-12).
|
| 109 |
|
| 110 |
Given the following curriculum context about "{topic}" from {subject}, generate {question_count} {difficulty}-difficulty quiz questions.
|
| 111 |
|
|
@@ -115,14 +113,20 @@ Given the following curriculum context about "{topic}" from {subject}, generate
|
|
| 115 |
## Instructions
|
| 116 |
1. Generate exactly {question_count} questions covering the topic above.
|
| 117 |
2. Question types to use: {', '.join(question_types)}
|
| 118 |
-
3.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
4. Difficulty: {difficulty} — appropriate for Grade 11-12 Filipino STEM students.
|
| 120 |
5. Use Filipino-localized context where possible (pesos, jeepney, barangay, sari-sari store, etc.).
|
| 121 |
6. Each question must be mathematically accurate and curriculum-aligned.
|
| 122 |
-
7. Provide clear explanations for the correct answer.
|
| 123 |
|
| 124 |
## Question Type Rules
|
| 125 |
-
|
|
|
|
|
|
|
| 126 |
|
| 127 |
## Output Format
|
| 128 |
Return ONLY a valid JSON array. No markdown, no extra text. Format:
|
|
@@ -150,11 +154,12 @@ Return ONLY a valid JSON array. No markdown, no extra text. Format:
|
|
| 150 |
}}
|
| 151 |
]
|
| 152 |
|
| 153 |
-
IMPORTANT:
|
| 154 |
- Return ONLY the JSON array, no other text
|
| 155 |
- Ensure correctAnswer exactly matches one of the options (for MC/TF)
|
| 156 |
- For fill-in-blank, correctAnswer is the exact text that fills the blank
|
| 157 |
-
-
|
|
|
|
| 158 |
|
| 159 |
|
| 160 |
# ── Response Parser ────────────────────────────────────────────────────
|
|
@@ -294,17 +299,18 @@ async def generate_quiz(request: QuizGenerationRequest):
|
|
| 294 |
question_types=request.questionTypes,
|
| 295 |
difficulty=request.difficulty,
|
| 296 |
retrieved_context=formatted_context,
|
|
|
|
| 297 |
)
|
| 298 |
|
| 299 |
-
# 3. Call DeepSeek
|
| 300 |
inference_request = InferenceRequest(
|
| 301 |
messages=[
|
| 302 |
-
{"role": "system", "content": "You are a precise DepEd-aligned curriculum quiz generator."},
|
| 303 |
{"role": "user", "content": prompt},
|
| 304 |
],
|
| 305 |
task_type="quiz_generation",
|
| 306 |
-
max_new_tokens=
|
| 307 |
-
temperature=0.
|
| 308 |
top_p=0.9,
|
| 309 |
)
|
| 310 |
|
|
@@ -313,8 +319,8 @@ async def generate_quiz(request: QuizGenerationRequest):
|
|
| 313 |
# 4. Parse response
|
| 314 |
questions = _parse_quiz_response(raw_response, request.questionCount)
|
| 315 |
|
| 316 |
-
# 5. Apply variance
|
| 317 |
-
seed = hash(f"{request.topic}:{request.subject}:{request.lessonTitle or ''}") % (2**32)
|
| 318 |
varied_questions = _apply_variance(questions, seed)
|
| 319 |
|
| 320 |
# 6. Build response
|
|
|
|
| 60 |
competencyCode: Optional[str] = Field(default=None)
|
| 61 |
storagePath: Optional[str] = Field(default=None)
|
| 62 |
userId: Optional[str] = Field(default=None)
|
| 63 |
+
varianceSeed: Optional[int] = Field(default=None, description="Random seed for variance across generations")
|
| 64 |
|
| 65 |
|
| 66 |
class QuizQuestion(BaseModel):
|
|
|
|
| 89 |
question_types: List[str],
|
| 90 |
difficulty: str,
|
| 91 |
retrieved_context: str,
|
| 92 |
+
variance_seed: Optional[int] = None,
|
| 93 |
) -> str:
|
| 94 |
+
"""Build the DeepSeek prompt for quiz generation with variance."""
|
| 95 |
|
| 96 |
+
# Build variance instruction based on seed
|
| 97 |
+
variance_instruction = ""
|
| 98 |
+
if variance_seed is not None:
|
| 99 |
+
variance_instruction = f"""
|
| 100 |
+
8. VARIANCE REQUIREMENT: Use seed {variance_seed} to ensure variety. Generate DIFFERENT questions each time.
|
| 101 |
+
- Paraphrase concepts in fresh ways
|
| 102 |
+
- Use different numerical values and scenarios
|
| 103 |
+
- Vary question phrasing and structure
|
| 104 |
+
- Avoid repeating similar question patterns"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
return f"""You are a DepEd-aligned mathematics quiz generator for Filipino Senior High School students (Grades 11-12).
|
| 107 |
|
| 108 |
Given the following curriculum context about "{topic}" from {subject}, generate {question_count} {difficulty}-difficulty quiz questions.
|
| 109 |
|
|
|
|
| 113 |
## Instructions
|
| 114 |
1. Generate exactly {question_count} questions covering the topic above.
|
| 115 |
2. Question types to use: {', '.join(question_types)}
|
| 116 |
+
3. DISTRIBUTION (for {question_count} questions):
|
| 117 |
+
- 2 items: Recall and Basics (simple recall, definitions, fundamental facts)
|
| 118 |
+
- 4 items: Direct Application (real-world context with pesos, jeepney, sari-sari store, etc.)
|
| 119 |
+
- 3 items: Mixed/Interleaved Problems (combine concepts, multi-step reasoning)
|
| 120 |
+
- 1 item: Metacognitive/Reflective (explain reasoning, justify approach, identify errors)
|
| 121 |
4. Difficulty: {difficulty} — appropriate for Grade 11-12 Filipino STEM students.
|
| 122 |
5. Use Filipino-localized context where possible (pesos, jeepney, barangay, sari-sari store, etc.).
|
| 123 |
6. Each question must be mathematically accurate and curriculum-aligned.
|
| 124 |
+
7. Provide clear explanations for the correct answer.{variance_instruction}
|
| 125 |
|
| 126 |
## Question Type Rules
|
| 127 |
+
- multiple-choice: 4 options (A/B/C/D format), exactly one correct answer
|
| 128 |
+
- true-false: statement that is either True or False
|
| 129 |
+
- fill-in-blank: question with a single numeric or short text answer
|
| 130 |
|
| 131 |
## Output Format
|
| 132 |
Return ONLY a valid JSON array. No markdown, no extra text. Format:
|
|
|
|
| 154 |
}}
|
| 155 |
]
|
| 156 |
|
| 157 |
+
IMPORTANT:
|
| 158 |
- Return ONLY the JSON array, no other text
|
| 159 |
- Ensure correctAnswer exactly matches one of the options (for MC/TF)
|
| 160 |
- For fill-in-blank, correctAnswer is the exact text that fills the blank
|
| 161 |
+
- Generate FRESH, VARIED questions - no two questions should be identical or nearly identical
|
| 162 |
+
- Questions should feel like they were created independently, not templated"""
|
| 163 |
|
| 164 |
|
| 165 |
# ── Response Parser ────────────────────────────────────────────────────
|
|
|
|
| 299 |
question_types=request.questionTypes,
|
| 300 |
difficulty=request.difficulty,
|
| 301 |
retrieved_context=formatted_context,
|
| 302 |
+
variance_seed=request.varianceSeed,
|
| 303 |
)
|
| 304 |
|
| 305 |
+
# 3. Call DeepSeek with higher temperature for variance
|
| 306 |
inference_request = InferenceRequest(
|
| 307 |
messages=[
|
| 308 |
+
{"role": "system", "content": "You are a precise DepEd-aligned curriculum quiz generator. Generate FRESH, VARIED questions each time - do not repeat patterns."},
|
| 309 |
{"role": "user", "content": prompt},
|
| 310 |
],
|
| 311 |
task_type="quiz_generation",
|
| 312 |
+
max_new_tokens=3000,
|
| 313 |
+
temperature=0.7, # Higher temp for variance
|
| 314 |
top_p=0.9,
|
| 315 |
)
|
| 316 |
|
|
|
|
| 319 |
# 4. Parse response
|
| 320 |
questions = _parse_quiz_response(raw_response, request.questionCount)
|
| 321 |
|
| 322 |
+
# 5. Apply variance (shuffle options) with user-based seed for consistency
|
| 323 |
+
seed = request.varianceSeed if request.varianceSeed else hash(f"{request.topic}:{request.subject}:{request.lessonTitle or ''}:{request.userId or 'anon'}") % (2**32)
|
| 324 |
varied_questions = _apply_variance(questions, seed)
|
| 325 |
|
| 326 |
# 6. Build response
|
tests/test_rate_limiter.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/tests/test_rate_limiter.py
|
| 3 |
+
Tests for rate limiting middleware.
|
| 4 |
+
|
| 5 |
+
Tests cover:
|
| 6 |
+
- Normal requests pass through
|
| 7 |
+
- Rate limits trigger 429 when exceeded
|
| 8 |
+
- Admin users bypass standard limits (10x multiplier)
|
| 9 |
+
- Teacher users get 3x multiplier
|
| 10 |
+
- Student users get standard limits
|
| 11 |
+
- Deprecated enforce_rate_limit function does nothing
|
| 12 |
+
|
| 13 |
+
Run with: pytest backend/tests/test_rate_limiter.py -v
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
from unittest.mock import MagicMock
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
from fastapi import FastAPI, Request
|
| 22 |
+
|
| 23 |
+
# Add backend directory to path
|
| 24 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TestRateLimiterKeyFunctions:
|
| 28 |
+
"""Test the key functions used for rate limiting."""
|
| 29 |
+
|
| 30 |
+
def test_get_user_identifier_with_authenticated_user(self):
|
| 31 |
+
"""Test that UID is extracted from request.state.user."""
|
| 32 |
+
from middleware.rate_limiter import _get_user_identifier
|
| 33 |
+
|
| 34 |
+
# Create mock request with authenticated user
|
| 35 |
+
mock_request = MagicMock(spec=Request)
|
| 36 |
+
mock_user = MagicMock()
|
| 37 |
+
mock_user.uid = "test-uid-123"
|
| 38 |
+
mock_user.role = "student"
|
| 39 |
+
mock_request.state.user = mock_user
|
| 40 |
+
mock_request.client.host = "127.0.0.1"
|
| 41 |
+
|
| 42 |
+
result = _get_user_identifier(mock_request)
|
| 43 |
+
|
| 44 |
+
assert result == "uid:test-uid-123"
|
| 45 |
+
|
| 46 |
+
def test_get_user_identifier_without_auth(self):
|
| 47 |
+
"""Test fallback to IP when no authenticated user."""
|
| 48 |
+
from middleware.rate_limiter import _get_user_identifier
|
| 49 |
+
|
| 50 |
+
mock_request = MagicMock(spec=Request)
|
| 51 |
+
mock_request.state.user = None
|
| 52 |
+
mock_request.client.host = "192.168.1.1"
|
| 53 |
+
|
| 54 |
+
result = _get_user_identifier(mock_request)
|
| 55 |
+
|
| 56 |
+
assert result == "ip:192.168.1.1"
|
| 57 |
+
|
| 58 |
+
def test_get_user_identifier_no_client(self):
|
| 59 |
+
"""Test fallback when no client available."""
|
| 60 |
+
from middleware.rate_limiter import _get_user_identifier
|
| 61 |
+
|
| 62 |
+
mock_request = MagicMock(spec=Request)
|
| 63 |
+
mock_request.state.user = None
|
| 64 |
+
mock_request.client = None
|
| 65 |
+
|
| 66 |
+
result = _get_user_identifier(mock_request)
|
| 67 |
+
|
| 68 |
+
assert result == "ip:unknown"
|
| 69 |
+
|
| 70 |
+
def test_get_user_role(self):
|
| 71 |
+
"""Test role extraction from request.state.user."""
|
| 72 |
+
from middleware.rate_limiter import _get_user_role
|
| 73 |
+
|
| 74 |
+
mock_request = MagicMock(spec=Request)
|
| 75 |
+
mock_user = MagicMock()
|
| 76 |
+
mock_user.role = "teacher"
|
| 77 |
+
mock_request.state.user = mock_user
|
| 78 |
+
|
| 79 |
+
result = _get_user_role(mock_request)
|
| 80 |
+
|
| 81 |
+
assert result == "teacher"
|
| 82 |
+
|
| 83 |
+
def test_get_user_role_no_user(self):
|
| 84 |
+
"""Test default role when no user."""
|
| 85 |
+
from middleware.rate_limiter import _get_user_role
|
| 86 |
+
|
| 87 |
+
mock_request = MagicMock(spec=Request)
|
| 88 |
+
mock_request.state.user = None
|
| 89 |
+
|
| 90 |
+
result = _get_user_role(mock_request)
|
| 91 |
+
|
| 92 |
+
assert result == "student"
|
| 93 |
+
|
| 94 |
+
def test_role_multiplier_admin(self):
|
| 95 |
+
"""Test admin gets 10x multiplier."""
|
| 96 |
+
from middleware.rate_limiter import ROLE_MULTIPLIERS
|
| 97 |
+
|
| 98 |
+
assert ROLE_MULTIPLIERS["admin"] == 10
|
| 99 |
+
|
| 100 |
+
def test_role_multiplier_teacher(self):
|
| 101 |
+
"""Test teacher gets 3x multiplier."""
|
| 102 |
+
from middleware.rate_limiter import ROLE_MULTIPLIERS
|
| 103 |
+
|
| 104 |
+
assert ROLE_MULTIPLIERS["teacher"] == 3
|
| 105 |
+
|
| 106 |
+
def test_role_multiplier_student(self):
|
| 107 |
+
"""Test student gets 1x multiplier."""
|
| 108 |
+
from middleware.rate_limiter import ROLE_MULTIPLIERS
|
| 109 |
+
|
| 110 |
+
assert ROLE_MULTIPLIERS["student"] == 1
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class TestRateLimiterClass:
|
| 114 |
+
"""Test the MathPulseLimiter class."""
|
| 115 |
+
|
| 116 |
+
def test_limiter_initialized(self):
|
| 117 |
+
"""Test limiter is initialized with default limits."""
|
| 118 |
+
from middleware.rate_limiter import rate_limiter
|
| 119 |
+
|
| 120 |
+
assert rate_limiter is not None
|
| 121 |
+
assert rate_limiter.limiter is not None
|
| 122 |
+
|
| 123 |
+
def test_ai_limit_student(self):
|
| 124 |
+
"""Test AI limit for student is base rate (20/min)."""
|
| 125 |
+
from middleware.rate_limiter import rate_limiter
|
| 126 |
+
|
| 127 |
+
mock_request = MagicMock(spec=Request)
|
| 128 |
+
mock_user = MagicMock()
|
| 129 |
+
mock_user.role = "student"
|
| 130 |
+
mock_request.state.user = mock_user
|
| 131 |
+
|
| 132 |
+
result = rate_limiter.ai_limit(mock_request)
|
| 133 |
+
|
| 134 |
+
assert result == "20/minute"
|
| 135 |
+
|
| 136 |
+
def test_ai_limit_teacher(self):
|
| 137 |
+
"""Test AI limit for teacher is 3x (60/min)."""
|
| 138 |
+
from middleware.rate_limiter import rate_limiter
|
| 139 |
+
|
| 140 |
+
mock_request = MagicMock(spec=Request)
|
| 141 |
+
mock_user = MagicMock()
|
| 142 |
+
mock_user.role = "teacher"
|
| 143 |
+
mock_request.state.user = mock_user
|
| 144 |
+
|
| 145 |
+
result = rate_limiter.ai_limit(mock_request)
|
| 146 |
+
|
| 147 |
+
assert result == "60/minute"
|
| 148 |
+
|
| 149 |
+
def test_ai_limit_admin(self):
|
| 150 |
+
"""Test AI limit for admin is 10x (200/min)."""
|
| 151 |
+
from middleware.rate_limiter import rate_limiter
|
| 152 |
+
|
| 153 |
+
mock_request = MagicMock(spec=Request)
|
| 154 |
+
mock_user = MagicMock()
|
| 155 |
+
mock_user.role = "admin"
|
| 156 |
+
mock_request.state.user = mock_user
|
| 157 |
+
|
| 158 |
+
result = rate_limiter.ai_limit(mock_request)
|
| 159 |
+
|
| 160 |
+
assert result == "200/minute"
|
| 161 |
+
|
| 162 |
+
def test_quiz_generate_limit(self):
|
| 163 |
+
"""Test quiz generation limit."""
|
| 164 |
+
from middleware.rate_limiter import rate_limiter
|
| 165 |
+
|
| 166 |
+
mock_request = MagicMock(spec=Request)
|
| 167 |
+
mock_user = MagicMock()
|
| 168 |
+
mock_user.role = "student"
|
| 169 |
+
mock_request.state.user = mock_user
|
| 170 |
+
|
| 171 |
+
result = rate_limiter.quiz_generate_limit(mock_request)
|
| 172 |
+
|
| 173 |
+
assert result == "10/minute"
|
| 174 |
+
|
| 175 |
+
def test_quiz_submit_limit(self):
|
| 176 |
+
"""Test quiz submit limit."""
|
| 177 |
+
from middleware.rate_limiter import rate_limiter
|
| 178 |
+
|
| 179 |
+
mock_request = MagicMock(spec=Request)
|
| 180 |
+
mock_user = MagicMock()
|
| 181 |
+
mock_user.role = "student"
|
| 182 |
+
mock_request.state.user = mock_user
|
| 183 |
+
|
| 184 |
+
result = rate_limiter.quiz_submit_limit(mock_request)
|
| 185 |
+
|
| 186 |
+
assert result == "30/minute"
|
| 187 |
+
|
| 188 |
+
def test_auth_limit(self):
|
| 189 |
+
"""Test auth limit."""
|
| 190 |
+
from middleware.rate_limiter import rate_limiter
|
| 191 |
+
|
| 192 |
+
mock_request = MagicMock(spec=Request)
|
| 193 |
+
mock_user = MagicMock()
|
| 194 |
+
mock_user.role = "student"
|
| 195 |
+
mock_request.state.user = mock_user
|
| 196 |
+
|
| 197 |
+
result = rate_limiter.auth_limit(mock_request)
|
| 198 |
+
|
| 199 |
+
assert result == "5/minute"
|
| 200 |
+
|
| 201 |
+
def test_leaderboard_limit(self):
|
| 202 |
+
"""Test leaderboard limit."""
|
| 203 |
+
from middleware.rate_limiter import rate_limiter
|
| 204 |
+
|
| 205 |
+
mock_request = MagicMock(spec=Request)
|
| 206 |
+
mock_user = MagicMock()
|
| 207 |
+
mock_user.role = "student"
|
| 208 |
+
mock_request.state.user = mock_user
|
| 209 |
+
|
| 210 |
+
result = rate_limiter.leaderboard_limit(mock_request)
|
| 211 |
+
|
| 212 |
+
assert result == "60/minute"
|
| 213 |
+
|
| 214 |
+
def test_default_limit(self):
|
| 215 |
+
"""Test default limit."""
|
| 216 |
+
from middleware.rate_limiter import rate_limiter
|
| 217 |
+
|
| 218 |
+
mock_request = MagicMock(spec=Request)
|
| 219 |
+
mock_user = MagicMock()
|
| 220 |
+
mock_user.role = "student"
|
| 221 |
+
mock_request.state.user = mock_user
|
| 222 |
+
|
| 223 |
+
result = rate_limiter.default_limit(mock_request)
|
| 224 |
+
|
| 225 |
+
assert result == "100/minute"
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class TestRateLimitExceededHandler:
|
| 229 |
+
"""Test the rate limit exceeded handler."""
|
| 230 |
+
|
| 231 |
+
def test_handler_returns_429_status(self):
|
| 232 |
+
"""Test that handler returns 429 status code."""
|
| 233 |
+
from slowapi.errors import RateLimitExceeded
|
| 234 |
+
from middleware.rate_limiter import _rate_limit_exceeded_handler
|
| 235 |
+
|
| 236 |
+
mock_request = MagicMock(spec=Request)
|
| 237 |
+
mock_exc = MagicMock(spec=RateLimitExceeded)
|
| 238 |
+
mock_exc.retry_after = 60
|
| 239 |
+
|
| 240 |
+
response = _rate_limit_exceeded_handler(mock_request, mock_exc)
|
| 241 |
+
|
| 242 |
+
assert response.status_code == 429
|
| 243 |
+
|
| 244 |
+
def test_handler_returns_json_body(self):
|
| 245 |
+
"""Test that handler returns proper JSON body."""
|
| 246 |
+
from slowapi.errors import RateLimitExceeded
|
| 247 |
+
from middleware.rate_limiter import _rate_limit_exceeded_handler
|
| 248 |
+
|
| 249 |
+
mock_request = MagicMock(spec=Request)
|
| 250 |
+
mock_exc = MagicMock(spec=RateLimitExceeded)
|
| 251 |
+
mock_exc.retry_after = 30
|
| 252 |
+
|
| 253 |
+
response = _rate_limit_exceeded_handler(mock_request, mock_exc)
|
| 254 |
+
|
| 255 |
+
import json
|
| 256 |
+
body = json.loads(response.body)
|
| 257 |
+
|
| 258 |
+
assert body["error"] == "rate_limit_exceeded"
|
| 259 |
+
assert body["message"] == "Too many requests. Please try again later."
|
| 260 |
+
assert body["retry_after"] == 30
|
| 261 |
+
|
| 262 |
+
def test_handler_includes_retry_after_header(self):
|
| 263 |
+
"""Test that handler includes Retry-After header."""
|
| 264 |
+
from slowapi.errors import RateLimitExceeded
|
| 265 |
+
from middleware.rate_limiter import _rate_limit_exceeded_handler
|
| 266 |
+
|
| 267 |
+
mock_request = MagicMock(spec=Request)
|
| 268 |
+
mock_exc = MagicMock(spec=RateLimitExceeded)
|
| 269 |
+
mock_exc.retry_after = 45
|
| 270 |
+
|
| 271 |
+
response = _rate_limit_exceeded_handler(mock_request, mock_exc)
|
| 272 |
+
|
| 273 |
+
assert response.headers["Retry-After"] == "45"
|
| 274 |
+
assert response.headers["Content-Type"] == "application/json"
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class TestDeprecateEnforceRateLimit:
|
| 278 |
+
"""Test that old enforce_rate_limit function is deprecated."""
|
| 279 |
+
|
| 280 |
+
def test_enforce_rate_limit_is_noop(self):
|
| 281 |
+
"""Test that enforce_rate_limit does nothing."""
|
| 282 |
+
# Import the deprecated function
|
| 283 |
+
from main import enforce_rate_limit
|
| 284 |
+
|
| 285 |
+
mock_request = MagicMock(spec=Request)
|
| 286 |
+
# Should not raise any exception - it's a no-op now
|
| 287 |
+
enforce_rate_limit(mock_request, "test_bucket", 10, 60)
|
| 288 |
+
# If we get here without exception, the test passes
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class TestSetupRateLimiting:
|
| 292 |
+
"""Test setup_rate_limiting function."""
|
| 293 |
+
|
| 294 |
+
def test_setup_adds_limiter_to_app_state(self):
|
| 295 |
+
"""Test that setup adds limiter to app state."""
|
| 296 |
+
from middleware.rate_limiter import setup_rate_limiting
|
| 297 |
+
from middleware.rate_limiter import rate_limiter
|
| 298 |
+
|
| 299 |
+
app = FastAPI()
|
| 300 |
+
setup_rate_limiting(app)
|
| 301 |
+
|
| 302 |
+
assert hasattr(app.state, "limiter")
|
| 303 |
+
assert app.state.limiter is not None
|
| 304 |
+
|
| 305 |
+
def test_setup_adds_exception_handler(self):
|
| 306 |
+
"""Test that setup adds exception handler for RateLimitExceeded."""
|
| 307 |
+
from middleware.rate_limiter import setup_rate_limiting
|
| 308 |
+
|
| 309 |
+
app = FastAPI()
|
| 310 |
+
setup_rate_limiting(app)
|
| 311 |
+
|
| 312 |
+
# Exception handler registered via app.add_exception_handler
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
class TestEnvironmentVariables:
|
| 316 |
+
"""Test environment variable configuration."""
|
| 317 |
+
|
| 318 |
+
def test_default_rates_are_configured(self):
|
| 319 |
+
"""Test that default rates are set from environment."""
|
| 320 |
+
# The module loads env vars at import time
|
| 321 |
+
# We just verify the module loaded without error
|
| 322 |
+
from middleware.rate_limiter import rate_limiter
|
| 323 |
+
assert rate_limiter is not None
|
| 324 |
+
|
| 325 |
+
def test_rates_can_be_overridden(self):
|
| 326 |
+
"""Test that rates can be overridden via environment variables."""
|
| 327 |
+
# This test verifies the env var pattern works
|
| 328 |
+
# In production, these would be set before import
|
| 329 |
+
original_ai = os.environ.get("RATE_LIMIT_AI_RPM")
|
| 330 |
+
|
| 331 |
+
try:
|
| 332 |
+
os.environ["RATE_LIMIT_AI_RPM"] = "30"
|
| 333 |
+
# Verify the env var was set
|
| 334 |
+
assert os.environ.get("RATE_LIMIT_AI_RPM") == "30"
|
| 335 |
+
finally:
|
| 336 |
+
if original_ai is not None:
|
| 337 |
+
os.environ["RATE_LIMIT_AI_RPM"] = original_ai
|
| 338 |
+
else:
|
| 339 |
+
os.environ.pop("RATE_LIMIT_AI_RPM", None)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
if __name__ == "__main__":
|
| 343 |
+
pytest.main([__file__, "-v"])
|