Skip to content

Commit 8cc79f9

Browse files
committed
Add pre-emptive rate limiting to metadata API services
Wire the existing RateLimiter into the IGDB, ScreenScraper, MobyGames and RetroAchievements services so requests are spaced under each provider's documented req/s cap, instead of only reacting to HTTP 429 after the fact. - IGDB: 4 req/s (documented hard limit) - MobyGames: 1 req/s (free-tier burst cap) - ScreenScraper: 1 req/s (free-tier throttle) - RetroAchievements: 4 req/s (conservative; no published hard limit) A slot is acquired before both the initial request and the timeout/429 retry. The reactive 2s 429 backoff is kept as a fallback. Tests neutralize the shared limiters via an autouse fixture and assert acquire() is awaited. https://claude.ai/code/session_01133QQuWvq8Zm25DZMP9PVr
1 parent 84d4bf1 commit 8cc79f9

8 files changed

Lines changed: 121 additions & 3 deletions

File tree

backend/adapters/services/igdb.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
from collections.abc import Sequence
55
from functools import partial
6-
from typing import TYPE_CHECKING, TypedDict
6+
from typing import TYPE_CHECKING, Final, TypedDict
77

88
import aiohttp
99
import yarl
@@ -17,10 +17,15 @@
1717
from logger.logger import log
1818
from utils import get_version
1919
from utils.context import ctx_aiohttp_session
20+
from utils.rate_limiter import RateLimiter
2021

2122
if TYPE_CHECKING:
2223
from handler.metadata.igdb_handler import TwitchAuth
2324

25+
# IGDB caps clients at 4 requests per second (max 8 open requests).
26+
IGDB_MAX_REQUESTS_PER_SECOND: Final[float] = 4
27+
_rate_limiter = RateLimiter(IGDB_MAX_REQUESTS_PER_SECOND)
28+
2429

2530
class IGDBInvalidCredentialsException(Exception):
2631
"""Exception raised when IGDB credentials are invalid."""
@@ -94,6 +99,7 @@ async def _request(
9499
)
95100

