Skip to content

Commit 3a1b5b7

Browse files
authored
Add proactive rate limiting to prevent exceeding Flickr's query limits (#157)
Implement opt-in rate limiting that throttles requests to respect Flickr's documented 3600 requests/hour limit. This is proactive throttling (sleeping before requests) as opposed to the existing reactive retry handling (retrying after 429 responses). New public functions: - set_rate_limit(requests_per_hour) - Enable/disable rate limiting - get_rate_limit() - Get current configuration - get_rate_limit_status() - Get detailed status including interval Internal function _maybe_wait_for_rate_limit() is called at the start of _make_request_with_retry() to sleep if necessary before making each request.
1 parent 728c542 commit 3a1b5b7

File tree

3 files changed

+349
-0
lines changed

3 files changed

+349
-0
lines changed

flickr_api/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@
3535
from .auth import set_auth_handler as set_auth_handler
3636
from .method_call import disable_cache as disable_cache
3737
from .method_call import enable_cache as enable_cache
38+
from .method_call import get_rate_limit as get_rate_limit
39+
from .method_call import get_rate_limit_status as get_rate_limit_status
3840
from .method_call import get_retry_config as get_retry_config
3941
from .method_call import get_timeout as get_timeout
42+
from .method_call import set_rate_limit as set_rate_limit
4043
from .method_call import set_retry_config as set_retry_config
4144
from .method_call import set_timeout as set_timeout
4245
from .keys import set_keys as set_keys

flickr_api/method_call.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
RETRY_BASE_DELAY: float = 1.0 # Base delay in seconds for exponential backoff
3737
RETRY_MAX_DELAY: float = 60.0 # Maximum delay between retries
3838

39+
# Proactive rate limiting configuration
40+
_RATE_LIMIT_REQUESTS_PER_HOUR: float | None = None
41+
_RATE_LIMIT_LAST_REQUEST: float | None = None
42+
3943

