Skip to content

Commit

Permalink
Minor adjustment to rate-limiting
Browse files Browse the repository at this point in the history
pyrate-limiter works on 3.6 now, but keep it optional
  • Loading branch information
JWCook committed May 20, 2021
1 parent 31d8f89 commit 145296b
Show file tree
Hide file tree
Showing 2 changed files with 25 additions and 21 deletions.
38 changes: 21 additions & 17 deletions pyinaturalist/api_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,6 @@
from pyinaturalist.forge_utils import copy_signature
from pyinaturalist.request_params import prepare_request

# Request rate limits. Only compatible with python 3.7+.
# TODO: Remove try-except after dropping support for python 3.6
try:
from pyrate_limiter import Duration, Limiter, RequestRate

REQUEST_RATES = [
RequestRate(REQUESTS_PER_SECOND, Duration.SECOND),
RequestRate(REQUESTS_PER_MINUTE, Duration.MINUTE),
RequestRate(REQUESTS_PER_DAY, Duration.DAY),
]
RATE_LIMITER = Limiter(*REQUEST_RATES)
except ImportError:
RATE_LIMITER = None

# Mock response content to return in dry-run mode
MOCK_RESPONSE = Mock(spec=requests.Response)
MOCK_RESPONSE.json.return_value = {'results': [], 'total_results': 0, 'access_token': ''}
Expand Down Expand Up @@ -111,17 +97,32 @@ def put(url: str, **kwargs) -> requests.Response:

# TODO: Handle error 429 if we still somehow exceed the rate limit?
@contextmanager
def ratelimit(limiter=RATE_LIMITER, bucket=pyinaturalist.user_agent):
def ratelimit(bucket=pyinaturalist.user_agent):
"""Add delays in between requests to stay within the rate limits. If pyrate-limiter is
not installed, this will quietly do nothing.
"""
if limiter:
with limiter.ratelimit(bucket, delay=True, max_delay=MAX_DELAY):
if RATE_LIMITER:
with RATE_LIMITER.ratelimit(bucket, delay=True, max_delay=MAX_DELAY):
yield
else:
yield


def get_limiter():
"""Get a rate limiter object, if pyrate-limiter is installed"""
try:
from pyrate_limiter import Duration, Limiter, RequestRate

requst_rates = [
RequestRate(REQUESTS_PER_SECOND, Duration.SECOND),
RequestRate(REQUESTS_PER_MINUTE, Duration.MINUTE),
RequestRate(REQUESTS_PER_DAY, Duration.DAY),
]
return Limiter(*requst_rates)
except ImportError:
return None


def get_session() -> requests.Session:
"""Get a Session object that will be reused across requests to take advantage of connection
pooling. This is especially relevant for large paginated requests. If used in a multi-threaded
Expand Down Expand Up @@ -158,3 +159,6 @@ def log_request(*args, **kwargs):
"""Log all relevant information about an HTTP request"""
kwargs_strs = [f'{k}={v}' for k, v in kwargs.items()]
logger.info('Request: {}'.format(', '.join(list(args) + kwargs_strs)))


RATE_LIMITER = get_limiter()
8 changes: 4 additions & 4 deletions test/test_api_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,25 +125,25 @@ def test_request_dry_run_disabled(requests_mock):
assert request('GET', 'http://url').json() == real_response


@patch('pyinaturalist.api_requests.RATE_LIMITER', Limiter(RequestRate(5, Duration.SECOND)))
@patch('pyrate_limiter.limit_context_decorator.sleep', side_effect=sleep)
def test_ratelimit(mock_sleep):

limiter = Limiter(RequestRate(5, Duration.SECOND))
mock_func = MagicMock()

for i in range(6):
with ratelimit(limiter, bucket='pytest-1'):
with ratelimit(bucket='pytest-1'):
mock_func()

# With 6 requests and a limit of 5 request/second, there should be a delay for the final request
assert mock_func.call_count == 6
assert mock_sleep.call_count == 1


@patch('pyinaturalist.api_requests.RATE_LIMITER', None)
@patch('pyrate_limiter.limit_context_decorator.sleep')
def test_ratelimit__no_limiter(mock_sleep):
for i in range(70):
with ratelimit(None):
with ratelimit():
pass

assert mock_sleep.call_count == 0

0 comments on commit 145296b

Please sign in to comment.