Skip to content

Commit 0bc7a1f

Browse files
authored
Merge pull request #97 from praw-dev/last_request
Replace uses of time.time with time.monotonic(_ns)
2 parents b53edcc + 6b760d0 commit 0bc7a1f

File tree

6 files changed

+47
-29
lines changed

6 files changed

+47
-29
lines changed

CHANGES.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ Unreleased
99
**Changed**
1010

1111
- Drop support for Python 3.8, which was end-of-life on 2024-10-07.
12+
- :class:`RateLimiter` attribute ``next_request_timestamp`` has been removed and
13+
replaced with ``next_request_timestamp_ns``.
14+
15+
**Fixed**
16+
17+
- Add a half-second delay when there are no more requests in the rate limit window and
18+
the window has zero seconds remaining to avoid a semi-rare case where Reddit will
19+
return a 429 response resulting in a :class:`TooManyRequests` exception.
20+
21+
**Removed**
22+
23+
- Remove :class:`RateLimiter` attribute ``reset_timestamp``.
1224

1325
2.4.0 (2023/11/27)
1426
------------------

asyncprawcore/auth.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,21 +150,23 @@ def __init__(self, authenticator: BaseAuthenticator):
150150
self._validate_authenticator()
151151

152152
def _clear_access_token(self):
153-
self._expiration_timestamp: float
153+
self._expiration_timestamp_ns: int
154154
self.access_token: str | None = None
155155
self.scopes: set[str] | None = None
156156

157157
async def _request_token(self, **data: Any):
158158
url = self._authenticator._requestor.reddit_url + const.ACCESS_TOKEN_PATH
159-
pre_request_time = time.time()
159+
pre_request_timestamp_ns = time.monotonic_ns()
160160
async with self._authenticator._post(url=url, **data) as response:
161161
payload = await response.json()
162162
if "error" in payload: # Why are these OKAY responses?
163163
raise OAuthException(
164164
response, payload["error"], payload.get("error_description")
165165
)
166166

167-
self._expiration_timestamp = pre_request_time - 10 + payload["expires_in"]
167+
self._expiration_timestamp_ns = (
168+
pre_request_timestamp_ns + (payload["expires_in"] + 10) * const.NANOSECONDS
169+
)
168170
self.access_token = payload["access_token"]
169171
if "refresh_token" in payload:
170172
self.refresh_token = payload["refresh_token"]
@@ -189,7 +191,8 @@ def is_valid(self) -> bool:
189191
190192
"""
191193
return (
192-
self.access_token is not None and time.time() < self._expiration_timestamp
194+
self.access_token is not None
195+
and time.monotonic_ns() < self._expiration_timestamp_ns
193196
)
194197

195198
async def revoke(self):
@@ -360,7 +363,9 @@ def __init__(
360363
361364
"""
362365
super().__init__(authenticator)
363-
self._expiration_timestamp = time.time() + expires_in
366+
self._expiration_timestamp_ns = (
367+
time.monotonic_ns() + expires_in * const.NANOSECONDS
368+
)
364369
self.access_token = access_token
365370
self.scopes = set(scope.split(" "))
366371

