Skip to content

Commit 7d97a31

Browse files
author
Mark Saroufim
authored
Add per-user submission rate limit (1 per hour) (#436)
Enforce a per-user rate limit of 1 submission per hour, scoped to Modal B200 GPU submissions on leaderboard ID 730 only. The 429 response suggests using the NVIDIA runner instead of Modal for faster iteration.
1 parent bb4bea0 commit 7d97a31

4 files changed

Lines changed: 177 additions & 1 deletion

File tree

src/kernelbot/api/api_utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,21 @@ async def to_submit_info(
225225

226226
try:
227227
with db_context as db:
228+
# Per-user rate limit: max 1 submission per hour on Modal B200 for leaderboard 730
229+
if gpu_type == "B200":
230+
lb_id = db.get_leaderboard_id(leaderboard_name)
231+
if lb_id == 730:
232+
last_submission_time = db.check_user_rate_limit(user_id)
233+
if last_submission_time:
234+
raise HTTPException(
235+
status_code=429,
236+
detail=(
237+
f"Rate limit exceeded. You can submit once per hour. "
238+
f"Last submission: {last_submission_time.isoformat()}. "
239+
f"Consider using the NVIDIA runner instead of Modal for faster iteration."
240+
),
241+
)
242+
228243
leaderboard_item = db.get_leaderboard(leaderboard_name)
229244
gpus = leaderboard_item.get("gpu_types", [])
230245
if gpu_type not in gpus:
@@ -239,7 +254,7 @@ async def to_submit_info(
239254
except Exception as e:
240255
raise HTTPException(
241256
status_code=500,
242-
detail=f"Internal server error while validating leaderboard/GPU: {e}",
257+
detail=f"Internal server error while validating submission: {e}",
243258
) from e
244259

245260
try:

src/libkernelbot/leaderboard_db.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,23 @@ def cleanup_temp_users(self):
11721172
logger.exception("Could not cleanup temp users", exc_info=e)
11731173
raise KernelBotError("Database error while cleaning up temp users") from e
11741174

1175+
def check_user_rate_limit(self, user_id: str, hours: int = 1) -> Optional[datetime.datetime]:
1176+
"""Check if user has submitted within the last `hours` hours.
1177+
Returns the most recent submission_time if rate-limited, None if allowed."""
1178+
self.cursor.execute(
1179+
"""
1180+
SELECT submission_time
1181+
FROM leaderboard.submission
1182+
WHERE user_id = %s
1183+
AND submission_time > NOW() - INTERVAL '%s hours'
1184+
ORDER BY submission_time DESC
1185+
LIMIT 1
1186+
""",
1187+
(str(user_id), hours),
1188+
)
1189+
row = self.cursor.fetchone()
1190+
return row[0] if row else None
1191+
11751192
def validate_cli_id(self, cli_id: str) -> Optional[dict[str, str]]:
11761193
"""
11771194
Validates a CLI ID and returns the associated user ID if valid.

tests/test_admin_api.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for admin API endpoints."""
22

3+
import datetime
34
from unittest.mock import MagicMock, patch
45

56
import pytest
@@ -405,3 +406,101 @@ def test_update_problems_with_errors(self, test_client, mock_backend):
405406
assert data["status"] == "ok"
406407
assert len(data["errors"]) == 1
407408
assert data["errors"][0]["name"] == "bad-problem"
409+
410+
411+
class TestSubmissionRateLimit:
412+
"""Test per-user submission rate limiting on Modal B200 for leaderboard 730."""
413+
414+
def test_rate_limit_blocks_b200_leaderboard_730(self, test_client, mock_backend):
415+
"""Second B200 submission to leaderboard 730 within 1 hour is rejected with 429."""
416+
mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db)
417+
mock_backend.db.__exit__ = MagicMock(return_value=None)
418+
419+
recent_time = datetime.datetime.now(tz=datetime.timezone.utc)
420+
mock_backend.db.check_user_rate_limit = MagicMock(return_value=recent_time)
421+
mock_backend.db.get_leaderboard_id = MagicMock(return_value=730)
422+
mock_backend.db.validate_cli_id = MagicMock(
423+
return_value={"user_id": "123", "user_name": "testuser"}
424+
)
425+
426+
response = test_client.post(
427+
"/test-lb/B200/test",
428+
headers={"X-Popcorn-Cli-Id": "test-cli-id"},
429+
files={"file": ("solution.py", b"print('hello')", "text/plain")},
430+
)
431+
assert response.status_code == 429
432+
assert "Rate limit exceeded" in response.json()["detail"]
433+
assert "NVIDIA runner" in response.json()["detail"]
434+
435+
def test_rate_limit_skipped_for_non_b200(self, test_client, mock_backend):
436+
"""Rate limit is not enforced for non-B200 GPUs even on leaderboard 730."""
437+
mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db)
438+
mock_backend.db.__exit__ = MagicMock(return_value=None)
439+
mock_backend.accepts_jobs = True
440+
441+
mock_backend.db.validate_cli_id = MagicMock(
442+
return_value={"user_id": "123", "user_name": "testuser"}
443+
)
444+
445+
mock_lb = MagicMock()
446+
mock_lb.__getitem__ = lambda self, key: {"gpu_types": ["H100"]}[key]
447+
mock_lb.get = lambda key, default=None: {"gpu_types": ["H100"]}.get(key, default)
448+
mock_backend.db.get_leaderboard = MagicMock(return_value=mock_lb)
449+
450+
response = test_client.post(
451+
"/test-lb/H100/test",
452+
headers={"X-Popcorn-Cli-Id": "test-cli-id"},
453+
files={"file": ("solution.py", b"print('hello')", "text/plain")},
454+
)
455+
# Should not hit rate limit at all — check_user_rate_limit should not be called
456+
assert response.status_code != 429
457+
458+
def test_rate_limit_skipped_for_other_leaderboard(self, test_client, mock_backend):
459+
"""Rate limit is not enforced for B200 on a leaderboard other than 730."""
460+
mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db)
461+
mock_backend.db.__exit__ = MagicMock(return_value=None)
462+
mock_backend.accepts_jobs = True
463+
464+
recent_time = datetime.datetime.now(tz=datetime.timezone.utc)
465+
mock_backend.db.check_user_rate_limit = MagicMock(return_value=recent_time)
466+
mock_backend.db.get_leaderboard_id = MagicMock(return_value=999)
467+
mock_backend.db.validate_cli_id = MagicMock(
468+
return_value={"user_id": "123", "user_name": "testuser"}
469+
)
470+
471+
mock_lb = MagicMock()
472+
mock_lb.__getitem__ = lambda self, key: {"gpu_types": ["B200"]}[key]
473+
mock_lb.get = lambda key, default=None: {"gpu_types": ["B200"]}.get(key, default)
474+
mock_backend.db.get_leaderboard = MagicMock(return_value=mock_lb)
475+
476+
response = test_client.post(
477+
"/other-lb/B200/test",
478+
headers={"X-Popcorn-Cli-Id": "test-cli-id"},
479+
files={"file": ("solution.py", b"print('hello')", "text/plain")},
480+
)
481+
# Should not be rate limited since leaderboard ID is not 730
482+
assert response.status_code != 429
483+
484+
def test_rate_limit_allows_first_b200_submission(self, test_client, mock_backend):
485+
"""First B200 submission to leaderboard 730 passes the rate limit check."""
486+
mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db)
487+
mock_backend.db.__exit__ = MagicMock(return_value=None)
488+
mock_backend.accepts_jobs = True
489+
490+
mock_backend.db.check_user_rate_limit = MagicMock(return_value=None)
491+
mock_backend.db.get_leaderboard_id = MagicMock(return_value=730)
492+
mock_backend.db.validate_cli_id = MagicMock(
493+
return_value={"user_id": "123", "user_name": "testuser"}
494+
)
495+
496+
mock_lb = MagicMock()
497+
mock_lb.__getitem__ = lambda self, key: {"gpu_types": ["B200"]}[key]
498+
mock_lb.get = lambda key, default=None: {"gpu_types": ["B200"]}.get(key, default)
499+
mock_backend.db.get_leaderboard = MagicMock(return_value=mock_lb)
500+
501+
response = test_client.post(
502+
"/test-lb/B200/test",
503+
headers={"X-Popcorn-Cli-Id": "test-cli-id"},
504+
files={"file": ("solution.py", b"print('hello')", "text/plain")},
505+
)
506+
assert response.status_code != 429

