Skip to content

Commit 46c8d73

Browse files
authored
Merge pull request #88 from JWCook/fractional-ratelimits
Add support for floating point values for rate limits
2 parents 3e9a833 + 52d7864 commit 46c8d73

File tree

4 files changed

+45
-8
lines changed

4 files changed

+45
-8
lines changed

HISTORY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# History
2+
3+
## 0.5.0 (Unreleased)
4+
* Add support for floating point values for rate limits
5+
26
## 0.4.2 (2023-09-27)
37
* Update conda-forge package to restrict pyrate-limiter to <3.0
48

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,5 @@ ignore_missing_imports = true
7272

7373
[tool.ruff]
7474
output-format = 'grouped'
75-
line-length = 120
75+
line-length = 110
7676
select = ['B', 'C4','C90', 'E', 'F']

requests_ratelimiter/requests_ratelimiter.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from fractions import Fraction
12
from inspect import signature
23
from logging import getLogger
34
from time import time
@@ -42,7 +43,7 @@ def __init__(
4243
):
4344
# Translate request rate values into RequestRate objects
4445
rates = [
45-
RequestRate(limit, interval)
46+
_convert_rate(limit, interval)
4647
for interval, limit in {
4748
Duration.SECOND * burst: per_second * burst,
4849
Duration.MINUTE: per_minute,
@@ -52,6 +53,11 @@ def __init__(
5253
}.items()
5354
if limit
5455
]
56+
if rates and not limiter:
57+
logger.debug(
58+
"Creating Limiter with rates:\n%s",
59+
"\n".join([f"{r.limit}/{r.interval}s" for r in rates]),
60+
)
5561

5662
# If using a persistent backend, we don't want to use monotonic time (the default)
5763
if bucket_class not in (MemoryListBucket, MemoryQueueBucket) and not time_function:
@@ -69,7 +75,7 @@ def __init__(
6975
self._default_bucket = str(uuid4())
7076

7177
# If the superclass is an adapter or custom Session, pass along any valid keyword arguments
72-
session_kwargs = get_valid_kwargs(super().__init__, kwargs)
78+
session_kwargs = _get_valid_kwargs(super().__init__, kwargs)
7379
super().__init__(**session_kwargs) # type: ignore # Base Session doesn't take any kwargs
7480

7581
# Conveniently, both Session.send() and HTTPAdapter.send() have a mostly consistent signature
@@ -108,7 +114,7 @@ def _fill_bucket(self, request: PreparedRequest):
108114
If the server also has an hourly limit, we don't have enough information to know if we've
109115
exceeded that limit or how long to delay, so we'll keep delaying in 1-minute intervals.
110116
"""
111-
logger.info(f'Rate limit exceeded for {request.url}; filling limiter bucket')
117+
logger.info(f"Rate limit exceeded for {request.url}; filling limiter bucket")
112118
bucket = self.limiter.bucket_group[self._bucket_name(request)]
113119

114120
# Determine how many requests we've made within the smallest defined time interval
@@ -166,7 +172,16 @@ class LimiterAdapter(LimiterMixin, HTTPAdapter): # type: ignore # send signatu
166172
"""
167173

168174

169-
def get_valid_kwargs(func: Callable, kwargs: Dict) -> Dict:
175+
def _convert_rate(limit: float, interval: float) -> RequestRate:
176+
"""Handle fractional rate limits by converting to a whole number of requests per interval"""
177+
# Convert both limit and interval to fractions, and adjust for floating point weirdness
178+
f1 = Fraction(limit).limit_denominator(1000)
179+
f2 = Fraction(interval).limit_denominator(1000)
180+
rate_fraction = f1 / f2
181+
return RequestRate(rate_fraction.numerator, rate_fraction.denominator)
182+
183+
184+
def _get_valid_kwargs(func: Callable, kwargs: Dict) -> Dict:
170185
"""Get the subset of non-None ``kwargs`` that are valid params for ``func``"""
171186
sig_params = list(signature(func).parameters)
172187
return {k: v for k, v in kwargs.items() if k in sig_params and v is not None}

test/test_requests_ratelimiter.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
from time import sleep
1515
from unittest.mock import patch
1616

17+
import pytest
1718
from pyrate_limiter import Duration, Limiter, RequestRate
1819
from requests import Response, Session
1920
from requests.adapters import HTTPAdapter
2021

2122
from requests_ratelimiter import LimiterAdapter, LimiterMixin, LimiterSession
23+
from requests_ratelimiter.requests_ratelimiter import _convert_rate
2224

23-
patch_sleep = patch('pyrate_limiter.limit_context_decorator.sleep', side_effect=sleep)
25+
patch_sleep = patch("pyrate_limiter.limit_context_decorator.sleep", side_effect=sleep)
2426
rate = RequestRate(5, Duration.SECOND)
2527

2628

@@ -38,7 +40,7 @@ def test_limiter_session(mock_sleep):
3840

3941

4042
@patch_sleep
41-
@patch.object(HTTPAdapter, 'send')
43+
@patch.object(HTTPAdapter, "send")
4244
def test_limiter_adapter(mock_send, mock_sleep):
4345
# To allow mounting a mock:// URL, we need to patch HTTPAdapter.send()
4446
# so it doesn't validate the protocol
@@ -49,7 +51,7 @@ def test_limiter_adapter(mock_send, mock_sleep):
4951

5052
session = Session()
5153
adapter = LimiterAdapter(per_second=5)
52-
session.mount('http+mock://', adapter)
54+
session.mount("http+mock://", adapter)
5355

5456
for _ in range(5):
5557
session.get(MOCKED_URL)
@@ -138,3 +140,19 @@ def test_limit_status_disabled(mock_sleep):
138140
session.get(MOCKED_URL_429)
139141
session.get(MOCKED_URL_429)
140142
assert mock_sleep.called is False
143+
144+
145+
@pytest.mark.parametrize(
146+
"limit, interval, expected_limit, expected_interval",
147+
[
148+
(5, 1, 5, 1),
149+
(0.5, 1, 1, 2),
150+
(1, 0.5, 2, 1),
151+
(0.1, 0.5, 1, 5),
152+
(0.001, 0.05, 1, 50),
153+
],
154+
)
155+
def test_convert_rate(limit, interval, expected_limit, expected_interval):
156+
rate = _convert_rate(limit, interval)
157+
assert rate.limit == expected_limit
158+
assert rate.interval == expected_interval

0 commit comments

Comments
 (0)