Skip to content

Commit a1da133

Browse files
remove retries to avoid rate limiting #1195 (#1198)
1 parent 7c113a9 commit a1da133

File tree

9 files changed

+121
-115
lines changed

9 files changed

+121
-115
lines changed

CHANGELOG.md

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

33
## Unreleased
44

5+
- fix: retries trigger rate limiting [#1195](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1195)
56
- fix: smtp & script notifications terminate the program [#898](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/898)
67

78
## 1.29.0 (2025-07-19)

src/icloudpd/base.py

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

66
from requests import Timeout
77
from requests.exceptions import ConnectionError
8+
from urllib3.exceptions import NewConnectionError
89

910
import foundation
1011
from foundation.core import compose, constant, identity
@@ -1229,42 +1230,34 @@ def retrier(
12291230

12301231

12311232
def session_error_handle_builder(
1232-
logger: Logger, icloud: PyiCloudService
1233-
) -> Callable[[Exception, int], None]:
1234-
"""Build handler for session error"""
1235-
1236-
def session_error_handler(ex: Exception, attempt: int) -> None:
1237-
"""Handles session errors in the PhotoAlbum photos iterator"""
1238-
if "Invalid global session" in str(ex):
1239-
if attempt > constants.MAX_RETRIES:
1240-
logger.error("iCloud re-authentication failed. Please try again later.")
1241-
raise ex
1233+
logger: Logger, icloud: PyiCloudService, ex: Exception, attempt: int
1234+
) -> None:
1235+
"""Handles session errors in the PhotoAlbum photos iterator"""
1236+
if "Invalid global session" in str(ex):
1237+
if constants.MAX_RETRIES == 0:
12421238
logger.error("Session error, re-authenticating...")
1243-
if attempt > 1:
1244-
# If the first re-authentication attempt failed,
1245-
# start waiting a few seconds before retrying in case
1246-
# there are some issues with the Apple servers
1247-
time.sleep(constants.WAIT_SECONDS * attempt)
1248-
icloud.authenticate()
1249-
1250-
return session_error_handler
1251-
1252-
1253-
def internal_error_handle_builder(logger: logging.Logger) -> Callable[[Exception, int], None]:
1254-
"""Build handler for internal error"""
1255-
1256-
def internal_error_handler(ex: Exception, attempt: int) -> None:
1257-
"""Handles session errors in the PhotoAlbum photos iterator"""
1258-
if "INTERNAL_ERROR" in str(ex):
1259-
if attempt > constants.MAX_RETRIES:
1260-
logger.error("Internal Error at Apple.")
1261-
raise ex
1262-
logger.error("Internal Error at Apple, retrying...")
1239+
if attempt > constants.MAX_RETRIES:
1240+
logger.error("iCloud re-authentication failed. Please try again later.")
1241+
raise ex
1242+
logger.error("Session error, re-authenticating...")
1243+
if attempt > 1:
1244+
# If the first re-authentication attempt failed,
12631245
# start waiting a few seconds before retrying in case
12641246
# there are some issues with the Apple servers
12651247
time.sleep(constants.WAIT_SECONDS * attempt)
1248+
icloud.authenticate()
1249+
12661250

1267-
return internal_error_handler
1251+
def internal_error_handle_builder(logger: logging.Logger, ex: Exception, attempt: int) -> None:
1252+
"""Handles session errors in the PhotoAlbum photos iterator"""
1253+
if "INTERNAL_ERROR" in str(ex):
1254+
if attempt > constants.MAX_RETRIES:
1255+
logger.error("Internal Error at Apple.")
1256+
raise ex
1257+
logger.error("Internal Error at Apple, retrying...")
1258+
# start waiting a few seconds before retrying in case
1259+
# there are some issues with the Apple servers
1260+
time.sleep(constants.WAIT_SECONDS * attempt)
12681261

12691262

12701263
def compose_handlers(
@@ -1378,8 +1371,8 @@ def core(
13781371
album_phrase = f" from album {album}" if album else ""
13791372
logger.debug(f"Looking up all photos{videos_phrase}{album_phrase}...")
13801373

1381-
session_exception_handler = session_error_handle_builder(logger, icloud)
1382-
internal_error_handler = internal_error_handle_builder(logger)
1374+
session_exception_handler = partial(session_error_handle_builder, logger, icloud)
1375+
internal_error_handler = partial(internal_error_handle_builder, logger)
13831376

13841377
error_handler = compose_handlers(
13851378
[session_exception_handler, internal_error_handler]
@@ -1541,7 +1534,7 @@ def should_break(counter: Counter) -> bool:
15411534
return 1
15421535
else:
15431536
pass
1544-
except (ConnectionError, TimeoutError, Timeout) as _error:
1537+
except (ConnectionError, TimeoutError, Timeout, NewConnectionError) as _error:
15451538
logger.info("Cannot connect to Apple iCloud service")
15461539
# logger.debug(error)
15471540
# it not watching then return error

src/icloudpd/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
from typing import Final
44

55
# For retrying connection after timeouts and errors
6-
MAX_RETRIES: Final[int] = 5
6+
MAX_RETRIES: Final[int] = 0
77
WAIT_SECONDS: Final[int] = 5

src/icloudpd/download.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ def download_media(
112112
if not mkdirs_local(logger, download_path):
113113
return False
114114

115-
for retries in range(constants.MAX_RETRIES):
115+
retries = 0
116+
while True:
116117
try:
117118
photo_response = photo.download(version.url)
118119
if photo_response:
@@ -134,6 +135,9 @@ def download_media(
134135

135136
icloud.authenticate()
136137
else:
138+
# short circuiting 0 retries
139+
if retries == constants.MAX_RETRIES:
140+
break
137141
# you end up here when p.e. throttling by Apple happens
138142
wait_time = (retries + 1) * constants.WAIT_SECONDS
139143
logger.error(
@@ -150,7 +154,10 @@ def download_media(
150154
download_path,
151155
)
152156
break
153-
else:
157+
retries = retries + 1
158+
if retries >= constants.MAX_RETRIES:
159+
break
160+
if retries >= constants.MAX_RETRIES:
154161
logger.error(
155162
"Could not download %s. Please try again later.",
156163
photo.filename,

src/pyicloud_ipd/exceptions.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,12 @@ class PyiCloudException(Exception):
1010
#API
1111
class PyiCloudAPIResponseException(PyiCloudException):
1212
"""iCloud response exception."""
13-
def __init__(self, reason:str, code:Optional[str]=None, retry:bool=False):
13+
def __init__(self, reason:str, code:Optional[str]=None):
1414
self.reason = reason
1515
self.code = code
1616
message = reason or ""
1717
if code:
1818
message += " (%s)" % code
19-
if retry:
20-
message += ". Retrying ..."
2119

2220
super().__init__(message)
2321

src/pyicloud_ipd/session.py

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,6 @@ def request(self, method: str, url, **kwargs): # type: ignore
6060

6161
request_logger.debug("%s %s %s", method, url, kwargs.get("data", ""))
6262

63-
has_retried = kwargs.get("retried")
64-
kwargs.pop("retried", None)
6563
if "timeout" not in kwargs and self.service.http_timeout is not None:
6664
kwargs["timeout"] = self.service.http_timeout
6765
response = throw_on_503(super().request(method, url, **kwargs))
@@ -91,35 +89,6 @@ def request(self, method: str, url, **kwargs): # type: ignore
9189
content_type not in json_mimetypes
9290
or response.status_code in [421, 450, 500]
9391
):
94-
try:
95-
# pylint: disable=protected-access
96-
fmip_url = self.service._get_webservice_url("findme")
97-
if (
98-
has_retried is None
99-
and response.status_code in [421, 450, 500]
100-
and fmip_url in url
101-
):
102-
# Handle re-authentication for Find My iPhone
103-
LOGGER.debug("Re-authenticating Find My iPhone service")
104-
try:
105-
# If 450, authentication requires a full sign in to the account
106-
service = None if response.status_code == 450 else "find"
107-
self.service.authenticate(True, service)
108-
109-
except PyiCloudAPIResponseException:
110-
LOGGER.debug("Re-authentication failed")
111-
kwargs["retried"] = True
112-
return self.request(method, url, **kwargs)
113-
except Exception:
114-
pass
115-
116-
if has_retried is None and response.status_code in [421, 450, 500]:
117-
api_error = PyiCloudAPIResponseException(
118-
response.reason, str(response.status_code), True
119-
)
120-
request_logger.debug(api_error)
121-
kwargs["retried"] = True
122-
return self.request(method, url, **kwargs)
12392

12493
self._raise_error(str(response.status_code), response.reason)
12594

tests/test_autodelete_photos.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ def test_autodelete_photos(self) -> None:
359359
f"{file_name} not expected, but present"
360360
)
361361

362+
@pytest.mark.skipif(constants.MAX_RETRIES == 0, reason="Disabled when MAX_RETRIES set to 0")
362363
def test_retry_delete_after_download_session_error(self) -> None:
363364
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
364365
cookie_dir = os.path.join(base_dir, "cookie")
@@ -434,16 +435,24 @@ def mocked_authenticate(self: PyiCloudService) -> None:
434435
self._caplog.text,
435436
)
436437

437-
# Error msg should be repeated 5 times
438+
# Error msg should be repeated always 1 time
438439
self.assertEqual(
439440
self._caplog.text.count("Session error, re-authenticating..."),
440441
1,
441-
"Re-auth message count",
442+
"retry count",
442443
)
443444

444-
self.assertEqual(pa_delete.call_count, 2, "delete call count")
445-
# Make sure we only call sleep 4 times (skip the first retry)
446-
self.assertEqual(sleep_mock.call_count, 0, "Sleep call count")
445+
self.assertEqual(
446+
pa_delete.call_count,
447+
1 + min(1, constants.MAX_RETRIES),
448+
"delete call count",
449+
)
450+
# Make sure we only call sleep 0 times (skip the first retry)
451+
self.assertEqual(
452+
sleep_mock.call_count,
453+
0,
454+
"sleep count",
455+
)
447456
self.assertEqual(result.exit_code, 0, "Exit code")
448457

449458
# check files
@@ -529,19 +538,21 @@ def mocked_authenticate(self: PyiCloudService) -> None:
529538
self._caplog.text,
530539
)
531540

532-
# Error msg should be repeated 5 times
541+
# Error msg should be repeated MAX_RETRIES times
533542
self.assertEqual(
534543
self._caplog.text.count("Session error, re-authenticating..."),
535-
constants.MAX_RETRIES,
536-
"Re-auth message count",
544+
max(1, constants.MAX_RETRIES),
545+
"retry count",
537546
)
538547

539548
self.assertEqual(
540549
pa_delete.call_count, constants.MAX_RETRIES + 1, "delete call count"
541550
)
542-
# Make sure we only call sleep 4 times (skip the first retry)
551+
# Make sure we only call sleep MAX_RETRIES-1 times (skip the first retry)
543552
self.assertEqual(
544-
sleep_mock.call_count, constants.MAX_RETRIES - 1, "Sleep call count"
553+
sleep_mock.call_count,
554+
max(0, constants.MAX_RETRIES - 1),
555+
"sleep count",
545556
)
546557
self.assertEqual(result.exit_code, 1, "Exit code")
547558

@@ -555,6 +566,7 @@ def mocked_authenticate(self: PyiCloudService) -> None:
555566

556567
assert sum(1 for _ in files_in_result) == 1
557568

569+
@pytest.mark.skipif(constants.MAX_RETRIES == 0, reason="Disabled when MAX_RETRIES set to 0")
558570
def test_retry_delete_after_download_internal_error(self) -> None:
559571
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
560572
cookie_dir = os.path.join(base_dir, "cookie")
@@ -616,16 +628,20 @@ def mock_raise_response_error(
616628
self._caplog.text,
617629
)
618630

619-
# Error msg should be repeated 5 times
631+
# Error msg should be repeated MAX_RETRIES times
620632
self.assertEqual(
621633
self._caplog.text.count("Internal Error at Apple, retrying..."),
622-
1,
623-
"Retry message count",
634+
min(1, constants.MAX_RETRIES),
635+
"retry count",
624636
)
625637

626-
self.assertEqual(pa_delete.call_count, 2, "delete call count")
638+
self.assertEqual(
639+
pa_delete.call_count, 1 + min(1, constants.MAX_RETRIES), "delete count"
640+
)
627641
# Make sure we only call sleep 4 times (skip the first retry)
628-
self.assertEqual(sleep_mock.call_count, 1, "Sleep call count")
642+
self.assertEqual(
643+
sleep_mock.call_count, min(1, constants.MAX_RETRIES), "sleep count"
644+
)
629645
self.assertEqual(result.exit_code, 0, "Exit code")
630646

631647
# check files
@@ -701,16 +717,14 @@ def mock_raise_response_error(
701717
self.assertEqual(
702718
self._caplog.text.count("Internal Error at Apple, retrying..."),
703719
constants.MAX_RETRIES,
704-
"Retry message count",
720+
"retry count",
705721
)
706722

707723
self.assertEqual(
708-
pa_delete.call_count, constants.MAX_RETRIES + 1, "delete call count"
724+
pa_delete.call_count, constants.MAX_RETRIES + 1, "delete count"
709725
)
710726
# Make sure we only call sleep N times (skip the first retry)
711-
self.assertEqual(
712-
sleep_mock.call_count, constants.MAX_RETRIES, "Sleep call count"
713-
)
727+
self.assertEqual(sleep_mock.call_count, constants.MAX_RETRIES, "sleep count")
714728
self.assertEqual(result.exit_code, 1, "Exit code")
715729

716730
# check files

0 commit comments

Comments
 (0)