tests/test_leaderboard_db.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,51 @@ def test_generate_stats(database, submit_leaderboard):
605605
}
606606

607607

608+
def test_check_user_rate_limit_no_submissions(database, submit_leaderboard):
609+
"""Test rate limit returns None when user has no submissions"""
610+
with database as db:
611+
result = db.check_user_rate_limit("999")
612+
assert result is None
613+
614+
615+
def test_check_user_rate_limit_recent_submission(database, submit_leaderboard):
616+
"""Test rate limit returns submission_time when user submitted recently"""
617+
submit_time = datetime.datetime.now(tz=datetime.timezone.utc)
618+
with database as db:
619+
db.create_submission(
620+
"submit-leaderboard", "file.py", 5, "code", submit_time, user_name="user"
621+
)
622+
result = db.check_user_rate_limit("5")
623+
assert result is not None
624+
assert abs((result - submit_time).total_seconds()) < 2
625+
626+
627+
def test_check_user_rate_limit_old_submission(database, submit_leaderboard):
628+
"""Test rate limit returns None when submission is older than the window"""
629+
old_time = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(hours=2)
630+
with database as db:
631+
db.create_submission(
632+
"submit-leaderboard", "file.py", 5, "code", old_time, user_name="user"
633+
)
634+
result = db.check_user_rate_limit("5")
635+
assert result is None
636+
637+
638+
def test_check_user_rate_limit_different_user(database, submit_leaderboard):
639+
"""Test rate limit only applies to the specific user"""
640+
submit_time = datetime.datetime.now(tz=datetime.timezone.utc)
641+
with database as db:
642+
db.create_submission(
643+
"submit-leaderboard", "file.py", 5, "code", submit_time, user_name="user5"
644+
)
645+
# User 6 should not be rate limited
646+
result = db.check_user_rate_limit("6")
647+
assert result is None
648+
# User 5 should be rate limited
649+
result = db.check_user_rate_limit("5")
650+
assert result is not None
651+
652+
608653
def test_get_user_submissions_empty(database, submit_leaderboard):
609654
"""Test get_user_submissions returns empty list for user with no submissions"""
610655
with database as db:

0 commit comments

Comments
 (0)