Skip to content

Commit 961027f

Browse files
authored
Merge pull request #163 from python-astrodynamics/feature/ratelimit-accounting
Count all requests against the rate limit
2 parents 168de31 + 3d52604 commit 961027f

4 files changed

Lines changed: 112 additions & 16 deletions

File tree

newsfragments/163.fixed.2.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Model definition requests are now rate limited like all other requests.

newsfragments/163.fixed.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Requests sent after a rate limit wait are now counted against the rate limit.
2+
Previously they were not, so sustained use could exceed Space-Track's limits and trigger server-side rate limit errors.

src/spacetrack/base.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -754,25 +754,37 @@ def _parse_types(data, predicates):
754754

755755
return data
756756

757-
def _ratelimited_send_generator(self, request, *, stream=False):
758-
"""Send a request, handling rate limiting."""
759-
minute_limit = self._per_minute_throttle.check(self._per_minute_key, 1)
760-
hour_limit = self._per_hour_throttle.check(self._per_hour_key, 1)
761-
762-
sleep_time = 0
757+
def _ratelimit_check_generator(self):
758+
"""Wait until no throttle is limited, then charge all of them.
763759
764-
if minute_limit.limited:
765-
sleep_time = minute_limit.retry_after.total_seconds()
760+
Peeking first means that waiting on one quota does not consume the
761+
others.
762+
"""
763+
throttles = [
764+
(self._per_minute_throttle, self._per_minute_key),
765+
(self._per_hour_throttle, self._per_hour_key),
766+
]
767+
if self._additional_throttle is not None:
768+
throttles.append((self._additional_throttle, self._additional_key))
766769

767-
if hour_limit.limited:
768-
sleep_time = max(sleep_time, hour_limit.retry_after.total_seconds())
770+
while True:
771+
limits = [throttle.peek(key) for throttle, key in throttles]
772+
if not any(limit.limited for limit in limits):
773+
# Another process sharing the store may have used the quota
774+
# since the peek, in which case wait and try again.
775+
limits = [throttle.check(key, 1) for throttle, key in throttles]
776+
777+
limited = [limit for limit in limits if limit.limited]
778+
if not limited:
779+
return
769780

770-
if self._additional_throttle is not None:
771-
additional_limit = self._additional_throttle.check(self._additional_key, 1)
772-
sleep_time = max(sleep_time, additional_limit.retry_after.total_seconds())
781+
yield RateLimitWait(
782+
max(limit.retry_after.total_seconds() for limit in limited)
783+
)
773784

774-
if sleep_time > 0:
775-
yield RateLimitWait(sleep_time)
785+
def _ratelimited_send_generator(self, request, *, stream=False):
786+
"""Send a request, handling rate limiting."""
787+
yield from self._ratelimit_check_generator()
776788

777789
req_event = NormalRequest(request, stream=stream, follow_redirects=True)
778790
resp = yield req_event
@@ -794,6 +806,7 @@ def _ratelimited_send_generator(self, request, *, stream=False):
794806
yield RateLimitWait(
795807
self._per_minute_throttle.rate.period.total_seconds()
796808
)
809+
yield from self._ratelimit_check_generator()
797810
resp = yield req_event
798811

799812
return resp
@@ -881,7 +894,7 @@ def _download_predicate_data_generator(self, class_, controller):
881894
req = self.client.build_request("GET", url)
882895
logger.debug(req.url)
883896

884-
resp = yield NormalRequest(req)
897+
resp = yield from self._ratelimited_send_generator(req)
885898

886899
_raise_for_status(resp)
887900

tests/test_spacetrack.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,86 @@ def test_ratelimit_callback_error(client, httpx2_mock, mock_auth, mock_gp_predic
252252
client.gp()
253253

254254

255+
def test_ratelimit_rechecked_after_wait(
256+
client, httpx2_mock, mock_auth, mock_gp_predicates
257+
):
258+
# With one request per window, every query after the model definition
259+
# request has to wait, which is only the case if requests sent after a
260+
# wait are recorded.
261+
client._per_minute_throttle.rate = Quota(
262+
period=dt.timedelta(milliseconds=50), count=1
263+
)
264+
265+
url = api_url("basicspacedata/query/class/gp")
266+
httpx2_mock.add_response(method="GET", url=url, json={"a": 1}, is_reusable=True)
267+
268+
waits = []
269+
client.callback = waits.append
270+
271+
for _ in range(3):
272+
assert client.gp() == {"a": 1}
273+
274+
assert len(waits) >= 3
275+
276+
# Waiting on the per-minute quota must not have charged the per-hour one
277+
# more than once per request sent (the model definition plus 3 queries).
278+
hour_limit = client._per_hour_throttle.peek(client._per_hour_key)
279+
assert hour_limit.limit - hour_limit.remaining == 4
280+
281+
282+
def test_ratelimit_per_hour(client, httpx2_mock, mock_auth, mock_gp_predicates):
283+
# Trip the per-hour throttle instead of the per-minute one, with a short
284+
# period so that the real wait is brief.
285+
client._per_hour_throttle.rate = Quota(
286+
period=dt.timedelta(milliseconds=50), count=2
287+
)
288+
289+
url = api_url("basicspacedata/query/class/gp")
290+
httpx2_mock.add_response(method="GET", url=url, json={"a": 1}, is_reusable=True)
291+
292+
waits = []
293+
client.callback = waits.append
294+
295+
for _ in range(3):
296+
assert client.gp() == {"a": 1}
297+
298+
assert waits
299+
300+
301+
def test_additional_rate_limit(httpx2_mock, mock_auth, mock_gp_predicates):
302+
url = api_url("basicspacedata/query/class/gp")
303+
httpx2_mock.add_response(method="GET", url=url, json={"a": 1}, is_reusable=True)
304+
305+
with SpaceTrackClient(
306+
"identity",
307+
"password",
308+
additional_rate_limit=Quota(period=dt.timedelta(milliseconds=50), count=1),
309+
) as client:
310+
waits = []
311+
client.callback = waits.append
312+
313+
assert client.gp() == {"a": 1}
314+
assert client.gp() == {"a": 1}
315+
316+
assert waits
317+
318+
319+
def test_modeldef_ratelimit_error(client, httpx2_mock, mock_auth):
320+
# Change ratelimiter period to speed up test
321+
client._per_minute_throttle.rate = Quota(
322+
period=dt.timedelta(milliseconds=50), count=30
323+
)
324+
325+
url = api_url("basicspacedata/modeldef/class/gp")
326+
httpx2_mock.add_response(
327+
method="GET", url=url, status_code=500, text="violated your query rate limit"
328+
)
329+
httpx2_mock.add_response(method="GET", url=url, json={"data": []})
330+
331+
assert client.get_predicates("gp") == []
332+
assert len(httpx2_mock.get_requests(method="GET", url=url)) == 2
333+
334+
255335
def test_predicate_parse_modeldef(client):
256336
predicates_data = [
257337
{

0 commit comments

Comments
 (0)