Skip to content

Commit 76b404b

Browse files
beaufourclaude
andauthored
Add rate limiting handling with automatic retry (#156)
- Add FlickrRateLimitError exception for HTTP 429 responses - Implement automatic retry with exponential backoff on rate limit - Parse Retry-After header when available - Add configurable retry settings: set_retry_config(), get_retry_config() - Default: 3 retries, 1s base delay, 60s max delay The retry behavior can be configured or disabled: flickr_api.set_retry_config(max_retries=0) # Disable retries flickr_api.set_retry_config(max_retries=5, base_delay=2.0) When rate limited and retries are exhausted, raises FlickrRateLimitError with retry_after attribute containing the suggested wait time. Includes 17 new tests for rate limit handling. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5bf0d9a commit 76b404b

File tree

4 files changed

+449
-9
lines changed

4 files changed

+449
-9
lines changed

flickr_api/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@
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_retry_config as get_retry_config
3839
from .method_call import get_timeout as get_timeout
40+
from .method_call import set_retry_config as set_retry_config
3941
from .method_call import set_timeout as set_timeout
4042
from .keys import set_keys as set_keys
43+
from .flickrerrors import FlickrRateLimitError as FlickrRateLimitError
4144
from ._version import __version__ as __version__

flickr_api/flickrerrors.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,39 @@ def __init__(self, status_code: int, content: str) -> None:
6565
FlickrError.__init__(self, "HTTP Server Error %i: %s" % (status_code, content))
6666
self.status_code = status_code
6767
self.content = content
68+
69+
70+
class FlickrRateLimitError(FlickrError):
71+
"""Exception for Flickr Rate Limit Errors (HTTP 429)
72+
73+
Raised when the API rate limit has been exceeded. Contains retry
74+
information to help callers implement backoff strategies.
75+
76+
Parameters:
77+
-----------
78+
retry_after: float | None
79+
Seconds to wait before retrying, from Retry-After header (if provided)
80+
content: str
81+
error content message
82+
"""
83+
84+
retry_after: float | None
85+
content: str
86+
87+
def __init__(self, retry_after: float | None, content: str) -> None:
88+
"""Constructor
89+
90+
Parameters:
91+
-----------
92+
retry_after: float | None
93+
Seconds to wait before retrying (from Retry-After header, if available)
94+
content: str
95+
error content message
96+
"""
97+
if retry_after:
98+
msg = f"Rate limit exceeded. Retry after {retry_after} seconds: {content}"
99+
else:
100+
msg = f"Rate limit exceeded: {content}"
101+
FlickrError.__init__(self, msg)
102+
self.retry_after = retry_after
103+
self.content = content

flickr_api/method_call.py

Lines changed: 159 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
1010
"""
1111

12+
import time
1213
import urllib.parse
1314
import urllib.request
1415
import urllib.error
@@ -19,7 +20,7 @@
1920

2021
from . import keys
2122
from .utils import urlopen_and_read
22-
from .flickrerrors import FlickrError, FlickrAPIError, FlickrServerError
23+
from .flickrerrors import FlickrError, FlickrAPIError, FlickrServerError, FlickrRateLimitError
2324
from .cache import SimpleCache
2425

2526
REST_URL = "https://api.flickr.com/services/rest/"
@@ -30,6 +31,11 @@
3031

3132
logger = logging.getLogger(__name__)
3233

34+
# Rate limit retry configuration
35+
MAX_RETRIES: int = 3
36+
RETRY_BASE_DELAY: float = 1.0 # Base delay in seconds for exponential backoff
37+
RETRY_MAX_DELAY: float = 60.0 # Maximum delay between retries
38+
3339

3440
def enable_cache(cache_object: Any | None = None) -> None:
3541
"""enable caching
@@ -64,6 +70,150 @@ def get_timeout() -> float:
6470
return TIMEOUT
6571

6672

73+
def set_retry_config(
74+
max_retries: int | None = None,
75+
base_delay: float | None = None,
76+
max_delay: float | None = None,
77+
) -> None:
78+
"""Configure rate limit retry behavior.
79+
80+
Parameters:
81+
-----------
82+
max_retries: int, optional
83+
Maximum number of retries on rate limit (default 3). Set to 0 to disable.
84+
base_delay: float, optional
85+
Base delay in seconds for exponential backoff (default 1.0)
86+
max_delay: float, optional
87+
Maximum delay between retries in seconds (default 60.0)
88+
"""
89+
global MAX_RETRIES, RETRY_BASE_DELAY, RETRY_MAX_DELAY
90+
if max_retries is not None:
91+
MAX_RETRIES = max_retries
92+
if base_delay is not None:
93+
RETRY_BASE_DELAY = base_delay
94+
if max_delay is not None:
95+
RETRY_MAX_DELAY = max_delay
96+
97+
98+
def get_retry_config() -> dict[str, Any]:
99+
"""Get current retry configuration.
100+
101+
Returns:
102+
--------
103+
dict with keys: max_retries, base_delay, max_delay
104+
"""
105+
return {
106+
"max_retries": MAX_RETRIES,
107+
"base_delay": RETRY_BASE_DELAY,
108+
"max_delay": RETRY_MAX_DELAY,
109+
}
110+
111+
112+
def _calculate_retry_delay(attempt: int, retry_after: float | None) -> float:
113+
"""Calculate delay before next retry.
114+
115+
Uses Retry-After header if available, otherwise exponential backoff.
116+
117+
Parameters:
118+
-----------
119+
attempt: int
120+
Current retry attempt number (0-indexed)
121+
retry_after: float | None
122+
Value from Retry-After header, if present
123+
124+
Returns:
125+
--------
126+
Delay in seconds
127+
"""
128+
if retry_after is not None and retry_after > 0:
129+
return min(retry_after, RETRY_MAX_DELAY)
130+
131+
# Exponential backoff: base_delay * 2^attempt
132+
delay = RETRY_BASE_DELAY * (2**attempt)
133+
return min(delay, RETRY_MAX_DELAY)
134+
135+
136+
def _parse_retry_after(response: requests.Response) -> float | None:
137+
"""Parse Retry-After header from response.
138+
139+
Parameters:
140+
-----------
141+
response: requests.Response
142+
The HTTP response
143+
144+
Returns:
145+
--------
146+
Seconds to wait, or None if header not present/parseable
147+
"""
148+
retry_after = response.headers.get("Retry-After")
149+
if retry_after is None:
150+
return None
151+
152+
try:
153+
return float(retry_after)
154+
except ValueError:
155+
# Could be an HTTP-date format, but Flickr typically uses seconds
156+
logger.warning("Could not parse Retry-After header: %s", retry_after)
157+
return None
158+
159+
160+
def _make_request_with_retry(
161+
request_url: str,
162+
args: dict[str, Any],
163+
oauth_auth: Any,
164+
) -> requests.Response:
165+
"""Make HTTP request with automatic retry on rate limit errors.
166+
167+
Parameters:
168+
-----------
169+
request_url: str
170+
The URL to request
171+
args: dict
172+
Request arguments
173+
oauth_auth: Any
174+
OAuth authentication object (or None)
175+
176+
Returns:
177+
--------
178+
requests.Response
179+
180+
Raises:
181+
-------
182+
FlickrRateLimitError: If rate limit exceeded and max retries exhausted
183+
"""
184+
last_error: FlickrRateLimitError | None = None
185+
186+
for attempt in range(MAX_RETRIES + 1):
187+
resp = requests.post(request_url, args, auth=oauth_auth, timeout=get_timeout())
188+
189+
if resp.status_code != 429:
190+
return resp
191+
192+
# Rate limited - parse retry info and potentially retry
193+
retry_after = _parse_retry_after(resp)
194+
content = resp.content.decode("utf8") if resp.content else "Too Many Requests"
195+
last_error = FlickrRateLimitError(retry_after, content)
196+
197+
if attempt >= MAX_RETRIES:
198+
logger.warning(
199+
"Rate limit exceeded, max retries (%d) exhausted",
200+
MAX_RETRIES,
201+
)
202+
break
203+
204+
delay = _calculate_retry_delay(attempt, retry_after)
205+
logger.warning(
206+
"Rate limit exceeded (attempt %d/%d), retrying in %.1f seconds",
207+
attempt + 1,
208+
MAX_RETRIES + 1,
209+
delay,
210+
)
211+
time.sleep(delay)
212+
213+
# If we get here, we've exhausted retries
214+
raise last_error # type: ignore[misc]
215+
216+
67217
def send_request(url, data):
68218
"""send a http request."""
69219
req = urllib.request.Request(url, data.encode())
@@ -145,19 +295,19 @@ def call_api(
145295
args = dict(oauth_request.items())
146296

147297
if CACHE is None:
148-
resp = requests.post(request_url, args, auth=oauth_auth, timeout=get_timeout())
298+
resp = _make_request_with_retry(request_url, args, oauth_auth)
149299
else:
150300
cachekey = {k: v for k, v in args.items() if k not in IGNORED_FIELDS}
151301
cachekey = urllib.parse.urlencode(cachekey)
152302

153-
resp = CACHE.get(cachekey) or requests.post(
154-
request_url, args, auth=oauth_auth, timeout=get_timeout()
155-
)
156-
if cachekey not in CACHE:
157-
CACHE.set(cachekey, resp)
158-
logger.debug("NO HIT for cache key: %s" % cachekey)
303+
cached_resp = CACHE.get(cachekey)
304+
if cached_resp:
305+
resp = cached_resp
306+
logger.debug(" HIT for cache key: %s", cachekey)
159307
else:
160-
logger.debug(" HIT for cache key: %s" % cachekey)
308+
resp = _make_request_with_retry(request_url, args, oauth_auth)
309+
CACHE.set(cachekey, resp)
310+
logger.debug("NO HIT for cache key: %s", cachekey)
161311

162312
if raw:
163313
return resp.content

0 commit comments

Comments
 (0)