|
| 1 | +import asyncio |
1 | 2 | import json |
2 | 3 | from enum import Enum |
3 | 4 | from typing import Final, NotRequired, TypedDict |
|
12 | 13 | from models.rom import Rom, RomFile |
13 | 14 | from utils import get_version |
14 | 15 | 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) |
15 | 22 |
|
16 | 23 |
|
17 | 24 | class PlaymatchProvider(str, Enum): |
@@ -144,29 +151,47 @@ async def _request(self, url: str, query: dict) -> dict: |
144 | 151 |
|
145 | 152 | headers = {"user-agent": f"RomM/{get_version()}"} |
146 | 153 |
|
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 {} |
162 | 187 |
|
163 | 188 | async def lookup_rom(self, files: list[RomFile]) -> PlaymatchRomMatch: |
164 | 189 | """ |
165 | 190 | Identify a ROM file using Playmatch API. |
166 | 191 |
|
167 | 192 | :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. |
170 | 195 | """ |
171 | 196 | fallback_rom = PlaymatchRomMatch( |
172 | 197 | igdb_id=None, |
@@ -203,8 +228,9 @@ async def lookup_rom(self, files: list[RomFile]) -> PlaymatchRomMatch: |
203 | 228 | "sha1": first_file.sha1_hash, |
204 | 229 | }, |
205 | 230 | ) |
206 | | - except httpx.HTTPStatusError: |
| 231 | + except Exception as exc: |
207 | 232 | # 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) |
208 | 234 | return fallback_rom |
209 | 235 |
|
210 | 236 | game_match_type = response.get("gameMatchType", None) |
@@ -290,6 +316,7 @@ async def submit_manual_match_suggestion(self, rom: Rom) -> None: |
290 | 316 | } |
291 | 317 |
|
292 | 318 | httpx_client = ctx_httpx_client.get() |
| 319 | + await _rate_limiter.acquire() |
293 | 320 | res = await httpx_client.post( |
294 | 321 | self.suggestion_url, |
295 | 322 | json=payload, |
|
0 commit comments