Skip to content

Commit 99417f2

Browse files
authored
Merge pull request #89 from jcxldn/feat/jcx/rate-limits
Add support for microsoft-defined rate limits
2 parents 668ca26 + 4f1a6b0 commit 99417f2

File tree

10 files changed

+726
-25
lines changed

10 files changed

+726
-25
lines changed

tests/test_ratelimits.py

+241
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
from datetime import datetime, timedelta
2+
from httpx import Response
3+
import pytest
4+
import asyncio
5+
6+
from tests.common import get_response_json
7+
from xbox.webapi.api.provider.ratelimitedprovider import RateLimitedProvider
8+
9+
from xbox.webapi.common.exceptions import RateLimitExceededException, XboxException
10+
from xbox.webapi.common.ratelimits import CombinedRateLimit
11+
from xbox.webapi.common.ratelimits.models import TimePeriod
12+
13+
14+
def helper_test_combinedratelimit(
15+
crl: CombinedRateLimit, burstLimit: int, sustainLimit: int
16+
):
17+
burst = crl.get_limits_by_period(TimePeriod.BURST)
18+
sustain = crl.get_limits_by_period(TimePeriod.SUSTAIN)
19+
20+
# These functions should return a list with one element
21+
assert type(burst) == list
22+
assert type(sustain) == list
23+
24+
assert len(burst) == 1
25+
assert len(sustain) == 1
26+
27+
# Check that their limits are what we expect
28+
assert burst[0].get_limit() == burstLimit
29+
assert sustain[0].get_limit() == sustainLimit
30+
31+
32+
def test_ratelimitedprovider_rate_limits_same_rw_values(xbl_client):
33+
class child_class(RateLimitedProvider):
34+
RATE_LIMITS = {"burst": 1, "sustain": 2}
35+
36+
instance = child_class(xbl_client)
37+
38+
helper_test_combinedratelimit(instance.rate_limit_read, 1, 2)
39+
helper_test_combinedratelimit(instance.rate_limit_write, 1, 2)
40+
41+
42+
def test_ratelimitedprovider_rate_limits_diff_rw_values(xbl_client):
43+
class child_class(RateLimitedProvider):
44+
RATE_LIMITS = {
45+
"burst": {"read": 1, "write": 2},
46+
"sustain": {"read": 3, "write": 4},
47+
}
48+
49+
instance = child_class(xbl_client)
50+
51+
helper_test_combinedratelimit(instance.rate_limit_read, 1, 3)
52+
helper_test_combinedratelimit(instance.rate_limit_write, 2, 4)
53+
54+
55+
def test_ratelimitedprovider_rate_limits_mixed(xbl_client):
56+
class burst_diff(RateLimitedProvider):
57+
RATE_LIMITS = {"burst": {"read": 1, "write": 2}, "sustain": 3}
58+
59+
burst_diff_inst = burst_diff(xbl_client)
60+
61+
# Sustain values are the same (third paramater)
62+
helper_test_combinedratelimit(burst_diff_inst.rate_limit_read, 1, 3)
63+
helper_test_combinedratelimit(burst_diff_inst.rate_limit_write, 2, 3)
64+
65+
class sustain_diff(RateLimitedProvider):
66+
RATE_LIMITS = {"burst": 4, "sustain": {"read": 5, "write": 6}}
67+
68+
sustain_diff_inst = sustain_diff(xbl_client)
69+
70+
# Burst values are the same (second paramater)
71+
helper_test_combinedratelimit(sustain_diff_inst.rate_limit_read, 4, 5)
72+
helper_test_combinedratelimit(sustain_diff_inst.rate_limit_write, 4, 6)
73+
74+
75+
def test_ratelimitedprovider_rate_limits_missing_values_correct_type(xbl_client):
76+
class child_class(RateLimitedProvider):
77+
RATE_LIMITS = {"incorrect": "values"}
78+
79+
with pytest.raises(XboxException) as exception:
80+
child_class(xbl_client)
81+
82+
ex: XboxException = exception.value
83+
assert "RATE_LIMITS object missing required keys" in ex.args[0]
84+
85+
86+
def test_ratelimitedprovider_rate_limits_not_set(xbl_client):
87+
class child_class(RateLimitedProvider):
88+
pass
89+
90+
with pytest.raises(XboxException) as exception:
91+
child_class(xbl_client)
92+
93+
ex: XboxException = exception.value
94+
assert "RateLimitedProvider as parent class but RATE_LIMITS not set!" in ex.args[0]
95+
96+
97+
def test_ratelimitedprovider_rate_limits_incorrect_key_type(xbl_client):
98+
class child_class(RateLimitedProvider):
99+
RATE_LIMITS = {"burst": True, "sustain": False}
100+
101+
with pytest.raises(XboxException) as exception:
102+
child_class(xbl_client)
103+
104+
ex: XboxException = exception.value
105+
assert "RATE_LIMITS value types not recognised." in ex.args[0]
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_ratelimits_exceeded_burst_only(respx_mock, xbl_client):
110+
async def make_request():
111+
route = respx_mock.get("https://social.xboxlive.com").mock(
112+
return_value=Response(200, json=get_response_json("people_summary_own"))
113+
)
114+
ret = await xbl_client.people.get_friends_summary_own()
115+
116+
assert route.called
117+
118+
# Record the start time to ensure that the timeouts are the correct length
119+
start_time = datetime.now()
120+
121+
# Make as many requests as possible without exceeding
122+
max_request_num = xbl_client.people.RATE_LIMITS["burst"]
123+
for i in range(max_request_num):
124+
await make_request()
125+
126+
# Make another request, ensure that it raises the exception.
127+
with pytest.raises(RateLimitExceededException) as exception:
128+
await make_request()
129+
130+
# Get the error instance from pytest
131+
ex: RateLimitExceededException = exception.value
132+
133+
# Assert that the counter matches the max request num (should not have incremented above max value)
134+
assert ex.rate_limit.get_counter() == max_request_num
135+
136+
# Get the timeout we were issued
137+
try_again_in = ex.rate_limit.get_reset_after()
138+
139+
# Assert that the timeout is the correct length
140+
delta: timedelta = try_again_in - start_time
141+
assert delta.seconds == TimePeriod.BURST.value # 15 seconds
142+
143+
144+
async def helper_reach_and_wait_for_burst(
145+
make_request, start_time, burst_limit: int, expected_counter: int
146+
):
147+
# Make as many requests as possible without exceeding the BURST limit.
148+
for i in range(burst_limit):
149+
await make_request()
150+
151+
# Make another request, ensure that it raises the exception.
152+
with pytest.raises(RateLimitExceededException) as exception:
153+
await make_request()
154+
155+
# Get the error instance from pytest
156+
ex: RateLimitExceededException = exception.value
157+
158+
# Assert that the counter matches the what we expect (burst, 2x burstm etc)
159+
assert ex.rate_limit.get_counter() == expected_counter
160+
161+
# Get the reset_after value
162+
# (if we call it after waiting for it to expire the function will return None)
163+
burst_resets_after = ex.rate_limit.get_reset_after()
164+
165+
# Wait for the burst limit timeout to elapse.
166+
await asyncio.sleep(TimePeriod.BURST.value) # 15 seconds
167+
168+
# Assert that the reset_after value has passed.
169+
assert burst_resets_after < datetime.now()
170+
171+
172+
@pytest.mark.asyncio
173+
async def test_ratelimits_exceeded_sustain_only(respx_mock, xbl_client):
174+
async def make_request():
175+
route = respx_mock.get("https://social.xboxlive.com").mock(
176+
return_value=Response(200, json=get_response_json("people_summary_own"))
177+
)
178+
ret = await xbl_client.people.get_friends_summary_own()
179+
180+
assert route.called
181+
182+
# Record the start time to ensure that the timeouts are the correct length
183+
start_time = datetime.now()
184+
185+
# Get the max requests for this route.
186+
max_request_num = xbl_client.people.RATE_LIMITS["sustain"] # 30
187+
burst_max_request_num = xbl_client.people.RATE_LIMITS["burst"] # 10
188+
189+
# In this case, the BURST limit is three times that of SUSTAIN, so we need to exceed the burst limit three times.
190+
191+
# Exceed the burst limit and wait for it to reset (10 requests)
192+
await helper_reach_and_wait_for_burst(
193+
make_request, start_time, burst_limit=burst_max_request_num, expected_counter=10
194+
)
195+
196+
# Repeat: Exceed the burst limit and wait for it to reset (10 requests)
197+
# Counter (the sustain one will be returned)
198+
# For (CombinedRateLimit).get_counter(), the highest counter is returned. (sustain in this case)
199+
await helper_reach_and_wait_for_burst(
200+
make_request, start_time, burst_limit=burst_max_request_num, expected_counter=20
201+
)
202+
203+
# Now, make the rest of the requests (10 left, 20/30 done!)
204+
for i in range(10):
205+
await make_request()
206+
207+
# Wait for the burst limit to 'reset'.
208+
await asyncio.sleep(TimePeriod.BURST.value) # 15 seconds
209+
210+
# Now, we have made 30 requests.
211+
# The counters should be as follows:
212+
# - BURST: 0* (will reset on next check)
213+
# - SUSTAIN: 30
214+
# The next request we make should exceed the SUSTAIN rate limit.
215+
216+
# Make another request, ensure that it raises the exception.
217+
with pytest.raises(RateLimitExceededException) as exception:
218+
await make_request()
219+
220+
# Get the error instance from pytest
221+
ex: RateLimitExceededException = exception.value
222+
223+
# Get the SingleRateLimit objects from the exception
224+
rl: CombinedRateLimit = ex.rate_limit
225+
burst = rl.get_limits_by_period(TimePeriod.BURST)[0]
226+
sustain = rl.get_limits_by_period(TimePeriod.SUSTAIN)[0]
227+
228+
# Assert that we have only exceeded the sustain limit.
229+
assert not burst.is_exceeded()
230+
assert sustain.is_exceeded()
231+
232+
# Assert that the counter matches the max request num (should not have incremented above max value)
233+
assert ex.rate_limit.get_counter() == max_request_num
234+
235+
# Get the timeout we were issued
236+
try_again_in = ex.rate_limit.get_reset_after()
237+
238+
# Assert that the timeout is the correct length
239+
# The SUSTAIN counter has not been reset during this test, so the try again in should be 300 seconds since we started this test.
240+
delta: timedelta = try_again_in - start_time
241+
assert delta.seconds == TimePeriod.SUSTAIN.value # 300 seconds (5 minutes)

