diff --git a/src/kernelbot/api/api_utils.py b/src/kernelbot/api/api_utils.py index 034f64c5..b94cba36 100644 --- a/src/kernelbot/api/api_utils.py +++ b/src/kernelbot/api/api_utils.py @@ -146,7 +146,7 @@ async def _run_submission( submission: SubmissionRequest, mode: SubmissionMode, backend: KernelBackend ): try: - req = prepare_submission(submission, backend) + req = prepare_submission(submission, backend, mode) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/src/kernelbot/api/main.py b/src/kernelbot/api/main.py index 260fb09b..071a8219 100644 --- a/src/kernelbot/api/main.py +++ b/src/kernelbot/api/main.py @@ -478,8 +478,9 @@ async def enqueue_background_job( file_name=req.file_name, code=req.code, user_id=req.user_id, - time=datetime.datetime.now(), + time=datetime.datetime.now(datetime.timezone.utc), user_name=req.user_name, + mode_category=req.mode_category, ) job_id = db.upsert_submission_job_status(sub_id, "initial", None) # put submission request in queue @@ -523,8 +524,10 @@ async def run_submission_async( user_info, submission_mode, file, leaderboard_name, gpu_type, db_context ) - req = prepare_submission(submission_request, backend_instance) + req = prepare_submission(submission_request, backend_instance, submission_mode_enum) + except KernelBotError as e: + raise HTTPException(status_code=e.http_code, detail=str(e)) from e except Exception as e: raise HTTPException( status_code=400, detail=f"failed to prepare submission request: {str(e)}" @@ -1041,6 +1044,58 @@ async def admin_set_visibility( return {"status": "ok", "leaderboard": leaderboard_name, "visibility": visibility} +@app.put("/admin/leaderboards/{leaderboard_name}/rate-limits") +async def admin_set_rate_limit( + leaderboard_name: str, + payload: dict, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + """Create or update a rate limit for a leaderboard.""" + mode_category = payload.get("mode_category") + if mode_category not in ("test", "leaderboard"): + raise HTTPException(status_code=400, detail="mode_category must be 'test' or 'leaderboard'") + max_per_hour = payload.get("max_submissions_per_hour") + if not isinstance(max_per_hour, int) or max_per_hour < 1: + raise HTTPException(status_code=400, detail="max_submissions_per_hour must be a positive integer") + try: + with db_context as db: + result = db.set_rate_limit(leaderboard_name, mode_category, max_per_hour) + return {"status": "ok", "rate_limit": dict(result)} + except KernelBotError as e: + raise HTTPException(status_code=e.http_code, detail=str(e)) from e + + +@app.get("/admin/leaderboards/{leaderboard_name}/rate-limits") +async def admin_get_rate_limits( + leaderboard_name: str, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + """List rate limits for a leaderboard.""" + with db_context as db: + limits = db.get_rate_limits(leaderboard_name) + return {"status": "ok", "leaderboard": leaderboard_name, "rate_limits": [dict(r) for r in limits]} + + +@app.delete("/admin/leaderboards/{leaderboard_name}/rate-limits/{mode_category}") +async def admin_delete_rate_limit( + leaderboard_name: str, + mode_category: str, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + """Delete a rate limit for a leaderboard.""" + if mode_category not in ("test", "leaderboard"): + raise HTTPException(status_code=400, detail="mode_category must be 'test' or 'leaderboard'") + try: + with db_context as db: + db.delete_rate_limit(leaderboard_name, mode_category) + return {"status": "ok", "leaderboard": leaderboard_name, "mode_category": mode_category} + except KernelBotError as e: + raise HTTPException(status_code=e.http_code, detail=str(e)) from e + + @app.post("/user/join") async def user_join_leaderboard( payload: dict, diff --git a/src/kernelbot/cogs/leaderboard_cog.py b/src/kernelbot/cogs/leaderboard_cog.py index bab62b44..01d5c583 100644 --- a/src/kernelbot/cogs/leaderboard_cog.py +++ b/src/kernelbot/cogs/leaderboard_cog.py @@ -126,7 +126,7 @@ async def submit( leaderboard=leaderboard_name, identity_type="discord", ) - req = prepare_submission(req, self.bot.backend) + req = prepare_submission(req, self.bot.backend, mode) if req.gpus is None: view = await self.select_gpu_view(interaction, leaderboard_name, req.task_gpus) diff --git a/src/libkernelbot/backend.py b/src/libkernelbot/backend.py index f3b68bb0..5da3ae6b 100644 --- a/src/libkernelbot/backend.py +++ b/src/libkernelbot/backend.py @@ -1,10 +1,10 @@ import asyncio import copy -from datetime import datetime +import datetime from types import SimpleNamespace from typing import Optional -from libkernelbot.consts import GPU, GPU_TO_SM, SubmissionMode, get_gpu_by_name +from libkernelbot.consts import GPU, GPU_TO_SM, SubmissionMode, get_gpu_by_name, get_mode_category from libkernelbot.launchers import Launcher from libkernelbot.leaderboard_db import LeaderboardDB from libkernelbot.report import ( @@ -67,8 +67,9 @@ async def submit_full( file_name=req.file_name, code=req.code, user_id=req.user_id, - time=datetime.now(), + time=datetime.datetime.now(datetime.timezone.utc), user_name=req.user_name, + mode_category=req.mode_category or get_mode_category(mode), ) selected_gpus = [get_gpu_by_name(gpu) for gpu in req.gpus] try: diff --git a/src/libkernelbot/consts.py b/src/libkernelbot/consts.py index 0685331d..55113e76 100644 --- a/src/libkernelbot/consts.py +++ b/src/libkernelbot/consts.py @@ -101,6 +101,13 @@ class SubmissionMode(Enum): PRIVATE = "private" +def get_mode_category(mode: "SubmissionMode") -> str: + """Map a SubmissionMode to its rate limit category ('test' or 'leaderboard').""" + if mode == SubmissionMode.LEADERBOARD: + return "leaderboard" + return "test" + + class Language(Enum): Python = "py" CUDA = "cu" diff --git a/src/libkernelbot/db_types.py b/src/libkernelbot/db_types.py index 75db1ffb..ee1e84a3 100644 --- a/src/libkernelbot/db_types.py +++ b/src/libkernelbot/db_types.py @@ -62,4 +62,12 @@ class SubmissionItem(TypedDict): runs: List[RunItem] -__all__ = [LeaderboardItem, LeaderboardRankedEntry, RunItem, SubmissionItem] +class RateLimitItem(TypedDict): + id: int + leaderboard_id: int + leaderboard_name: str + mode_category: str + max_submissions_per_hour: int + + +__all__ = [LeaderboardItem, LeaderboardRankedEntry, RunItem, SubmissionItem, RateLimitItem] diff --git a/src/libkernelbot/leaderboard_db.py b/src/libkernelbot/leaderboard_db.py index 5a64bfbb..c59bc271 100644 --- a/src/libkernelbot/leaderboard_db.py +++ b/src/libkernelbot/leaderboard_db.py @@ -9,6 +9,7 @@ IdentityType, LeaderboardItem, LeaderboardRankedEntry, + RateLimitItem, RunItem, SubmissionItem, ) @@ -276,8 +277,13 @@ def create_submission( code: str, time: datetime.datetime, user_name: str = None, + mode_category: str = None, ) -> Optional[int]: try: + if time.tzinfo is None: + time = time.astimezone() + time = time.astimezone(datetime.timezone.utc) + # check if we already have the code self.cursor.execute( """ @@ -323,10 +329,10 @@ def create_submission( self.cursor.execute( """ INSERT INTO leaderboard.submission (leaderboard_id, file_name, - user_id, code_id, submission_time) + user_id, code_id, submission_time, mode_category) VALUES ( (SELECT id FROM leaderboard.leaderboard WHERE name = %s), - %s, %s, %s, %s) + %s, %s, %s, %s, %s) RETURNING id """, ( @@ -335,6 +341,7 @@ def create_submission( user_id, code_id, time, + mode_category, ), ) submission_id = self.cursor.fetchone()[0] @@ -1438,6 +1445,154 @@ def validate_cli_id(self, cli_id: str) -> Optional[dict[str, str]]: raise KernelBotError("Error validating CLI ID") from e + def set_rate_limit(self, leaderboard_name: str, mode_category: str, max_per_hour: int) -> RateLimitItem: + try: + self.cursor.execute( + """ + INSERT INTO leaderboard.rate_limit (leaderboard_id, mode_category, max_submissions_per_hour) + VALUES ( + (SELECT id FROM leaderboard.leaderboard WHERE name = %s), + %s, %s + ) + ON CONFLICT (leaderboard_id, mode_category) + DO UPDATE SET max_submissions_per_hour = EXCLUDED.max_submissions_per_hour + RETURNING id, leaderboard_id, mode_category, max_submissions_per_hour + """, + (leaderboard_name, mode_category, max_per_hour), + ) + row = self.cursor.fetchone() + if row is None: + raise LeaderboardDoesNotExist(leaderboard_name) + self.connection.commit() + return RateLimitItem( + id=row[0], + leaderboard_id=row[1], + leaderboard_name=leaderboard_name, + mode_category=row[2], + max_submissions_per_hour=row[3], + ) + except psycopg2.Error as e: + self.connection.rollback() + if "null value in column" in str(e): + raise LeaderboardDoesNotExist(leaderboard_name) from e + logger.exception("Error setting rate limit", exc_info=e) + raise KernelBotError("Error setting rate limit") from e + + def get_rate_limits(self, leaderboard_name: str) -> List[RateLimitItem]: + try: + self.cursor.execute( + """ + SELECT rl.id, rl.leaderboard_id, rl.mode_category, rl.max_submissions_per_hour + FROM leaderboard.rate_limit rl + JOIN leaderboard.leaderboard lb ON rl.leaderboard_id = lb.id + WHERE lb.name = %s + """, + (leaderboard_name,), + ) + return [ + RateLimitItem( + id=row[0], + leaderboard_id=row[1], + leaderboard_name=leaderboard_name, + mode_category=row[2], + max_submissions_per_hour=row[3], + ) + for row in self.cursor.fetchall() + ] + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error getting rate limits", exc_info=e) + raise KernelBotError("Error getting rate limits") from e + + def delete_rate_limit(self, leaderboard_name: str, mode_category: str) -> None: + try: + self.cursor.execute( + """ + DELETE FROM leaderboard.rate_limit + WHERE leaderboard_id = (SELECT id FROM leaderboard.leaderboard WHERE name = %s) + AND mode_category = %s + """, + (leaderboard_name, mode_category), + ) + if self.cursor.rowcount == 0: + raise KernelBotError( + f"No rate limit found for '{leaderboard_name}' with category '{mode_category}'", + code=404, + ) + self.connection.commit() + except KernelBotError: + raise + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error deleting rate limit", exc_info=e) + raise KernelBotError("Error deleting rate limit") from e + + def check_rate_limit( + self, leaderboard_name: str, user_id: str, mode_category: str + ) -> Optional[dict]: + """Check if a user has exceeded the rate limit for a leaderboard+category. + + Returns None if no rate limit is configured, otherwise returns a dict with: + - allowed: bool + - current_count: int + - max_per_hour: int + - retry_after_seconds: int (0 if allowed) + """ + try: + # Get the rate limit config + self.cursor.execute( + """ + SELECT rl.max_submissions_per_hour + FROM leaderboard.rate_limit rl + JOIN leaderboard.leaderboard lb ON rl.leaderboard_id = lb.id + WHERE lb.name = %s AND rl.mode_category = %s + """, + (leaderboard_name, mode_category), + ) + row = self.cursor.fetchone() + if row is None: + return None + + max_per_hour = row[0] + + # Count submissions in the last hour + self.cursor.execute( + """ + SELECT COUNT(*), MIN(s.submission_time) + FROM leaderboard.submission s + JOIN leaderboard.leaderboard lb ON s.leaderboard_id = lb.id + WHERE lb.name = %s + AND s.user_id = %s + AND s.mode_category = %s + AND s.submission_time > NOW() - INTERVAL '1 hour' + """, + (leaderboard_name, user_id, mode_category), + ) + count_row = self.cursor.fetchone() + current_count = count_row[0] + oldest_time = count_row[1] + + allowed = current_count < max_per_hour + retry_after = 0 + if not allowed and oldest_time is not None: + import datetime as dt + + expiry = oldest_time + dt.timedelta(hours=1) + now = dt.datetime.now(dt.timezone.utc) + retry_after = max(0, int((expiry - now).total_seconds())) + + return { + "allowed": allowed, + "current_count": current_count, + "max_per_hour": max_per_hour, + "retry_after_seconds": retry_after, + } + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error checking rate limit", exc_info=e) + raise KernelBotError("Error checking rate limit") from e + + class LeaderboardDoesNotExist(KernelBotError): def __init__(self, name: str): super().__init__(message=f"Leaderboard `{name}` does not exist.", code=404) diff --git a/src/libkernelbot/submission.py b/src/libkernelbot/submission.py index 6fd8e417..60ecf4e6 100644 --- a/src/libkernelbot/submission.py +++ b/src/libkernelbot/submission.py @@ -7,7 +7,7 @@ from better_profanity import profanity -from libkernelbot.consts import RankCriterion +from libkernelbot.consts import RankCriterion, SubmissionMode, get_mode_category from libkernelbot.db_types import RunItem, SubmissionItem from libkernelbot.leaderboard_db import LeaderboardDB, LeaderboardItem from libkernelbot.run_eval import FullResult @@ -38,10 +38,11 @@ class ProcessedSubmissionRequest(SubmissionRequest): task: LeaderboardTask = None secret_seed: int = None task_gpus: list = None + mode_category: str = None def prepare_submission( # noqa: C901 - req: SubmissionRequest, backend: "KernelBackend" + req: SubmissionRequest, backend: "KernelBackend", mode: SubmissionMode = None ) -> ProcessedSubmissionRequest: if not backend.accepts_jobs: raise KernelBotError( @@ -70,6 +71,19 @@ def prepare_submission( # noqa: C901 ) if not db.check_leaderboard_access(req.leaderboard, str(req.user_id)): raise KernelBotError("You do not have access to this leaderboard", code=403) + + mode_category = get_mode_category(mode) if mode else None + if mode_category is not None: + with backend.db as db: + rate_check = db.check_rate_limit(req.leaderboard, str(req.user_id), mode_category) + if rate_check and not rate_check["allowed"]: + raise KernelBotError( + f"Rate limit exceeded: {rate_check['current_count']}/{rate_check['max_per_hour']} " + f"{mode_category} submissions per hour. " + f"Try again in {rate_check['retry_after_seconds']}s.", + code=429, + ) + check_deadline(leaderboard) task_gpus = get_avail_gpus(req.leaderboard, backend.db) @@ -91,6 +105,7 @@ def prepare_submission( # noqa: C901 task=leaderboard["task"], secret_seed=leaderboard["secret_seed"], task_gpus=task_gpus, + mode_category=mode_category, ) diff --git a/src/migrations/20260317_01_rate-limits.py b/src/migrations/20260317_01_rate-limits.py new file mode 100644 index 00000000..2c0d109a --- /dev/null +++ b/src/migrations/20260317_01_rate-limits.py @@ -0,0 +1,32 @@ +""" +Add per-leaderboard rate limiting configuration and track mode category on submissions. +""" + +from yoyo import step + +__depends__ = {"20260313_01_invite-multi-leaderboard"} + +steps = [ + step( + """ + CREATE TABLE leaderboard.rate_limit ( + id SERIAL PRIMARY KEY, + leaderboard_id INTEGER NOT NULL REFERENCES leaderboard.leaderboard(id) ON DELETE CASCADE, + mode_category TEXT NOT NULL CHECK (mode_category IN ('test', 'leaderboard')), + max_submissions_per_hour INTEGER NOT NULL CHECK (max_submissions_per_hour > 0), + UNIQUE (leaderboard_id, mode_category) + ); + """, + """ + DROP TABLE leaderboard.rate_limit; + """, + ), + step( + """ + ALTER TABLE leaderboard.submission ADD COLUMN mode_category TEXT; + """, + """ + ALTER TABLE leaderboard.submission DROP COLUMN mode_category; + """, + ), +] diff --git a/tests/conftest.py b/tests/conftest.py index 2dbe2f03..9e17d480 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,7 +51,7 @@ def _nuke_contents(db): "TRUNCATE leaderboard.code_files, leaderboard.submission, leaderboard.runs, " "leaderboard.leaderboard, leaderboard.user_info, leaderboard.templates, " "leaderboard.gpu_type, leaderboard.leaderboard_invite_scope, " - "leaderboard.leaderboard_invite RESTART IDENTITY CASCADE" + "leaderboard.leaderboard_invite, leaderboard.rate_limit RESTART IDENTITY CASCADE" ) db.connection.commit() diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 80c14ee0..e3130606 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -717,3 +717,101 @@ def test_export_hf_rejects_active_public_export(self, test_client, mock_backend) assert response.status_code == 400 assert "Cannot export active leaderboards" in response.json()["detail"] + + +class TestAdminRateLimits: + """Test admin rate limit endpoints.""" + + def test_set_rate_limit(self, test_client, mock_backend): + """PUT /admin/leaderboards/{name}/rate-limits creates a rate limit.""" + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.set_rate_limit = MagicMock(return_value={ + "id": 1, + "leaderboard_id": 1, + "leaderboard_name": "test-lb", + "mode_category": "test", + "max_submissions_per_hour": 5, + }) + + response = test_client.put( + "/admin/leaderboards/test-lb/rate-limits", + headers={"Authorization": "Bearer test_token"}, + json={"mode_category": "test", "max_submissions_per_hour": 5}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["rate_limit"]["max_submissions_per_hour"] == 5 + mock_backend.db.set_rate_limit.assert_called_once_with("test-lb", "test", 5) + + def test_set_rate_limit_invalid_category(self, test_client): + """PUT /admin/leaderboards/{name}/rate-limits rejects invalid category.""" + response = test_client.put( + "/admin/leaderboards/test-lb/rate-limits", + headers={"Authorization": "Bearer test_token"}, + json={"mode_category": "invalid", "max_submissions_per_hour": 5}, + ) + assert response.status_code == 400 + + def test_set_rate_limit_invalid_count(self, test_client): + """PUT /admin/leaderboards/{name}/rate-limits rejects non-positive count.""" + response = test_client.put( + "/admin/leaderboards/test-lb/rate-limits", + headers={"Authorization": "Bearer test_token"}, + json={"mode_category": "test", "max_submissions_per_hour": 0}, + ) + assert response.status_code == 400 + + def test_set_rate_limit_requires_auth(self, test_client): + """PUT /admin/leaderboards/{name}/rate-limits requires auth.""" + response = test_client.put( + "/admin/leaderboards/test-lb/rate-limits", + json={"mode_category": "test", "max_submissions_per_hour": 5}, + ) + assert response.status_code == 401 + + def test_get_rate_limits(self, test_client, mock_backend): + """GET /admin/leaderboards/{name}/rate-limits returns rate limits.""" + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.get_rate_limits = MagicMock(return_value=[ + { + "id": 1, + "leaderboard_id": 1, + "leaderboard_name": "test-lb", + "mode_category": "test", + "max_submissions_per_hour": 5, + } + ]) + + response = test_client.get( + "/admin/leaderboards/test-lb/rate-limits", + headers={"Authorization": "Bearer test_token"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert len(data["rate_limits"]) == 1 + + def test_delete_rate_limit(self, test_client, mock_backend): + """DELETE /admin/leaderboards/{name}/rate-limits/{category} removes a rate limit.""" + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.delete_rate_limit = MagicMock(return_value=None) + + response = test_client.delete( + "/admin/leaderboards/test-lb/rate-limits/test", + headers={"Authorization": "Bearer test_token"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + mock_backend.db.delete_rate_limit.assert_called_once_with("test-lb", "test") + + def test_delete_rate_limit_invalid_category(self, test_client): + """DELETE rejects invalid mode_category.""" + response = test_client.delete( + "/admin/leaderboards/test-lb/rate-limits/invalid", + headers={"Authorization": "Bearer test_token"}, + ) + assert response.status_code == 400 diff --git a/tests/test_leaderboard_db.py b/tests/test_leaderboard_db.py index 999cd115..9c1160f8 100644 --- a/tests/test_leaderboard_db.py +++ b/tests/test_leaderboard_db.py @@ -1027,3 +1027,112 @@ def test_set_leaderboard_visibility(database, submit_leaderboard): db.set_leaderboard_visibility("submit-leaderboard", "public") assert db.get_leaderboard("submit-leaderboard")["visibility"] == "public" + +# --- Rate Limit Tests --- + + +def test_set_rate_limit(database, submit_leaderboard): + """Setting a rate limit creates it and upserting updates it.""" + with database as db: + result = db.set_rate_limit("submit-leaderboard", "test", 5) + assert result["mode_category"] == "test" + assert result["max_submissions_per_hour"] == 5 + assert result["leaderboard_name"] == "submit-leaderboard" + + # Upsert updates the value + result = db.set_rate_limit("submit-leaderboard", "test", 10) + assert result["max_submissions_per_hour"] == 10 + + # Different category creates a separate entry + result = db.set_rate_limit("submit-leaderboard", "leaderboard", 3) + assert result["mode_category"] == "leaderboard" + assert result["max_submissions_per_hour"] == 3 + + +def test_get_rate_limits_empty(database, submit_leaderboard): + """No rate limits returns empty list.""" + with database as db: + limits = db.get_rate_limits("submit-leaderboard") + assert limits == [] + + +def test_get_rate_limits(database, submit_leaderboard): + """Getting rate limits returns all configured limits.""" + with database as db: + db.set_rate_limit("submit-leaderboard", "test", 5) + db.set_rate_limit("submit-leaderboard", "leaderboard", 3) + limits = db.get_rate_limits("submit-leaderboard") + assert len(limits) == 2 + categories = {r["mode_category"] for r in limits} + assert categories == {"test", "leaderboard"} + + +def test_delete_rate_limit(database, submit_leaderboard): + """Deleting a rate limit removes it.""" + with database as db: + db.set_rate_limit("submit-leaderboard", "test", 5) + db.delete_rate_limit("submit-leaderboard", "test") + limits = db.get_rate_limits("submit-leaderboard") + assert limits == [] + + +def test_delete_rate_limit_not_found(database, submit_leaderboard): + """Deleting a non-existent rate limit raises an error.""" + with database as db: + with pytest.raises(KernelBotError, match="No rate limit found"): + db.delete_rate_limit("submit-leaderboard", "test") + + +def test_check_rate_limit_no_config(database, submit_leaderboard): + """check_rate_limit returns None when no rate limit is configured.""" + with database as db: + result = db.check_rate_limit("submit-leaderboard", "123", "test") + assert result is None + + +def test_check_rate_limit_under_limit(database, submit_leaderboard): + """check_rate_limit allows submissions under the limit.""" + with database as db: + db.set_rate_limit("submit-leaderboard", "test", 5) + db.create_submission( + "submit-leaderboard", "test.py", 123, "code1", datetime.datetime.now(), mode_category="test" + ) + result = db.check_rate_limit("submit-leaderboard", "123", "test") + assert result["allowed"] is True + assert result["current_count"] == 1 + assert result["max_per_hour"] == 5 + + +def test_check_rate_limit_at_limit(database, submit_leaderboard): + """check_rate_limit blocks submissions at the limit.""" + with database as db: + db.set_rate_limit("submit-leaderboard", "test", 2) + for i in range(2): + db.create_submission( + "submit-leaderboard", f"test{i}.py", 123, f"code{i}", datetime.datetime.now(), mode_category="test" + ) + result = db.check_rate_limit("submit-leaderboard", "123", "test") + assert result["allowed"] is False + assert result["current_count"] == 2 + assert result["max_per_hour"] == 2 + assert result["retry_after_seconds"] >= 0 + + +def test_check_rate_limit_categories_independent(database, submit_leaderboard): + """Test submissions don't count against leaderboard rate limit.""" + with database as db: + db.set_rate_limit("submit-leaderboard", "test", 1) + db.set_rate_limit("submit-leaderboard", "leaderboard", 1) + # Use up test limit + db.create_submission( + "submit-leaderboard", "test.py", 123, "code_test", datetime.datetime.now(), mode_category="test" + ) + # Leaderboard should still be allowed + result = db.check_rate_limit("submit-leaderboard", "123", "leaderboard") + assert result["allowed"] is True + assert result["current_count"] == 0 + + # Test should be blocked + result = db.check_rate_limit("submit-leaderboard", "123", "test") + assert result["allowed"] is False +