4044
def enable_cache(cache_object: Any | None = None) -> None:
4145
"""enable caching
@@ -109,6 +113,85 @@ def get_retry_config() -> dict[str, Any]:
109113
}
110114

111115

116+
def set_rate_limit(requests_per_hour: float | None) -> None:
117+
"""Enable or disable proactive rate limiting.
118+
119+
Parameters:
120+
-----------
121+
requests_per_hour: float | None
122+
Maximum requests per hour. Set to None to disable rate limiting.
123+
Flickr's documented limit is 3600 requests per hour.
124+
125+
Raises:
126+
-------
127+
ValueError: If requests_per_hour is not positive (zero or negative).
128+
"""
129+
if requests_per_hour is not None and requests_per_hour <= 0:
130+
raise ValueError("requests_per_hour must be positive")
131+
global _RATE_LIMIT_REQUESTS_PER_HOUR
132+
_RATE_LIMIT_REQUESTS_PER_HOUR = requests_per_hour
133+
134+
135+
def get_rate_limit() -> dict[str, float | None]:
136+
"""Get current rate limit configuration.
137+
138+
Returns:
139+
--------
140+
dict with key: requests_per_hour (float | None)
141+
"""
142+
return {"requests_per_hour": _RATE_LIMIT_REQUESTS_PER_HOUR}
143+
144+
145+
def get_rate_limit_status() -> dict[str, Any]:
146+
"""Get detailed rate limit status.
147+
148+
Returns:
149+
--------
150+
dict with keys:
151+
- enabled: bool - Whether rate limiting is active
152+
- requests_per_hour: float | None - Configured limit
153+
- interval_seconds: float - Minimum time between requests (0.0 if disabled)
154+
- last_request_time: float | None - Timestamp of last request
155+
"""
156+
enabled = _RATE_LIMIT_REQUESTS_PER_HOUR is not None
157+
interval = 3600.0 / _RATE_LIMIT_REQUESTS_PER_HOUR if enabled else 0.0
158+
return {
159+
"enabled": enabled,
160+
"requests_per_hour": _RATE_LIMIT_REQUESTS_PER_HOUR,
161+
"interval_seconds": interval,
162+
"last_request_time": _RATE_LIMIT_LAST_REQUEST,
163+
}
164+
165+
166+
def _maybe_wait_for_rate_limit() -> None:
167+
"""Wait if necessary to respect rate limit.
168+
169+
This function should be called before making a request. It will:
170+
1. Do nothing if rate limiting is disabled
171+
2. Do nothing if this is the first request
172+
3. Sleep for the remaining interval time if needed
173+
4. Update the last request timestamp
174+
"""
175+
global _RATE_LIMIT_LAST_REQUEST
176+
177+
if _RATE_LIMIT_REQUESTS_PER_HOUR is None:
178+
return
179+
180+
current_time = time.time()
181+
182+
if _RATE_LIMIT_LAST_REQUEST is not None:
183+
interval = 3600.0 / _RATE_LIMIT_REQUESTS_PER_HOUR
184+
elapsed = current_time - _RATE_LIMIT_LAST_REQUEST
185+
remaining = interval - elapsed
186+
187+
if remaining > 0:
188+
logger.debug("Rate limiting: sleeping for %.2f seconds", remaining)
189+
time.sleep(remaining)
190+
current_time = time.time()
191+
192+
_RATE_LIMIT_LAST_REQUEST = current_time
193+
194+
112195
def _calculate_retry_delay(attempt: int, retry_after: float | None) -> float:
113196
"""Calculate delay before next retry.
114197
@@ -181,6 +264,8 @@ def _make_request_with_retry(
181264
-------
182265
FlickrRateLimitError: If rate limit exceeded and max retries exhausted
183266
"""
267+
_maybe_wait_for_rate_limit()
268+
184269
last_error: FlickrRateLimitError | None = None
185270

186271
for attempt in range(MAX_RETRIES + 1):

test/test_rate_limit_throttle.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""Tests for proactive rate limiting (throttling) in method_call module."""
2+
3+
import unittest
4+
from unittest.mock import patch
5+
6+
from flickr_api import method_call
7+
8+
9+
class TestRateLimitDisabledByDefault(unittest.TestCase):
10+
"""Test that rate limiting is disabled by default."""
11+
12+
def setUp(self):
13+
"""Reset rate limit state."""
14+
method_call.set_rate_limit(None)
15+
16+
def tearDown(self):
17+
"""Reset rate limit state."""
18+
method_call.set_rate_limit(None)
19+
20+
def test_rate_limit_disabled_by_default(self):
21+
"""Rate limiting should be disabled by default."""
22+
result = method_call.get_rate_limit()
23+
self.assertIsNone(result["requests_per_hour"])
24+
25+
26+
class TestSetAndGetRateLimit(unittest.TestCase):
27+
"""Test setting and getting rate limit configuration."""
28+
29+
def setUp(self):
30+
"""Reset rate limit state."""
31+
method_call.set_rate_limit(None)
32+
33+
def tearDown(self):
34+
"""Reset rate limit state."""
35+
method_call.set_rate_limit(None)
36+
37+
def test_set_and_get_rate_limit(self):
38+
"""Can set and get rate limit value."""
39+
method_call.set_rate_limit(3600.0)
40+
result = method_call.get_rate_limit()
41+
self.assertEqual(3600.0, result["requests_per_hour"])
42+
43+
def test_disable_rate_limit(self):
44+
"""Can disable rate limiting by setting to None."""
45+
method_call.set_rate_limit(3600.0)
46+
method_call.set_rate_limit(None)
47+
result = method_call.get_rate_limit()
48+
self.assertIsNone(result["requests_per_hour"])
49+
50+
def test_reject_zero_rate_limit(self):
51+
"""Zero rate limit should raise ValueError."""
52+
with self.assertRaises(ValueError) as ctx:
53+
method_call.set_rate_limit(0.0)
54+
self.assertEqual(str(ctx.exception), "requests_per_hour must be positive")
55+
56+
def test_reject_negative_rate_limit(self):
57+
"""Negative rate limit should raise ValueError."""
58+
with self.assertRaises(ValueError) as ctx:
59+
method_call.set_rate_limit(-100.0)
60+
self.assertEqual(str(ctx.exception), "requests_per_hour must be positive")
61+
62+
63+
class TestIntervalCalculation(unittest.TestCase):
64+
"""Test interval calculation for rate limiting."""
65+
66+
def setUp(self):
67+
"""Reset rate limit state."""
68+
method_call.set_rate_limit(None)
69+
70+
def tearDown(self):
71+
"""Reset rate limit state."""
72+
method_call.set_rate_limit(None)
73+
74+
def test_interval_calculation_3600_per_hour(self):
75+
"""3600 requests/hour = 1.0 second interval."""
76+
method_call.set_rate_limit(3600.0)
77+
status = method_call.get_rate_limit_status()
78+
self.assertEqual(1.0, status["interval_seconds"])
79+
80+
def test_interval_calculation_1800_per_hour(self):
81+
"""1800 requests/hour = 2.0 second interval."""
82+
method_call.set_rate_limit(1800.0)
83+
status = method_call.get_rate_limit_status()
84+
self.assertEqual(2.0, status["interval_seconds"])
85+
86+
def test_interval_calculation_7200_per_hour(self):
87+
"""7200 requests/hour = 0.5 second interval."""
88+
method_call.set_rate_limit(7200.0)
89+
status = method_call.get_rate_limit_status()
90+
self.assertEqual(0.5, status["interval_seconds"])
91+
92+
93+
class TestGetRateLimitStatus(unittest.TestCase):
94+
"""Test get_rate_limit_status function."""
95+
96+
def setUp(self):
97+
"""Reset rate limit state."""
98+
method_call.set_rate_limit(None)
99+
method_call._RATE_LIMIT_LAST_REQUEST = None
100+
101+
def tearDown(self):
102+
"""Reset rate limit state."""
103+
method_call.set_rate_limit(None)
104+
method_call._RATE_LIMIT_LAST_REQUEST = None
105+
106+
def test_get_rate_limit_status_disabled(self):
107+
"""Status shows disabled state correctly."""
108+
status = method_call.get_rate_limit_status()
109+
self.assertFalse(status["enabled"])
110+
self.assertIsNone(status["requests_per_hour"])
111+
self.assertEqual(0.0, status["interval_seconds"])
112+
self.assertIsNone(status["last_request_time"])
113+
114+
def test_get_rate_limit_status_enabled(self):
115+
"""Status shows enabled state correctly."""
116+
method_call.set_rate_limit(3600.0)
117+
status = method_call.get_rate_limit_status()
118+
self.assertTrue(status["enabled"])
119+
self.assertEqual(3600.0, status["requests_per_hour"])
120+
self.assertEqual(1.0, status["interval_seconds"])
121+
self.assertIsNone(status["last_request_time"])
122+
123+
def test_get_rate_limit_status_with_last_request(self):
124+
"""Status includes last request time when set."""
125+
method_call.set_rate_limit(3600.0)
126+
method_call._RATE_LIMIT_LAST_REQUEST = 1000.0
127+
status = method_call.get_rate_limit_status()
128+
self.assertEqual(1000.0, status["last_request_time"])
129+
130+
131+
class TestNoSleepWhenDisabled(unittest.TestCase):
132+
"""Test that no sleep occurs when rate limiting is disabled."""
133+
134+
def setUp(self):
135+
"""Reset rate limit state."""
136+
method_call.set_rate_limit(None)
137+
method_call._RATE_LIMIT_LAST_REQUEST = None
138+
139+
def tearDown(self):
140+
"""Reset rate limit state."""
141+
method_call.set_rate_limit(None)
142+
method_call._RATE_LIMIT_LAST_REQUEST = None
143+
144+
@patch.object(method_call.time, "sleep")
145+
@patch.object(method_call.time, "time", return_value=1000.0)
146+
def test_no_sleep_when_disabled(self, mock_time, mock_sleep):
147+
"""No sleep when rate limiting is disabled."""
148+
method_call._maybe_wait_for_rate_limit()
149+
mock_sleep.assert_not_called()
150+
151+
152+
class TestSleepsWhenIntervalNotElapsed(unittest.TestCase):
153+
"""Test that sleep occurs when interval hasn't elapsed."""
154+
155+
def setUp(self):
156+
"""Reset rate limit state."""
157+
method_call.set_rate_limit(None)
158+
method_call._RATE_LIMIT_LAST_REQUEST = None
159+
160+
def tearDown(self):
161+
"""Reset rate limit state."""
162+
method_call.set_rate_limit(None)
163+
method_call._RATE_LIMIT_LAST_REQUEST = None
164+
165+
@patch.object(method_call.time, "sleep")
166+
@patch.object(method_call.time, "time", return_value=1000.5)
167+
def test_sleeps_when_interval_not_elapsed(self, mock_time, mock_sleep):
168+
"""Sleep for remaining time when interval hasn't elapsed."""
169+
method_call.set_rate_limit(3600.0) # 1 second interval
170+
method_call._RATE_LIMIT_LAST_REQUEST = 1000.0 # 0.5 seconds ago
171+
172+
method_call._maybe_wait_for_rate_limit()
173+
174+
# Should sleep for 0.5 seconds (1.0 - 0.5)
175+
mock_sleep.assert_called_once_with(0.5)
176+
177+
178+
class TestNoSleepWhenIntervalElapsed(unittest.TestCase):
179+
"""Test that no sleep occurs when interval has elapsed."""
180+
181+
def setUp(self):
182+
"""Reset rate limit state."""
183+
method_call.set_rate_limit(None)
184+
method_call._RATE_LIMIT_LAST_REQUEST = None
185+
186+
def tearDown(self):
187+
"""Reset rate limit state."""
188+
method_call.set_rate_limit(None)
189+
method_call._RATE_LIMIT_LAST_REQUEST = None
190+
191+
@patch.object(method_call.time, "sleep")
192+
@patch.object(method_call.time, "time", return_value=1002.0)
193+
def test_no_sleep_when_interval_elapsed(self, mock_time, mock_sleep):
194+
"""No sleep when interval has already elapsed."""
195+
method_call.set_rate_limit(3600.0) # 1 second interval
196+
method_call._RATE_LIMIT_LAST_REQUEST = 1000.0 # 2.0 seconds ago
197+
198+
method_call._maybe_wait_for_rate_limit()
199+
200+
mock_sleep.assert_not_called()
201+
202+
203+
class TestFirstRequestNoSleep(unittest.TestCase):
204+
"""Test that first request doesn't sleep."""
205+
206+
def setUp(self):
207+
"""Reset rate limit state."""
208+
method_call.set_rate_limit(None)
209+
method_call._RATE_LIMIT_LAST_REQUEST = None
210+
211+
def tearDown(self):
212+
"""Reset rate limit state."""
213+
method_call.set_rate_limit(None)
214+
method_call._RATE_LIMIT_LAST_REQUEST = None
215+
216+
@patch.object(method_call.time, "sleep")
217+
@patch.object(method_call.time, "time", return_value=1000.0)
218+
def test_first_request_no_sleep(self, mock_time, mock_sleep):
219+
"""First request (no last_request_time) doesn't sleep."""
220+
method_call.set_rate_limit(3600.0) # Rate limiting enabled
221+
# _RATE_LIMIT_LAST_REQUEST is None (first request)
222+
223+
method_call._maybe_wait_for_rate_limit()
224+
225+
mock_sleep.assert_not_called()
226+
227+
228+
class TestUpdatesLastRequestTime(unittest.TestCase):
229+
"""Test that last request time is updated after waiting."""
230+
231+
def setUp(self):
232+
"""Reset rate limit state."""
233+
method_call.set_rate_limit(None)
234+
method_call._RATE_LIMIT_LAST_REQUEST = None
235+
236+
def tearDown(self):
237+
"""Reset rate limit state."""
238+
method_call.set_rate_limit(None)
239+
method_call._RATE_LIMIT_LAST_REQUEST = None
240+
241+
@patch.object(method_call.time, "sleep")
242+
@patch.object(method_call.time, "time", return_value=1000.0)
243+
def test_updates_last_request_time(self, mock_time, mock_sleep):
244+
"""Last request time is updated after _maybe_wait_for_rate_limit."""
245+
method_call.set_rate_limit(3600.0)
246+
247+
method_call._maybe_wait_for_rate_limit()
248+
249+
self.assertEqual(1000.0, method_call._RATE_LIMIT_LAST_REQUEST)
250+
251+
@patch.object(method_call.time, "sleep")
252+
@patch.object(method_call.time, "time", return_value=1001.0)
253+
def test_updates_last_request_time_after_sleep(self, mock_time, mock_sleep):
254+
"""Last request time is updated to current time after sleeping."""
255+
method_call.set_rate_limit(3600.0) # 1 second interval
256+
method_call._RATE_LIMIT_LAST_REQUEST = 1000.5 # Would need to wait
257+
258+
method_call._maybe_wait_for_rate_limit()
259+
260+
# Should update to the current time after potential sleep
261+
self.assertEqual(1001.0, method_call._RATE_LIMIT_LAST_REQUEST)

0 commit comments

Comments
 (0)