xbox/webapi/api/client.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from xbox.webapi.api.provider.usersearch import UserSearchProvider
2929
from xbox.webapi.api.provider.userstats import UserStatsProvider
3030
from xbox.webapi.authentication.manager import AuthenticationManager
31+
from xbox.webapi.common.exceptions import RateLimitExceededException
32+
from xbox.webapi.common.ratelimits import RateLimit
3133

3234
log = logging.getLogger("xbox.api")
3335

@@ -55,6 +57,9 @@ async def request(
5557
extra_params = kwargs.pop("extra_params", None)
5658
extra_data = kwargs.pop("extra_data", None)
5759

60+
# Rate limit object
61+
rate_limits: RateLimit = kwargs.pop("rate_limits", None)
62+
5863
if include_auth:
5964
# Ensure tokens valid
6065
await self._auth_mgr.refresh_tokens()
@@ -78,10 +83,20 @@ async def request(
7883
data = data or {}
7984
data.update(extra_data)
8085

81-
return await self._auth_mgr.session.request(
86+
if rate_limits:
87+
# Check if rate limits have been exceeded for this endpoint
88+
if rate_limits.is_exceeded():
89+
raise RateLimitExceededException("Rate limit exceeded", rate_limits)
90+
91+
response = await self._auth_mgr.session.request(
8292
method, url, **kwargs, headers=headers, params=params, data=data
8393
)
8494

95+
if rate_limits:
96+
rate_limits.increment()
97+
98+
return response
99+
85100
async def get(self, url: str, **kwargs: Any) -> Response:
86101
return await self.request("GET", url, **kwargs)
87102

xbox/webapi/api/provider/achievements/__init__.py

+31-8
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@
99
AchievementResponse,
1010
RecentProgressResponse,
1111
)
12-
from xbox.webapi.api.provider.baseprovider import BaseProvider
12+
from xbox.webapi.api.provider.ratelimitedprovider import RateLimitedProvider
1313

1414

15-
class AchievementsProvider(BaseProvider):
15+
class AchievementsProvider(RateLimitedProvider):
1616
ACHIEVEMENTS_URL = "https://achievements.xboxlive.com"
1717
HEADERS_GAME_360_PROGRESS = {"x-xbl-contract-version": "1"}
1818
HEADERS_GAME_PROGRESS = {"x-xbl-contract-version": "2"}
1919

20+
RATE_LIMITS = {"burst": 100, "sustain": 300}
21+
2022
async def get_achievements_detail_item(
2123
self, xuid, service_config_id, achievement_id, **kwargs
2224
) -> AchievementResponse:
@@ -33,7 +35,10 @@ async def get_achievements_detail_item(
3335
"""
3436
url = f"{self.ACHIEVEMENTS_URL}/users/xuid({xuid})/achievements/{service_config_id}/{achievement_id}"
3537
resp = await self.client.session.get(
36-
url, headers=self.HEADERS_GAME_PROGRESS, **kwargs
38+
url,
39+
headers=self.HEADERS_GAME_PROGRESS,
40+
rate_limits=self.rate_limit_read,
41+
**kwargs,
3742
)
3843
resp.raise_for_status()
3944
return AchievementResponse(**resp.json())
@@ -54,7 +59,11 @@ async def get_achievements_xbox360_all(
5459
url = f"{self.ACHIEVEMENTS_URL}/users/xuid({xuid})/titleachievements?"
5560
params = {"titleId": title_id}
5661
resp = await self.client.session.get(
57-
url, params=params, headers=self.HEADERS_GAME_360_PROGRESS, **kwargs
62+
url,
63+
params=params,
64+
headers=self.HEADERS_GAME_360_PROGRESS,
65+
rate_limits=self.rate_limit_read,
66+
**kwargs,
5867
)
5968
resp.raise_for_status()
6069
return Achievement360Response(**resp.json())
@@ -75,7 +84,11 @@ async def get_achievements_xbox360_earned(
7584
url = f"{self.ACHIEVEMENTS_URL}/users/xuid({xuid})/achievements?"
7685
params = {"titleId": title_id}
7786
resp = await self.client.session.get(
78-
url, params=params, headers=self.HEADERS_GAME_360_PROGRESS, **kwargs
87+
url,
88+
params=params,
89+
headers=self.HEADERS_GAME_360_PROGRESS,
90+
rate_limits=self.rate_limit_read,
91+
**kwargs,
7992
)
8093
resp.raise_for_status()
8194
return Achievement360Response(**resp.json())
@@ -94,7 +107,10 @@ async def get_achievements_xbox360_recent_progress_and_info(
94107
"""
95108
url = f"{self.ACHIEVEMENTS_URL}/users/xuid({xuid})/history/titles"
96109
resp = await self.client.session.get(
97-
url, headers=self.HEADERS_GAME_360_PROGRESS, **kwargs
110+
url,
111+
headers=self.HEADERS_GAME_360_PROGRESS,
112+
rate_limits=self.rate_limit_read,
113+
**kwargs,
98114
)
99115
resp.raise_for_status()
100116
return Achievement360ProgressResponse(**resp.json())
@@ -115,7 +131,11 @@ async def get_achievements_xboxone_gameprogress(
115131
url = f"{self.ACHIEVEMENTS_URL}/users/xuid({xuid})/achievements?"
116132
params = {"titleId": title_id}
117133
resp = await self.client.session.get(
118-
url, params=params, headers=self.HEADERS_GAME_PROGRESS, **kwargs
134+
url,
135+
params=params,
136+
headers=self.HEADERS_GAME_PROGRESS,
137+
rate_limits=self.rate_limit_read,
138+
**kwargs,
119139
)
120140
resp.raise_for_status()
121141
return AchievementResponse(**resp.json())
@@ -134,7 +154,10 @@ async def get_achievements_xboxone_recent_progress_and_info(
134154
"""
135155
url = f"{self.ACHIEVEMENTS_URL}/users/xuid({xuid})/history/titles"
136156
resp = await self.client.session.get(
137-
url, headers=self.HEADERS_GAME_PROGRESS, **kwargs
157+
url,
158+
headers=self.HEADERS_GAME_PROGRESS,
159+
rate_limits=self.rate_limit_read,
160+
**kwargs,
138161
)
139162
resp.raise_for_status()
140163
return RecentProgressResponse(**resp.json())

0 commit comments

Comments
 (0)