Skip to content

Commit 1da7858

Browse files
authored
Merge pull request #25 from CodeKeanu/feature/issue-15-global-rate-limiter
feat: add global rate limiter shared across all clients
2 parents 7ebca6f + 4e04cb4 commit 1da7858

2 files changed

Lines changed: 264 additions & 4 deletions

File tree

src/steam_mcp/client/steam_client.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Steam API Client with rate limiting, caching, and error handling.
22
33
This client provides a robust interface to the Steam Web API with:
4-
- Rate limiting to avoid hitting API limits
4+
- Rate limiting to avoid hitting API limits (global singleton by default)
55
- TTL-based response caching to reduce API load
66
- Automatic retry with exponential backoff
77
- Consistent error handling across all endpoints
@@ -21,6 +21,12 @@
2121

2222
logger = logging.getLogger(__name__)
2323

24+
# Default rate limit (requests per second)
25+
DEFAULT_RATE_LIMIT = 10.0
26+
27+
# Global rate limiter instance (singleton)
28+
_global_rate_limiter: "RateLimiter | None" = None
29+
2430

2531
class SteamAPIError(Exception):
2632
"""Base exception for Steam API errors."""
@@ -67,6 +73,38 @@ async def acquire(self) -> None:
6773
self.last_request_time = time.monotonic()
6874

6975

76+
def get_global_rate_limiter() -> RateLimiter:
77+
"""
78+
Get or create the global rate limiter singleton.
79+
80+
The rate limit can be configured via the STEAM_RATE_LIMIT environment variable.
81+
Default is 10 requests per second.
82+
83+
Thread-safe due to GIL; RateLimiter.__init__ is synchronous so no async race.
84+
85+
Returns:
86+
The shared global RateLimiter instance.
87+
"""
88+
global _global_rate_limiter
89+
90+
if _global_rate_limiter is None:
91+
rate_limit = float(os.getenv("STEAM_RATE_LIMIT", str(DEFAULT_RATE_LIMIT)))
92+
_global_rate_limiter = RateLimiter(requests_per_second=rate_limit)
93+
logger.debug(f"Created global rate limiter: {rate_limit} req/s")
94+
95+
return _global_rate_limiter
96+
97+
98+
def reset_global_rate_limiter() -> None:
99+
"""
100+
Reset the global rate limiter.
101+
102+
This is primarily useful for testing to ensure a fresh state.
103+
"""
104+
global _global_rate_limiter
105+
_global_rate_limiter = None
106+
107+
70108
class SteamClient:
71109
"""Async client for the Steam Web API."""
72110

