Skip to content

Commit 01a0072

Browse files
authored
Feat: rate limiting (#470)
1 parent f9eb7e5 commit 01a0072

12 files changed

Lines changed: 493 additions & 13 deletions

File tree

src/kernelbot/api/api_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ async def _run_submission(
146146
submission: SubmissionRequest, mode: SubmissionMode, backend: KernelBackend
147147
):
148148
try:
149-
req = prepare_submission(submission, backend)
149+
req = prepare_submission(submission, backend, mode)
150150
except Exception as e:
151151
raise HTTPException(status_code=400, detail=str(e)) from e
152152

src/kernelbot/api/main.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,9 @@ async def enqueue_background_job(
478478
file_name=req.file_name,
479479
code=req.code,
480480
user_id=req.user_id,
481-
time=datetime.datetime.now(),
481+
time=datetime.datetime.now(datetime.timezone.utc),
482482
user_name=req.user_name,
483+
mode_category=req.mode_category,
483484
)
484485
job_id = db.upsert_submission_job_status(sub_id, "initial", None)
485486
# put submission request in queue
@@ -523,8 +524,10 @@ async def run_submission_async(
523524
user_info, submission_mode, file, leaderboard_name, gpu_type, db_context
524525
)
525526

526-
req = prepare_submission(submission_request, backend_instance)
527+
req = prepare_submission(submission_request, backend_instance, submission_mode_enum)
527528

529+
except KernelBotError as e:
530+
raise HTTPException(status_code=e.http_code, detail=str(e)) from e
528531
except Exception as e:
529532
raise HTTPException(
530533
status_code=400, detail=f"failed to prepare submission request: {str(e)}"
@@ -1041,6 +1044,58 @@ async def admin_set_visibility(
10411044
return {"status": "ok", "leaderboard": leaderboard_name, "visibility": visibility}
10421045

10431046

1047+
@app.put("/admin/leaderboards/{leaderboard_name}/rate-limits")
1048+
async def admin_set_rate_limit(
1049+
leaderboard_name: str,
1050+
payload: dict,
1051+
_: Annotated[None, Depends(require_admin)],
1052+
db_context=Depends(get_db),
1053+
) -> dict:
1054+
"""Create or update a rate limit for a leaderboard."""
1055+
mode_category = payload.get("mode_category")
1056+
if mode_category not in ("test", "leaderboard"):
1057+
raise HTTPException(status_code=400, detail="mode_category must be 'test' or 'leaderboard'")
1058+
max_per_hour = payload.get("max_submissions_per_hour")
1059+
if not isinstance(max_per_hour, int) or max_per_hour < 1:
1060+
raise HTTPException(status_code=400, detail="max_submissions_per_hour must be a positive integer")
1061+
try:
1062+
with db_context as db:
1063+
result = db.set_rate_limit(leaderboard_name, mode_category, max_per_hour)
1064+
return {"status": "ok", "rate_limit": dict(result)}
1065+
except KernelBotError as e:
1066+
raise HTTPException(status_code=e.http_code, detail=str(e)) from e
1067+
1068+
1069+
@app.get("/admin/leaderboards/{leaderboard_name}/rate-limits")
1070+
async def admin_get_rate_limits(
1071+
leaderboard_name: str,
1072+
_: Annotated[None, Depends(require_admin)],
1073+
db_context=Depends(get_db),
1074+
) -> dict:
1075+
"""List rate limits for a leaderboard."""
1076+
with db_context as db:
1077+
limits = db.get_rate_limits(leaderboard_name)
1078+
return {"status": "ok", "leaderboard": leaderboard_name, "rate_limits": [dict(r) for r in limits]}
1079+
1080+
1081+
@app.delete("/admin/leaderboards/{leaderboard_name}/rate-limits/{mode_category}")
1082+
async def admin_delete_rate_limit(
1083+
leaderboard_name: str,
1084+
mode_category: str,
1085+
_: Annotated[None, Depends(require_admin)],
1086+
db_context=Depends(get_db),
1087+
) -> dict:
1088+
"""Delete a rate limit for a leaderboard."""
1089+
if mode_category not in ("test", "leaderboard"):
1090+
raise HTTPException(status_code=400, detail="mode_category must be 'test' or 'leaderboard'")
1091+
try:
1092+
with db_context as db:
1093+
db.delete_rate_limit(leaderboard_name, mode_category)
1094+
return {"status": "ok", "leaderboard": leaderboard_name, "mode_category": mode_category}
1095+
except KernelBotError as e:
1096+
raise HTTPException(status_code=e.http_code, detail=str(e)) from e
1097+
1098+
10441099
@app.post("/user/join")
10451100
async def user_join_leaderboard(
10461101
payload: dict,

src/kernelbot/cogs/leaderboard_cog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async def submit(
126126
leaderboard=leaderboard_name,
127127
identity_type="discord",
128128
)
129-
req = prepare_submission(req, self.bot.backend)
129+
req = prepare_submission(req, self.bot.backend, mode)
130130

131131
if req.gpus is None:
132132
view = await self.select_gpu_view(interaction, leaderboard_name, req.task_gpus)

src/libkernelbot/backend.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import asyncio
22
import copy
3-
from datetime import datetime
3+
import datetime
44
from types import SimpleNamespace
55
from typing import Optional
66

7-
from libkernelbot.consts import GPU, GPU_TO_SM, SubmissionMode, get_gpu_by_name
7+
from libkernelbot.consts import GPU, GPU_TO_SM, SubmissionMode, get_gpu_by_name, get_mode_category
88
from libkernelbot.launchers import Launcher
99
from libkernelbot.leaderboard_db import LeaderboardDB
1010
from libkernelbot.report import (
@@ -67,8 +67,9 @@ async def submit_full(
6767
file_name=req.file_name,
6868
code=req.code,
6969
user_id=req.user_id,
70-
time=datetime.now(),
70+
time=datetime.datetime.now(datetime.timezone.utc),
7171
user_name=req.user_name,
72+
mode_category=req.mode_category or get_mode_category(mode),
7273
)
7374
selected_gpus = [get_gpu_by_name(gpu) for gpu in req.gpus]
7475
try:

src/libkernelbot/consts.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ class SubmissionMode(Enum):
101101
PRIVATE = "private"
102102

103103

104+
def get_mode_category(mode: "SubmissionMode") -> str:
105+
"""Map a SubmissionMode to its rate limit category ('test' or 'leaderboard')."""
106+
if mode == SubmissionMode.LEADERBOARD:
107+
return "leaderboard"
108+
return "test"
109+
110+
104111
class Language(Enum):
105112
Python = "py"
106113
CUDA = "cu"

src/libkernelbot/db_types.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,12 @@ class SubmissionItem(TypedDict):
6262
runs: List[RunItem]
6363

6464

65-
__all__ = [LeaderboardItem, LeaderboardRankedEntry, RunItem, SubmissionItem]
65+
class RateLimitItem(TypedDict):
66+
id: int
67+
leaderboard_id: int
68+
leaderboard_name: str
69+
mode_category: str
70+
max_submissions_per_hour: int
71+
72+
73+
__all__ = [LeaderboardItem, LeaderboardRankedEntry, RunItem, SubmissionItem, RateLimitItem]

src/libkernelbot/leaderboard_db.py

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
IdentityType,
1010
LeaderboardItem,
1111
LeaderboardRankedEntry,
12+
RateLimitItem,
1213
RunItem,
1314
SubmissionItem,
1415
)
@@ -276,8 +277,13 @@ def create_submission(
276277
code: str,
277278
time: datetime.datetime,
278279
user_name: str = None,
280+
mode_category: str = None,
279281
) -> Optional[int]:
280282
try:
283+
if time.tzinfo is None:
284+
time = time.astimezone()
285+
time = time.astimezone(datetime.timezone.utc)
286+
281287
# check if we already have the code
282288
self.cursor.execute(
283289
"""
@@ -323,10 +329,10 @@ def create_submission(
323329
self.cursor.execute(
324330
"""
325331
INSERT INTO leaderboard.submission (leaderboard_id, file_name,
326-
user_id, code_id, submission_time)
332+
user_id, code_id, submission_time, mode_category)
327333
VALUES (
328334
(SELECT id FROM leaderboard.leaderboard WHERE name = %s),
329-
%s, %s, %s, %s)
335+
%s, %s, %s, %s, %s)
330336
RETURNING id
331337
""",
332338
(
@@ -335,6 +341,7 @@ def create_submission(
335341
user_id,
336342
code_id,
337343
time,
344+
mode_category,
338345
),
339346
)
340347
submission_id = self.cursor.fetchone()[0]
@@ -1438,6 +1445,154 @@ def validate_cli_id(self, cli_id: str) -> Optional[dict[str, str]]:
14381445
raise KernelBotError("Error validating CLI ID") from e
14391446

14401447

1448+
def set_rate_limit(self, leaderboard_name: str, mode_category: str, max_per_hour: int) -> RateLimitItem:
1449+
try:
1450+
self.cursor.execute(
1451+
"""
1452+
INSERT INTO leaderboard.rate_limit (leaderboard_id, mode_category, max_submissions_per_hour)
1453+
VALUES (
1454+
(SELECT id FROM leaderboard.leaderboard WHERE name = %s),
1455+
%s, %s
1456+
)
1457+
ON CONFLICT (leaderboard_id, mode_category)
1458+
DO UPDATE SET max_submissions_per_hour = EXCLUDED.max_submissions_per_hour
1459+
RETURNING id, leaderboard_id, mode_category, max_submissions_per_hour
1460+
""",
1461+
(leaderboard_name, mode_category, max_per_hour),
1462+
)
1463+
row = self.cursor.fetchone()
1464+
if row is None:
1465+
raise LeaderboardDoesNotExist(leaderboard_name)
1466+
self.connection.commit()
1467+
return RateLimitItem(
1468+
id=row[0],
1469+
leaderboard_id=row[1],
1470+
leaderboard_name=leaderboard_name,
1471+
mode_category=row[2],
1472+
max_submissions_per_hour=row[3],
1473+
)
1474+
except psycopg2.Error as e:
1475+
self.connection.rollback()
1476+
if "null value in column" in str(e):
1477+
raise LeaderboardDoesNotExist(leaderboard_name) from e
1478+
logger.exception("Error setting rate limit", exc_info=e)
1479+
raise KernelBotError("Error setting rate limit") from e
1480+
1481+
def get_rate_limits(self, leaderboard_name: str) -> List[RateLimitItem]:
1482+
try:
1483+
self.cursor.execute(
1484+
"""
1485+
SELECT rl.id, rl.leaderboard_id, rl.mode_category, rl.max_submissions_per_hour
1486+
FROM leaderboard.rate_limit rl
1487+
JOIN leaderboard.leaderboard lb ON rl.leaderboard_id = lb.id
1488+
WHERE lb.name = %s
1489+
""",
1490+
(leaderboard_name,),
1491+
)
1492+
return [
1493+
RateLimitItem(
1494+
id=row[0],
1495+
leaderboard_id=row[1],
1496+
leaderboard_name=leaderboard_name,
1497+
mode_category=row[2],
1498+
max_submissions_per_hour=row[3],
1499+
)
1500+
for row in self.cursor.fetchall()
1501+
]
1502+
except psycopg2.Error as e:
1503+
self.connection.rollback()
1504+
logger.exception("Error getting rate limits", exc_info=e)
1505+
raise KernelBotError("Error getting rate limits") from e
1506+
1507+
def delete_rate_limit(self, leaderboard_name: str, mode_category: str) -> None:
1508+
try:
1509+
self.cursor.execute(
1510+
"""
1511+
DELETE FROM leaderboard.rate_limit
1512+
WHERE leaderboard_id = (SELECT id FROM leaderboard.leaderboard WHERE name = %s)
1513+
AND mode_category = %s
1514+
""",
1515+
(leaderboard_name, mode_category),
1516+
)
1517+
if self.cursor.rowcount == 0:
1518+
raise KernelBotError(
1519+
f"No rate limit found for '{leaderboard_name}' with category '{mode_category}'",
1520+
code=404,
1521+
)
1522+
self.connection.commit()
1523+
except KernelBotError:
1524+
raise
1525+
except psycopg2.Error as e:
1526+
self.connection.rollback()
1527+
logger.exception("Error deleting rate limit", exc_info=e)
1528+
raise KernelBotError("Error deleting rate limit") from e
1529+
1530+
def check_rate_limit(
1531+
self, leaderboard_name: str, user_id: str, mode_category: str
1532+
) -> Optional[dict]:
1533+
"""Check if a user has exceeded the rate limit for a leaderboard+category.
1534+
1535+
Returns None if no rate limit is configured, otherwise returns a dict with:
1536+
- allowed: bool
1537+
- current_count: int
1538+
- max_per_hour: int
1539+
- retry_after_seconds: int (0 if allowed)
1540+
"""
1541+
try:
1542+
# Get the rate limit config
1543+
self.cursor.execute(
1544+
"""
1545+
SELECT rl.max_submissions_per_hour
1546+
FROM leaderboard.rate_limit rl
1547+
JOIN leaderboard.leaderboard lb ON rl.leaderboard_id = lb.id
1548+
WHERE lb.name = %s AND rl.mode_category = %s
1549+
""",
1550+
(leaderboard_name, mode_category),
1551+
)
1552+
row = self.cursor.fetchone()
1553+
if row is None:
1554+
return None
1555+
1556+
max_per_hour = row[0]
1557+
1558+
# Count submissions in the last hour
1559+
self.cursor.execute(
1560+
"""
1561+
SELECT COUNT(*), MIN(s.submission_time)
1562+
FROM leaderboard.submission s
1563+
JOIN leaderboard.leaderboard lb ON s.leaderboard_id = lb.id
1564+
WHERE lb.name = %s
1565+
AND s.user_id = %s
1566+
AND s.mode_category = %s
1567+
AND s.submission_time > NOW() - INTERVAL '1 hour'
1568+
""",
1569+
(leaderboard_name, user_id, mode_category),
1570+
)
1571+
count_row = self.cursor.fetchone()
1572+
current_count = count_row[0]
1573+
oldest_time = count_row[1]
1574+
1575+
allowed = current_count < max_per_hour
1576+
retry_after = 0
1577+
if not allowed and oldest_time is not None:
1578+
import datetime as dt
1579+
1580+
expiry = oldest_time + dt.timedelta(hours=1)
1581+
now = dt.datetime.now(dt.timezone.utc)
1582+
retry_after = max(0, int((expiry - now).total_seconds()))
1583+
1584+
return {
1585+
"allowed": allowed,
1586+
"current_count": current_count,
1587+
"max_per_hour": max_per_hour,
1588+
"retry_after_seconds": retry_after,
1589+
}
1590+
except psycopg2.Error as e:
1591+
self.connection.rollback()
1592+
logger.exception("Error checking rate limit", exc_info=e)
1593+
raise KernelBotError("Error checking rate limit") from e
1594+
1595+
14411596
class LeaderboardDoesNotExist(KernelBotError):
14421597
def __init__(self, name: str):
14431598
super().__init__(message=f"Leaderboard `{name}` does not exist.", code=404)

src/libkernelbot/submission.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from better_profanity import profanity
99

10-
from libkernelbot.consts import RankCriterion
10+
from libkernelbot.consts import RankCriterion, SubmissionMode, get_mode_category
1111
from libkernelbot.db_types import RunItem, SubmissionItem
1212
from libkernelbot.leaderboard_db import LeaderboardDB, LeaderboardItem
1313
from libkernelbot.run_eval import FullResult
@@ -38,10 +38,11 @@ class ProcessedSubmissionRequest(SubmissionRequest):
3838
task: LeaderboardTask = None
3939
secret_seed: int = None
4040
task_gpus: list = None
41+
mode_category: str = None
4142

4243

4344
def prepare_submission( # noqa: C901
44-
req: SubmissionRequest, backend: "KernelBackend"
45+
req: SubmissionRequest, backend: "KernelBackend", mode: SubmissionMode = None
4546
) -> ProcessedSubmissionRequest:
4647
if not backend.accepts_jobs:
4748
raise KernelBotError(
@@ -70,6 +71,19 @@ def prepare_submission( # noqa: C901
7071
)
7172
if not db.check_leaderboard_access(req.leaderboard, str(req.user_id)):
7273
raise KernelBotError("You do not have access to this leaderboard", code=403)
74+
75+
mode_category = get_mode_category(mode) if mode else None
76+
if mode_category is not None:
77+
with backend.db as db:
78+
rate_check = db.check_rate_limit(req.leaderboard, str(req.user_id), mode_category)
79+
if rate_check and not rate_check["allowed"]:
80+
raise KernelBotError(
81+
f"Rate limit exceeded: {rate_check['current_count']}/{rate_check['max_per_hour']} "
82+
f"{mode_category} submissions per hour. "
83+
f"Try again in {rate_check['retry_after_seconds']}s.",
84+
code=429,
85+
)
86+
7387
check_deadline(leaderboard)
7488

7589
task_gpus = get_avail_gpus(req.leaderboard, backend.db)
@@ -91,6 +105,7 @@ def prepare_submission( # noqa: C901
91105
task=leaderboard["task"],
92106
secret_seed=leaderboard["secret_seed"],
93107
task_gpus=task_gpus,
108+
mode_category=mode_category,
94109
)
95110

96111

0 commit comments

Comments
 (0)