Skip to content

Commit 8017fef

Browse files
committed
Add per-user submission rate limit (1 per hour)
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 8017fef

File tree

4 files changed

+185
-0
lines changed

4 files changed

+185
-0
lines changed

src/kernelbot/api/api_utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,30 @@ async def to_submit_info(
203203
user_name = user_info["user_name"]
204204
user_id = user_info["user_id"]
205205

206+
# Per-user rate limit: max 1 submission per hour on Modal B200 for leaderboard 730
207+
if gpu_type == "B200":
208+
try:
209+
with db_context as db:
210+
lb_id = db.get_leaderboard_id(leaderboard_name)
211+
if lb_id == 730:
212+
last_submission_time = db.check_user_rate_limit(user_id)
213+
if last_submission_time:
214+
raise HTTPException(
215+
status_code=429,
216+
detail=(
217+
f"Rate limit exceeded. You can submit once per hour. "
218+
f"Last submission: {last_submission_time.isoformat()}. "
219+
f"Consider using the NVIDIA runner instead of Modal for faster iteration."
220+
),
221+
)
222+
except HTTPException:
223+
raise
224+
except Exception as e:
225+
raise HTTPException(
226+
status_code=500,
227+
detail=f"Internal server error while checking rate limit: {e}",
228+
) from e
229+
206230
try:
207231
submission_mode_enum: SubmissionMode = SubmissionMode(submission_mode.lower())
208232
except ValueError:

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)