@@ -92,11 +130,12 @@ def __init__(
92130
self,
93131
api_key: str | None = None,
94132
owner_steam_id: str | None = None,
95-
requests_per_second: float = 10.0,
133+
rate_limiter: RateLimiter | None = None,
96134
max_retries: int = 3,
97135
timeout: float = 30.0,
98136
enable_cache: bool = True,
99137
cache_max_size: int = 1000,
138+
requests_per_second: float | None = None,
100139
):
101140
"""
102141
Initialize Steam API client.
@@ -105,11 +144,14 @@ def __init__(
105144
api_key: Steam Web API key. If not provided, reads from STEAM_API_KEY env var.
106145
owner_steam_id: SteamID64 of the API key owner. If not provided, reads from
107146
STEAM_USER_ID env var. This enables "get my profile" style queries.
108-
requests_per_second: Rate limit for API requests.
147+
rate_limiter: Optional custom RateLimiter instance. If not provided, uses the
148+
global shared rate limiter (configured via STEAM_RATE_LIMIT env var).
109149
max_retries: Maximum number of retry attempts for failed requests.
110150
timeout: Request timeout in seconds.
111151
enable_cache: Whether to enable response caching (default: True).
112152
cache_max_size: Maximum number of cached entries (default: 1000).
153+
requests_per_second: Deprecated. Use rate_limiter or STEAM_RATE_LIMIT env var.
154+
Creates a dedicated RateLimiter for this client if provided.
113155
"""
114156
self.api_key = api_key or os.getenv("STEAM_API_KEY")
115157
if not self.api_key:
@@ -120,7 +162,14 @@ def __init__(
120162

121163
self.max_retries = max_retries
122164
self.timeout = timeout
123-
self.rate_limiter = RateLimiter(requests_per_second)
165+
166+
# Rate limiter priority: explicit rate_limiter > requests_per_second > global
167+
if rate_limiter is not None:
168+
self.rate_limiter = rate_limiter
169+
elif requests_per_second is not None:
170+
self.rate_limiter = RateLimiter(requests_per_second=requests_per_second)
171+
else:
172+
self.rate_limiter = get_global_rate_limiter()
124173

125174
# Initialize cache if enabled
126175
self._cache: TTLCache | None = None

tests/test_global_rate_limiter.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Tests for global rate limiter functionality."""
2+
3+
import asyncio
4+
import pytest
5+
from unittest.mock import patch
6+
7+
from steam_mcp.client.steam_client import (
8+
DEFAULT_RATE_LIMIT,
9+
RateLimiter,
10+
SteamClient,
11+
get_global_rate_limiter,
12+
reset_global_rate_limiter,
13+
)
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def reset_limiter():
18+
"""Reset global rate limiter before each test."""
19+
reset_global_rate_limiter()
20+
yield
21+
reset_global_rate_limiter()
22+
23+
24+
@pytest.fixture
25+
def mock_env():
26+
"""Set up mock environment variables."""
27+
with patch.dict("os.environ", {"STEAM_API_KEY": "test_key"}, clear=False):
28+
yield
29+
30+
31+
class TestGetGlobalRateLimiter:
32+
"""Tests for get_global_rate_limiter function."""
33+
34+
def test_returns_rate_limiter_instance(self):
35+
"""Should return a RateLimiter instance."""
36+
limiter = get_global_rate_limiter()
37+
assert isinstance(limiter, RateLimiter)
38+
39+
def test_returns_same_instance(self):
40+
"""Should return the same instance on subsequent calls."""
41+
limiter1 = get_global_rate_limiter()
42+
limiter2 = get_global_rate_limiter()
43+
assert limiter1 is limiter2
44+
45+
def test_uses_default_rate_limit(self):
46+
"""Should use DEFAULT_RATE_LIMIT when env var not set."""
47+
limiter = get_global_rate_limiter()
48+
assert limiter.requests_per_second == DEFAULT_RATE_LIMIT
49+
50+
def test_uses_env_var_rate_limit(self):
51+
"""Should use STEAM_RATE_LIMIT env var when set."""
52+
with patch.dict("os.environ", {"STEAM_RATE_LIMIT": "5.0"}):
53+
reset_global_rate_limiter()
54+
limiter = get_global_rate_limiter()
55+
assert limiter.requests_per_second == 5.0
56+
57+
58+
class TestResetGlobalRateLimiter:
59+
"""Tests for reset_global_rate_limiter function."""
60+
61+
def test_reset_creates_new_instance(self):
62+
"""After reset, should create a new instance."""
63+
limiter1 = get_global_rate_limiter()
64+
reset_global_rate_limiter()
65+
limiter2 = get_global_rate_limiter()
66+
assert limiter1 is not limiter2
67+
68+
69+
class TestSteamClientRateLimiter:
70+
"""Tests for SteamClient rate limiter integration."""
71+
72+
def test_uses_global_rate_limiter_by_default(self, mock_env):
73+
"""SteamClient should use global rate limiter by default."""
74+
global_limiter = get_global_rate_limiter()
75+
client = SteamClient()
76+
assert client.rate_limiter is global_limiter
77+
78+
def test_multiple_clients_share_rate_limiter(self, mock_env):
79+
"""Multiple SteamClient instances should share the same rate limiter."""
80+
client1 = SteamClient()
81+
client2 = SteamClient()
82+
assert client1.rate_limiter is client2.rate_limiter
83+
84+
def test_can_use_custom_rate_limiter(self, mock_env):
85+
"""SteamClient can use a custom rate limiter."""
86+
custom_limiter = RateLimiter(requests_per_second=5.0)
87+
client = SteamClient(rate_limiter=custom_limiter)
88+
assert client.rate_limiter is custom_limiter
89+
assert client.rate_limiter is not get_global_rate_limiter()
90+
91+
def test_backward_compat_requests_per_second(self, mock_env):
92+
"""SteamClient should support deprecated requests_per_second param."""
93+
client = SteamClient(requests_per_second=5.0)
94+
# Should create a dedicated limiter, not use global
95+
assert client.rate_limiter is not get_global_rate_limiter()
96+
assert client.rate_limiter.requests_per_second == 5.0
97+
98+
def test_rate_limiter_takes_precedence_over_requests_per_second(self, mock_env):
99+
"""Explicit rate_limiter should take precedence over requests_per_second."""
100+
custom_limiter = RateLimiter(requests_per_second=20.0)
101+
client = SteamClient(rate_limiter=custom_limiter, requests_per_second=5.0)
102+
assert client.rate_limiter is custom_limiter
103+
assert client.rate_limiter.requests_per_second == 20.0
104+
105+
106+
class TestConcurrentAccess:
107+
"""Tests for concurrent access to rate limiter."""
108+
109+
@pytest.mark.asyncio
110+
async def test_concurrent_acquire_is_serialized(self):
111+
"""Concurrent acquire calls should be properly serialized."""
112+
limiter = RateLimiter(requests_per_second=100.0) # Fast for testing
113+
acquire_times = []
114+
115+
async def track_acquire():
116+
await limiter.acquire()
117+
acquire_times.append(asyncio.get_event_loop().time())
118+
119+
# Launch multiple concurrent acquires
120+
tasks = [asyncio.create_task(track_acquire()) for _ in range(5)]
121+
await asyncio.gather(*tasks)
122+
123+
# Verify all acquired (should have 5 entries)
124+
assert len(acquire_times) == 5
125+
126+
@pytest.mark.asyncio
127+
async def test_global_limiter_serializes_across_clients(self, mock_env):
128+
"""Global rate limiter should serialize requests across multiple clients."""
129+
reset_global_rate_limiter()
130+
131+
# Create multiple clients sharing the global limiter
132+
client1 = SteamClient()
133+
client2 = SteamClient()
134+
135+
# Verify they share the same rate limiter
136+
assert client1.rate_limiter is client2.rate_limiter
137+
138+
acquire_count = 0
139+
lock = asyncio.Lock()
140+
141+
async def acquire_from_client(client):
142+
nonlocal acquire_count
143+
await client.rate_limiter.acquire()
144+
async with lock:
145+
acquire_count += 1
146+
147+
# Launch concurrent acquires from different clients
148+
tasks = [
149+
asyncio.create_task(acquire_from_client(client1)),
150+
asyncio.create_task(acquire_from_client(client2)),
151+
asyncio.create_task(acquire_from_client(client1)),
152+
asyncio.create_task(acquire_from_client(client2)),
153+
]
154+
await asyncio.gather(*tasks)
155+
156+
# All should have completed
157+
assert acquire_count == 4
158+
159+
@pytest.mark.asyncio
160+
async def test_rate_limiter_enforces_rate(self):
161+
"""Rate limiter should enforce the configured rate limit."""
162+
import time
163+
164+
# Very slow rate to make timing measurable
165+
limiter = RateLimiter(requests_per_second=10.0) # 100ms between requests
166+
167+
start = time.monotonic()
168+
169+
# Make 3 requests
170+
await limiter.acquire()
171+
await limiter.acquire()
172+
await limiter.acquire()
173+
174+
elapsed = time.monotonic() - start
175+
176+
# Should take at least 200ms for 3 requests at 10 req/s
177+
# (first is instant, second waits 100ms, third waits 100ms)
178+
assert elapsed >= 0.18 # Allow small margin for timing variance
179+
180+
@pytest.mark.asyncio
181+
async def test_no_race_conditions_under_load(self, mock_env):
182+
"""Global limiter should handle many concurrent requests without races."""
183+
reset_global_rate_limiter()
184+
185+
# Use a faster rate for this test
186+
with patch.dict("os.environ", {"STEAM_RATE_LIMIT": "1000.0"}):
187+
reset_global_rate_limiter()
188+
189+
# Create multiple clients
190+
clients = [SteamClient() for _ in range(3)]
191+
192+
# Verify all share the same limiter
193+
assert all(c.rate_limiter is clients[0].rate_limiter for c in clients)
194+
195+
results = []
196+
lock = asyncio.Lock()
197+
198+
async def acquire_and_record(client_idx):
199+
client = clients[client_idx % len(clients)]
200+
await client.rate_limiter.acquire()
201+
async with lock:
202+
results.append(client_idx)
203+
204+
# Launch many concurrent requests
205+
tasks = [
206+
asyncio.create_task(acquire_and_record(i)) for i in range(20)
207+
]
208+
await asyncio.gather(*tasks)
209+
210+
# All should complete without errors
211+
assert len(results) == 20

0 commit comments

Comments
 (0)