asyncprawcore/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
ACCESS_TOKEN_PATH = "/api/v1/access_token" # noqa: S105
66
AUTHORIZATION_PATH = "/api/v1/authorize" # noqa: S105
7+
NANOSECONDS = 1_000_000_000 # noqa: S105
78
REVOKE_TOKEN_PATH = "/api/v1/revoke_token" # noqa: S105
89
TIMEOUT = float(
910
os.environ.get(

asyncprawcore/rate_limit.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
from aiohttp import ClientResponse
1515

16+
from asyncprawcore.const import NANOSECONDS
17+
1618
log = logging.getLogger(__package__)
1719

1820

@@ -23,8 +25,6 @@ class RateLimiter:
2325
2426
"""
2527

26-
NANOSECONDS = 1_000_000_000
27-
2828
def __init__(self, *, window_size: int):
2929
"""Create an instance of the RateLimit class."""
3030
self.remaining: int | None = None
@@ -61,8 +61,7 @@ async def delay(self):
6161
if self.next_request_timestamp_ns is None:
6262
return
6363
sleep_seconds = (
64-
float(self.next_request_timestamp_ns - time.monotonic_ns())
65-
/ self.NANOSECONDS
64+
float(self.next_request_timestamp_ns - time.monotonic_ns()) / NANOSECONDS
6665
)
6766
if sleep_seconds <= 0:
6867
return
@@ -94,7 +93,7 @@ def update(self, response_headers: Mapping[str, str]):
9493

9594
if self.remaining <= 0:
9695
self.next_request_timestamp_ns = now_ns + max(
97-
self.NANOSECONDS / 2, seconds_to_reset * self.NANOSECONDS
96+
NANOSECONDS / 2, seconds_to_reset * NANOSECONDS
9897
)
9998
return
10099

@@ -114,5 +113,5 @@ def update(self, response_headers: Mapping[str, str]):
114113
),
115114
10,
116115
)
117-
* self.NANOSECONDS
116+
* NANOSECONDS
118117
)

asyncprawcore/sessions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def _log_request(
111111
params: dict[str, int],
112112
url: str,
113113
):
114-
log.debug("Fetching: %s %s at %s", method, url, time.time())
114+
log.debug("Fetching: %s %s at %s", method, url, time.monotonic())
115115
log.debug("Data: %s", pformat(data))
116116
log.debug("Params: %s", pformat(params))
117117

@@ -212,7 +212,7 @@ async def _make_request(
212212
response.headers.get("x-ratelimit-reset"),
213213
response.headers.get("x-ratelimit-remaining"),
214214
response.headers.get("x-ratelimit-used"),
215-
time.time(),
215+
time.monotonic(),
216216
)
217217
yield response, None
218218
except RequestException as exception:

tests/unit/test_rate_limit.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77

8+
from asyncprawcore.const import NANOSECONDS
89
from asyncprawcore.rate_limit import RateLimiter
910

1011
from . import UnitTest
@@ -14,7 +15,7 @@ class TestRateLimiter(UnitTest):
1415
@pytest.fixture
1516
def rate_limiter(self):
1617
rate_limiter = RateLimiter(window_size=600)
17-
rate_limiter.next_request_timestamp_ns = 100 * RateLimiter.NANOSECONDS
18+
rate_limiter.next_request_timestamp_ns = 100 * NANOSECONDS
1819
return rate_limiter
1920

2021
@staticmethod
@@ -28,7 +29,7 @@ def _headers(remaining, used, reset):
2829
@patch("time.monotonic_ns")
2930
@patch("asyncio.sleep")
3031
async def test_delay(self, mock_sleep, mock_monotonic_ns, rate_limiter):
31-
mock_monotonic_ns.return_value = 1 * RateLimiter.NANOSECONDS
32+
mock_monotonic_ns.return_value = 1 * NANOSECONDS
3233
await rate_limiter.delay()
3334
assert mock_monotonic_ns.called
3435
mock_sleep.assert_called_with(99)
@@ -38,7 +39,7 @@ async def test_delay(self, mock_sleep, mock_monotonic_ns, rate_limiter):
3839
async def test_delay__no_sleep_when_time_in_past(
3940
self, mock_sleep, mock_monotonic_ns, rate_limiter
4041
):
41-
mock_monotonic_ns.return_value = 101 * RateLimiter.NANOSECONDS
42+
mock_monotonic_ns.return_value = 101 * NANOSECONDS
4243
await rate_limiter.delay()
4344
assert mock_monotonic_ns.called
4445
assert not mock_sleep.called
@@ -54,7 +55,7 @@ async def test_delay__no_sleep_when_time_is_not_set(self, mock_sleep, rate_limit
5455
async def test_delay__no_sleep_when_times_match(
5556
self, mock_sleep, mock_monotonic_ns, rate_limiter
5657
):
57-
mock_monotonic_ns.return_value = 100 * RateLimiter.NANOSECONDS
58+
mock_monotonic_ns.return_value = 100 * NANOSECONDS
5859
await rate_limiter.delay()
5960
assert mock_monotonic_ns.called
6061
assert not mock_sleep.called
@@ -63,64 +64,64 @@ async def test_delay__no_sleep_when_times_match(
6364
def test_update__compute_delay_with_no_previous_info(
6465
self, mock_monotonic_ns, rate_limiter
6566
):
66-
mock_monotonic_ns.return_value = 100 * RateLimiter.NANOSECONDS
67+
mock_monotonic_ns.return_value = 100 * NANOSECONDS
6768
rate_limiter.update(self._headers(60, 100, 60))
6869
assert rate_limiter.remaining == 60
6970
assert rate_limiter.used == 100
70-
assert rate_limiter.next_request_timestamp_ns == 100 * RateLimiter.NANOSECONDS
71+
assert rate_limiter.next_request_timestamp_ns == 100 * NANOSECONDS
7172

7273
@patch("time.monotonic_ns")
7374
def test_update__compute_delay_with_single_client(
7475
self, mock_monotonic_ns, rate_limiter
7576
):
7677
rate_limiter.window_size = 150
77-
mock_monotonic_ns.return_value = 100 * RateLimiter.NANOSECONDS
78+
mock_monotonic_ns.return_value = 100 * NANOSECONDS
7879
rate_limiter.update(self._headers(50, 100, 60))
7980
assert rate_limiter.remaining == 50
8081
assert rate_limiter.used == 100
81-
assert rate_limiter.next_request_timestamp_ns == 110 * RateLimiter.NANOSECONDS
82+
assert rate_limiter.next_request_timestamp_ns == 110 * NANOSECONDS
8283

8384
@patch("time.monotonic_ns")
8485
def test_update__compute_delay_with_six_clients(
8586
self, mock_monotonic_ns, rate_limiter
8687
):
8788
rate_limiter.remaining = 66
8889
rate_limiter.window_size = 180
89-
mock_monotonic_ns.return_value = 100 * RateLimiter.NANOSECONDS
90+
mock_monotonic_ns.return_value = 100 * NANOSECONDS
9091
rate_limiter.update(self._headers(60, 100, 72))
9192
assert rate_limiter.remaining == 60
9293
assert rate_limiter.used == 100
93-
assert rate_limiter.next_request_timestamp_ns == 104.5 * RateLimiter.NANOSECONDS
94+
assert rate_limiter.next_request_timestamp_ns == 104.5 * NANOSECONDS
9495

9596
@patch("time.monotonic_ns")
9697
def test_update__delay_full_time_with_negative_remaining(
9798
self, mock_monotonic_ns, rate_limiter
9899
):
99-
mock_monotonic_ns.return_value = 37 * RateLimiter.NANOSECONDS
100+
mock_monotonic_ns.return_value = 37 * NANOSECONDS
100101
rate_limiter.update(self._headers(0, 100, 13))
101102
assert rate_limiter.remaining == 0
102103
assert rate_limiter.used == 100
103-
assert rate_limiter.next_request_timestamp_ns == 50 * RateLimiter.NANOSECONDS
104+
assert rate_limiter.next_request_timestamp_ns == 50 * NANOSECONDS
104105

105106
@patch("time.monotonic_ns")
106107
def test_update__delay_full_time_with_zero_remaining(
107108
self, mock_monotonic_ns, rate_limiter
108109
):
109-
mock_monotonic_ns.return_value = 37 * RateLimiter.NANOSECONDS
110+
mock_monotonic_ns.return_value = 37 * NANOSECONDS
110111
rate_limiter.update(self._headers(0, 100, 13))
111112
assert rate_limiter.remaining == 0
112113
assert rate_limiter.used == 100
113-
assert rate_limiter.next_request_timestamp_ns == 50 * RateLimiter.NANOSECONDS
114+
assert rate_limiter.next_request_timestamp_ns == 50 * NANOSECONDS
114115

115116
@patch("time.monotonic_ns")
116117
def test_update__delay_full_time_with_zero_remaining_and_no_sleep_time(
117118
self, mock_monotonic_ns, rate_limiter
118119
):
119-
mock_monotonic_ns.return_value = 37 * RateLimiter.NANOSECONDS
120+
mock_monotonic_ns.return_value = 37 * NANOSECONDS
120121
rate_limiter.update(self._headers(0, 100, 0))
121122
assert rate_limiter.remaining == 0
122123
assert rate_limiter.used == 100
123-
assert rate_limiter.next_request_timestamp_ns == 37.5 * RateLimiter.NANOSECONDS
124+
assert rate_limiter.next_request_timestamp_ns == 37.5 * NANOSECONDS
124125

125126
def test_update__no_change_without_headers(self, rate_limiter):
126127
prev = copy(rate_limiter)

0 commit comments

Comments
 (0)