Skip to content

Commit 84d4bf1

Browse files
authored
Merge pull request #3482 from DevYukine/feat/playmatch-rate-limit
feat(playmatch): add ratelimiting
2 parents 031fb57 + ec172df commit 84d4bf1

3 files changed

Lines changed: 130 additions & 18 deletions

File tree

backend/handler/metadata/playmatch_handler.py

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import json
23
from enum import Enum
34
from typing import Final, NotRequired, TypedDict
@@ -12,6 +13,12 @@
1213
from models.rom import Rom, RomFile
1314
from utils import get_version
1415
from utils.context import ctx_httpx_client
16+
from utils.rate_limiter import RateLimiter
17+
18+
# Playmatch caps clients at 4 req/s per IP
19+
PLAYMATCH_MAX_REQUESTS_PER_SECOND: Final[float] = 4
20+
PLAYMATCH_MAX_REQUEST_ATTEMPTS: Final[int] = 2
21+
_rate_limiter = RateLimiter(PLAYMATCH_MAX_REQUESTS_PER_SECOND)
1522

1623

1724
class PlaymatchProvider(str, Enum):
@@ -144,29 +151,47 @@ async def _request(self, url: str, query: dict) -> dict:
144151

145152
headers = {"user-agent": f"RomM/{get_version()}"}
146153

147-
try:
148-
res = await httpx_client.get(
149-
str(url_with_query), headers=headers, timeout=60
150-
)
151-
res.raise_for_status()
152-
return res.json()
153-
except (httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout) as exc:
154-
log.warning("Connection error: can't connect to Playmatch", exc_info=True)
155-
raise HTTPException(
156-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
157-
detail="Can't connect to Playmatch, check your internet connection",
158-
) from exc
159-
except json.JSONDecodeError as exc:
160-
log.error("Error decoding JSON response from Playmatch: %s", exc)
161-
return {}
154+
for attempt in range(PLAYMATCH_MAX_REQUEST_ATTEMPTS):
155+
await _rate_limiter.acquire()
156+
try:
157+
res = await httpx_client.get(
158+
str(url_with_query), headers=headers, timeout=60
159+
)
160+
res.raise_for_status()
161+
return res.json()
162+
except (
163+
httpx.HTTPStatusError,
164+
httpx.ConnectError,
165+
httpx.ReadTimeout,
166+
) as exc:
167+
if (
168+
attempt == 0
169+
and isinstance(exc, httpx.HTTPStatusError)
170+
and exc.response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
171+
):
172+
log.warning("Playmatch: rate limit hit, retrying after 2s")
173+
await asyncio.sleep(2)
174+
continue
175+
log.warning(
176+
"Connection error: can't connect to Playmatch", exc_info=True
177+
)
178+
raise HTTPException(
179+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
180+
detail="Can't connect to Playmatch, check your internet connection",
181+
) from exc
182+
except json.JSONDecodeError as exc:
183+
log.error("Error decoding JSON response from Playmatch: %s", exc)
184+
return {}
185+
186+
return {}
162187

163188
async def lookup_rom(self, files: list[RomFile]) -> PlaymatchRomMatch:
164189
"""
165190
Identify a ROM file using Playmatch API.
166191
167192
:param rom_attrs: A dictionary containing the ROM attributes.
168-
:return: A PlaymatchRomMatch objects containing the matched ROM information.
169-
:raises HTTPException: If the request fails or the service is unavailable.
193+
:return: A PlaymatchRomMatch with the matched IDs, or an empty match if the
194+
lookup fails. Playmatch is best-effort and never raises to the caller.
170195
"""
171196
fallback_rom = PlaymatchRomMatch(
172197
igdb_id=None,
@@ -203,8 +228,9 @@ async def lookup_rom(self, files: list[RomFile]) -> PlaymatchRomMatch:
203228
"sha1": first_file.sha1_hash,
204229
},
205230
)
206-
except httpx.HTTPStatusError:
231+
except Exception as exc:
207232
# We silently fail if the service is unavailable as this should not block the rest of RomM.
233+
log.warning("Playmatch lookup failed, skipping: %s", exc)
208234
return fallback_rom
209235

210236
game_match_type = response.get("gameMatchType", None)
@@ -290,6 +316,7 @@ async def submit_manual_match_suggestion(self, rom: Rom) -> None:
290316
}
291317

292318
httpx_client = ctx_httpx_client.get()
319+
await _rate_limiter.acquire()
293320
res = await httpx_client.post(
294321
self.suggestion_url,
295322
json=payload,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from utils.rate_limiter import RateLimiter
6+
7+
8+
def _record_sleeps(monkeypatch) -> list[float]:
9+
"""Replace asyncio.sleep with a no-op that records requested delays."""
10+
sleeps: list[float] = []
11+
12+
async def fake_sleep(delay: float) -> None:
13+
sleeps.append(delay)
14+
15+
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
16+
return sleeps
17+
18+
19+
class TestRateLimiter:
20+
async def test_first_acquire_does_not_sleep(self, monkeypatch):
21+
sleeps = _record_sleeps(monkeypatch)
22+
limiter = RateLimiter(requests_per_second=4)
23+
24+
await limiter.acquire()
25+
26+
assert sleeps == []
27+
28+
async def test_slots_advance_by_interval(self, monkeypatch):
29+
sleeps = _record_sleeps(monkeypatch)
30+
limiter = RateLimiter(requests_per_second=4)
31+
32+
for _ in range(5):
33+
await limiter.acquire()
34+
35+
# First grant is immediate; the next four are spaced one interval apart.
36+
assert len(sleeps) == 4
37+
for index, delay in enumerate(sleeps, start=1):
38+
assert delay == pytest.approx(index * 0.25, abs=0.05)
39+
40+
async def test_concurrent_callers_are_spaced(self):
41+
rate = 50
42+
interval = 1 / rate
43+
count = 5
44+
limiter = RateLimiter(requests_per_second=rate)
45+
46+
loop = asyncio.get_running_loop()
47+
start = loop.time()
48+
await asyncio.gather(*(limiter.acquire() for _ in range(count)))
49+
elapsed = loop.time() - start
50+
51+
assert elapsed >= (count - 1) * interval
52+
assert elapsed < (count - 1) * interval + 0.5
53+
54+
@pytest.mark.parametrize("rate", [0, -1, -0.5])
55+
def test_non_positive_rate_raises(self, rate):
56+
with pytest.raises(ValueError):
57+
RateLimiter(requests_per_second=rate)

backend/utils/rate_limiter.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import asyncio
2+
3+
4+
class RateLimiter:
5+
"""Pre-emptive async rate limiter.
6+
7+
Spaces grants so callers stay at or below ``requests_per_second``. Concurrent
8+
callers each reserve the next slot before awaiting, so they are evenly spaced
9+
instead of being released in a burst.
10+
"""
11+
12+
def __init__(self, requests_per_second: float) -> None:
13+
if requests_per_second <= 0:
14+
raise ValueError("requests_per_second must be positive")
15+
self._min_interval = 1.0 / requests_per_second
16+
self._next_slot = 0.0
17+
18+
async def acquire(self) -> None:
19+
# Reserving the slot is a read-then-write on _next_slot with no await
20+
# between, so it is atomic on the single-threaded loop and needs no lock.
21+
loop = asyncio.get_running_loop()
22+
now = loop.time()
23+
slot = max(now, self._next_slot)
24+
self._next_slot = slot + self._min_interval
25+
26+
delay = slot - now
27+
if delay > 0:
28+
await asyncio.sleep(delay)

0 commit comments

Comments
 (0)