Skip to content

Commit 8919d29

Browse files
committed
add configurable rate limit, backoff settings to flickr importer
1 parent 7ca7e0d commit 8919d29

1 file changed

Lines changed: 54 additions & 10 deletions

File tree

  • images/management/commands/importers

images/management/commands/importers/flickr.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@
2424

2525
import flickrapi
2626
from django.conf import settings
27+
from flickrapi.exceptions import FlickrError
2728
from tqdm import tqdm
2829

2930
from images.models import Collection, Image, Source
3031
from images.utils import R2Uploader
3132

32-
POLITE_WAIT_SECS = 1.0
33+
DEFAULT_POLITE_WAIT_SECS = 1.0
34+
BACKOFF_MULTIPLIER = 2
35+
MAX_BACKOFF_SECS = 120
36+
MAX_RETRIES = 5
3337

3438
# Flickr license IDs that represent public domain / no known restrictions
3539
# See https://www.flickr.com/services/api/flickr.photos.licenses.getInfo.html
@@ -40,6 +44,40 @@
4044
}
4145

4246

47+
def prompt_wait_secs():
48+
"""Ask the user to configure the polite wait time between API requests."""
49+
choice = input(
50+
f"\nSeconds to wait between API requests [{DEFAULT_POLITE_WAIT_SECS}]: "
51+
).strip()
52+
if not choice:
53+
return DEFAULT_POLITE_WAIT_SECS
54+
try:
55+
value = float(choice)
56+
if value < 0:
57+
print("Wait time cannot be negative, using default.")
58+
return DEFAULT_POLITE_WAIT_SECS
59+
return value
60+
except ValueError:
61+
print("Invalid number, using default.")
62+
return DEFAULT_POLITE_WAIT_SECS
63+
64+
65+
def flickr_call_with_backoff(func, *args, **kwargs):
66+
"""Call a Flickr API method with exponential backoff on 429 errors."""
67+
backoff = MAX_BACKOFF_SECS / (BACKOFF_MULTIPLIER ** (MAX_RETRIES - 1))
68+
for attempt in range(1, MAX_RETRIES + 1):
69+
try:
70+
return func(*args, **kwargs)
71+
except FlickrError as e:
72+
if "429" in str(e) and attempt < MAX_RETRIES:
73+
wait = min(backoff, MAX_BACKOFF_SECS)
74+
tqdm.write(f" ⏳ Rate limited (429), backing off {wait:.0f}s...")
75+
sleep(wait)
76+
backoff *= BACKOFF_MULTIPLIER
77+
else:
78+
raise
79+
80+
4381
def extract_album_id(album_input):
4482
"""Extract a Flickr album (photoset) ID from a URL or raw ID string."""
4583
match = re.search(r"albums/(\d+)", album_input)
@@ -243,7 +281,7 @@ def get_or_create_collection(source, album_info):
243281

244282
def get_original_url(flickr_json, photo_id):
245283
"""Get the URL of the largest available size for a photo."""
246-
sizes = flickr_json.photos.getSizes(photo_id=photo_id)
284+
sizes = flickr_call_with_backoff(flickr_json.photos.getSizes, photo_id=photo_id)
247285
size_list = sizes["sizes"]["size"]
248286

249287
# Prefer Original, then Large, then whatever is biggest
@@ -257,7 +295,7 @@ def get_original_url(flickr_json, photo_id):
257295
return size_list[-1]["source"] if size_list else None
258296

259297

260-
def fetch_album_photos(flickr, album_id, total):
298+
def fetch_album_photos(flickr, album_id, total, wait_secs):
261299
"""Paginate through all photos in an album, yielding each photo dict.
262300
263301
Handles pagination via flickr.photosets.getPhotos with parsed-json format,
@@ -267,7 +305,8 @@ def fetch_album_photos(flickr, album_id, total):
267305
pages = (total + per_page - 1) // per_page
268306

269307
for page in range(1, pages + 1):
270-
resp = flickr.photosets.getPhotos(
308+
resp = flickr_call_with_backoff(
309+
flickr.photosets.getPhotos,
271310
photoset_id=album_id,
272311
extras="description,license,owner_name,date_taken,url_o",
273312
per_page=per_page,
@@ -276,10 +315,10 @@ def fetch_album_photos(flickr, album_id, total):
276315
for photo in resp["photoset"]["photo"]:
277316
yield photo
278317
if page < pages:
279-
sleep(POLITE_WAIT_SECS)
318+
sleep(wait_secs)
280319

281320

282-
def process_album(flickr, album_id, collection, owner, total, options):
321+
def process_album(flickr, album_id, collection, owner, total, options, wait_secs):
283322
"""Enumerate photos in an album and import them.
284323
285324
Mode 1 ("album"): parse metadata from descriptions, import everything.
@@ -292,7 +331,7 @@ def process_album(flickr, album_id, collection, owner, total, options):
292331
skipped = 0
293332
errors = 0
294333

295-
photos = fetch_album_photos(flickr, album_id, total)
334+
photos = fetch_album_photos(flickr, album_id, total, wait_secs)
296335

297336
for photo in tqdm(photos, total=total, desc="Processing photos"):
298337
if max_images and imported >= max_images:
@@ -340,7 +379,7 @@ def process_album(flickr, album_id, collection, owner, total, options):
340379
continue
341380

342381
# Get the highest-resolution image URL
343-
sleep(POLITE_WAIT_SECS)
382+
sleep(wait_secs)
344383
try:
345384
image_url = get_original_url(flickr, photo_id)
346385
except Exception as e:
@@ -380,7 +419,7 @@ def process_album(flickr, album_id, collection, owner, total, options):
380419
tqdm.write(f" ✗ DB error for {photo_id}: {e}")
381420
errors += 1
382421

383-
sleep(POLITE_WAIT_SECS)
422+
sleep(wait_secs)
384423

385424
return imported, skipped, errors
386425

@@ -414,9 +453,13 @@ def handle(options):
414453
print(f"Album ID: {album_id}")
415454

416455
flickr = get_flickr_client()
456+
wait_secs = prompt_wait_secs()
457+
print(f"Wait between requests: {wait_secs}s")
417458

418459
# Fetch album metadata
419-
album_info_resp = flickr.photosets.getInfo(photoset_id=album_id)
460+
album_info_resp = flickr_call_with_backoff(
461+
flickr.photosets.getInfo, photoset_id=album_id
462+
)
420463
photoset = album_info_resp["photoset"]
421464
album_info = {
422465
"id": album_id,
@@ -445,6 +488,7 @@ def handle(options):
445488
album_info["owner"],
446489
int(album_info["count"]),
447490
options,
491+
wait_secs,
448492
)
449493

450494
# Summary

0 commit comments

Comments
 (0)