96101
try:
102+
await _rate_limiter.acquire()
97103
res = await aiohttp_session.post(
98104
url,
99105
data=content,
@@ -142,6 +148,7 @@ async def _request(
142148
content,
143149
request_timeout,
144150
)
151+
await _rate_limiter.acquire()
145152
res = await aiohttp_session.post(
146153
url,
147154
data=content,

backend/adapters/services/mobygames.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import http
33
import json
44
from collections.abc import Collection
5-
from typing import Literal, overload
5+
from typing import Final, Literal, overload
66

77
import aiohttp
88
import yarl
@@ -14,6 +14,11 @@
1414
from logger.logger import log
1515
from utils import get_version
1616
from utils.context import ctx_aiohttp_session
17+
from utils.rate_limiter import RateLimiter
18+
19+
# MobyGames caps the free/non-commercial tier at 1 request per second.
20+
MOBYGAMES_MAX_REQUESTS_PER_SECOND: Final[float] = 1
21+
_rate_limiter = RateLimiter(MOBYGAMES_MAX_REQUESTS_PER_SECOND)
1722

1823

1924
async def auth_middleware(
@@ -45,6 +50,7 @@ async def _request(self, url: str, request_timeout: int = 120) -> dict:
4550
)
4651

4752
try:
53+
await _rate_limiter.acquire()
4854
res = await aiohttp_session.get(
4955
url,
5056
headers={"user-agent": f"RomM/{get_version()}"},
@@ -85,6 +91,7 @@ async def _request(self, url: str, request_timeout: int = 120) -> dict:
8591
url,
8692
request_timeout,
8793
)
94+
await _rate_limiter.acquire()
8895
res = await aiohttp_session.get(
8996
url,
9097
headers={"user-agent": f"RomM/{get_version()}"},

backend/adapters/services/retroachievements.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import http
33
import json
44
from collections.abc import AsyncIterator
5-
from typing import cast
5+
from typing import Final, cast
66

77
import aiohttp
88
import yarl
@@ -20,6 +20,12 @@
2020
from logger.logger import log
2121
from utils import get_version
2222
from utils.context import ctx_aiohttp_session
23+
from utils.rate_limiter import RateLimiter
24+
25+
# RetroAchievements does not publish a fixed limit; stay conservative to keep
26+
# within the "fair burst" allowance the API documents.
27+
RA_MAX_REQUESTS_PER_SECOND: Final[float] = 4
28+
_rate_limiter = RateLimiter(RA_MAX_REQUESTS_PER_SECOND)
2329

2430

2531
async def auth_middleware(
@@ -53,6 +59,7 @@ async def _request(self, url: str, request_timeout: int = 120) -> dict:
5359
request_timeout,
5460
)
5561
try:
62+
await _rate_limiter.acquire()
5663
res = await aiohttp_session.get(
5764
url,
5865
headers={"user-agent": f"RomM/{get_version()}"},
@@ -90,6 +97,7 @@ async def _request(self, url: str, request_timeout: int = 120) -> dict:
9097
url,
9198
request_timeout,
9299
)
100+
await _rate_limiter.acquire()
93101
res = await aiohttp_session.get(
94102
url,
95103
headers={"user-agent": f"RomM/{get_version()}"},

backend/adapters/services/screenscraper.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@
1414
from logger.logger import log
1515
from utils import get_version
1616
from utils.context import ctx_aiohttp_session
17+
from utils.rate_limiter import RateLimiter
1718

1819
SS_DEV_ID: Final = base64.b64decode("enVyZGkxNQ==").decode()
1920
SS_DEV_PASSWORD: Final = base64.b64decode("eFRKd29PRmpPUUc=").decode()
2021
LOGIN_ERROR_CHECK: Final = "Erreur de login"
2122

23+
# ScreenScraper throttles the free tier to roughly one request per second.
24+
SS_MAX_REQUESTS_PER_SECOND: Final[float] = 1
25+
_rate_limiter = RateLimiter(SS_MAX_REQUESTS_PER_SECOND)
26+
2227

2328
async def auth_middleware(
2429
req: aiohttp.ClientRequest, handler: aiohttp.ClientHandlerType
@@ -57,6 +62,7 @@ async def _request(self, url: str, request_timeout: int = 120) -> dict:
5762
request_timeout,
5863
)
5964
try:
65+
await _rate_limiter.acquire()
6066
res = await aiohttp_session.get(
6167
url,
6268
headers={"user-agent": f"RomM/{get_version()}"},
@@ -125,6 +131,7 @@ async def _request(self, url: str, request_timeout: int = 120) -> dict:
125131
url,
126132
request_timeout,
127133
)
134+
await _rate_limiter.acquire()
128135
res = await aiohttp_session.get(
129136
url,
130137
headers={"user-agent": f"RomM/{get_version()}"},

backend/tests/adapters/services/conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
11
from contextvars import ContextVar
2+
from unittest.mock import AsyncMock
23

34
import aiohttp
5+
import pytest
46
import pytest_asyncio
57

68

9+
@pytest.fixture(autouse=True)
10+
def _disable_metadata_rate_limiters(monkeypatch):
11+
"""Neutralize the pre-emptive rate limiters during tests.
12+
13+
Each metadata service spaces its requests via a module-level ``RateLimiter``
14+
whose ``acquire`` sleeps to stay under the provider's req/s cap. Letting it
15+
run would add real delays and inject extra ``asyncio.sleep`` calls that
16+
interfere with retry assertions, so we replace ``acquire`` with a no-op.
17+
"""
18+
for module in (
19+
"adapters.services.igdb",
20+
"adapters.services.mobygames",
21+
"adapters.services.retroachievements",
22+
"adapters.services.screenscraper",
23+
):
24+
monkeypatch.setattr(f"{module}._rate_limiter.acquire", AsyncMock())
25+
26+
727
@pytest_asyncio.fixture
828
async def mock_ctx_aiohttp_session():
929
"""Create a real aiohttp session for integration tests."""

backend/tests/adapters/services/test_mobygames.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,28 @@ async def test_request_success(self, service):
104104
mock_response.raise_for_status.assert_called_once()
105105
mock_response.json.assert_called_once()
106106

107+
@pytest.mark.asyncio
108+
async def test_request_acquires_rate_limiter(self, service, monkeypatch):
109+
"""Test that the request reserves a rate-limiter slot before sending."""
110+
acquire_mock = AsyncMock()
111+
monkeypatch.setattr(
112+
"adapters.services.mobygames._rate_limiter.acquire", acquire_mock
113+
)
114+
115+
mock_session = AsyncMock()
116+
mock_response = MagicMock()
117+
mock_response.json = AsyncMock(return_value={"games": []})
118+
mock_response.raise_for_status.return_value = None
119+
mock_session.get.return_value = mock_response
120+
121+
mock_context = MagicMock()
122+
mock_context.get.return_value = mock_session
123+
124+
with patch("adapters.services.mobygames.ctx_aiohttp_session", mock_context):
125+
await service._request("https://api.mobygames.com/v1/games")
126+
127+
acquire_mock.assert_awaited_once()
128+
107129
@pytest.mark.asyncio
108130
async def test_request_connection_error(self, service):
109131
"""Test request with connection error."""

backend/tests/adapters/services/test_retroachivements.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,30 @@ async def test_request_connection_error(self, service):
7777
assert exc_info.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
7878
assert "Can't connect to RetroAchievements" in exc_info.value.detail
7979

80+
@pytest.mark.asyncio
81+
async def test_request_acquires_rate_limiter(self, service, monkeypatch):
82+
"""Test that the request reserves a rate-limiter slot before sending."""
83+
acquire_mock = AsyncMock()
84+
monkeypatch.setattr(
85+
"adapters.services.retroachievements._rate_limiter.acquire", acquire_mock
86+
)
87+
88+
mock_session = AsyncMock()
89+
mock_response = MagicMock()
90+
mock_response.json = AsyncMock(return_value={})
91+
mock_response.raise_for_status.return_value = None
92+
mock_session.get.return_value = mock_response
93+
94+
mock_context = MagicMock()
95+
mock_context.get.return_value = mock_session
96+
97+
with patch(
98+
"adapters.services.retroachievements.ctx_aiohttp_session", mock_context
99+
):
100+
await service._request("https://retroachievements.org/API")
101+
102+
acquire_mock.assert_awaited_once()
103+
80104

81105
class TestRetroAchievementsServiceIntegration:
82106
@pytest.fixture

backend/tests/adapters/services/test_screenscraper.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,29 @@ async def test_request_success(self, service):
148148
mock_response.raise_for_status.assert_called_once()
149149
mock_response.json.assert_called_once()
150150

151+
@pytest.mark.asyncio
152+
async def test_request_acquires_rate_limiter(self, service, monkeypatch):
153+
"""Test that the request reserves a rate-limiter slot before sending."""
154+
acquire_mock = AsyncMock()
155+
monkeypatch.setattr(
156+
"adapters.services.screenscraper._rate_limiter.acquire", acquire_mock
157+
)
158+
159+
mock_session = AsyncMock()
160+
mock_response = MagicMock()
161+
mock_response.json = AsyncMock(return_value={"response": {}})
162+
mock_response.text = AsyncMock(return_value="{}")
163+
mock_response.raise_for_status.return_value = None
164+
mock_session.get.return_value = mock_response
165+
166+
mock_context = MagicMock()
167+
mock_context.get.return_value = mock_session
168+
169+
with patch("adapters.services.screenscraper.ctx_aiohttp_session", mock_context):
170+
await service._request("https://api.screenscraper.fr/api2/jeuInfos.php")
171+
172+
acquire_mock.assert_awaited_once()
173+
151174
@pytest.mark.asyncio
152175
async def test_request_login_error(self, service):
153176
"""Test request with login error in response text."""

0 commit comments

Comments
 (0)