Skip to content

Commit e616155

Browse files
authored
Setting timeout for HTTP requests does not hanging forever if the download speed is zero (addressing an issue when Apple does not response to HTTP GET) (#1114)
1 parent f69efb5 commit e616155

File tree

5 files changed

+86
-24
lines changed

5 files changed

+86
-24
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- fix: timeout set to 30 seconds for HTTP requests [#793](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/793)
6+
57
## 1.27.4 (2025-04-15)
68

79
- fix: broken pypi publishing [#1105](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1105)
@@ -87,7 +89,7 @@
8789

8890
## 1.23.0 (2024-07-25)
8991

90-
- feature: update webui and allow to cancel and resume sync
92+
- feature: update webui and allow to cancel and resume sync
9193
- deprecate linux 386 and arm v6 support
9294
- add linux musl builds
9395

src/icloudpd/download.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import socket
77
import time
88

9-
from requests import Response
9+
from requests import Response, Timeout
1010
from requests.exceptions import ConnectionError
1111
from tzlocal import get_localzone
1212

@@ -125,7 +125,7 @@ def download_media(
125125
)
126126
break
127127

128-
except (ConnectionError, socket.timeout, PyiCloudAPIResponseException) as ex:
128+
except (ConnectionError, socket.timeout, PyiCloudAPIResponseException, Timeout) as ex:
129129
if "Invalid global session" in str(ex):
130130
logger.error("Session error, re-authenticating...")
131131
if retries > 0:

src/pyicloud_ipd/base.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,25 @@ class PyiCloudService:
6565
"""
6666

6767
def __init__(
68-
self,
68+
self,
6969
filename_cleaner: Callable[[str], str],
7070
lp_filename_generator: Callable[[str], str],
71-
domain:str,
71+
domain:str,
7272
raw_policy: RawTreatmentPolicy,
7373
file_match_policy: FileMatchPolicy,
7474
apple_id: str, password:str, cookie_directory:Optional[str]=None, verify:bool=True,
75-
client_id:Optional[str]=None, with_family:bool=True,
75+
client_id:Optional[str]=None, with_family:bool=True, http_timeout:float=30.0
7676
):
7777
self.filename_cleaner = filename_cleaner
7878
self.lp_filename_generator = lp_filename_generator
7979
self.raw_policy = raw_policy
8080
self.file_match_policy = file_match_policy
8181
self.user: Dict[str, Any] = {"accountName": apple_id, "password": password}
82-
self.data: Dict[str, Any] = {}
82+
self.data: Dict[str, Any] = {}
8383
self.params: Dict[str, Any] = {}
8484
self.client_id: str = client_id or ("auth-%s" % str(uuid1()).lower())
8585
self.with_family = with_family
86+
self.http_timeout = http_timeout
8687

8788
self.password_filter = PyiCloudPasswordFilter(password)
8889
LOGGER.addFilter(self.password_filter)
@@ -120,7 +121,7 @@ def __init__(
120121
try:
121122
with open(self.session_path, encoding="utf-8") as session_f:
122123
self.session_data = json.load(session_f)
123-
except:
124+
except:
124125
LOGGER.info("Session file does not exist")
125126
session_client_id: Optional[str] = self.session_data.get("client_id")
126127
if session_client_id:
@@ -466,7 +467,7 @@ def get_trusted_phone_numbers(self) -> Sequence[TrustedDevice]:
466467
).prepare()
467468

468469
response = self.send_request(request)
469-
470+
470471
return parse_trusted_phone_numbers_response(response)
471472

472473
def send_2fa_code_sms(self, device_id: int) -> bool:
@@ -485,7 +486,7 @@ def send_2fa_code_sms(self, device_id: int) -> bool:
485486
).prepare()
486487

487488
response = self.send_request(request)
488-
489+
489490
return response.ok
490491

491492
def send_verification_code(self, device: Dict[str, Any]) -> bool:
@@ -537,7 +538,7 @@ def validate_2fa_code_sms(self, device_id: int, code:str) -> bool:
537538
json = req.json,
538539
).prepare()
539540
response = self.send_request(request)
540-
541+
541542
if response.ok:
542543
return self.trust_session()
543544
return False
@@ -591,7 +592,7 @@ def _get_webservice_url(self, ws_key: str) -> str:
591592
return typing.cast(str, self._webservices[ws_key]["url"])
592593

593594
@property
594-
def devices(self) -> Sequence[AppleDevice]:
595+
def devices(self) -> Sequence[AppleDevice]:
595596
""" Return all devices."""
596597
service_root = self._get_webservice_url("findme")
597598
return typing.cast(Sequence[AppleDevice], FindMyiPhoneServiceManager(
@@ -630,11 +631,11 @@ def photos(self) -> PhotosService:
630631
if not self._photos:
631632
service_root = self._get_webservice_url("ckdatabasews")
632633
self._photos = PhotosService(
633-
service_root,
634-
self.session,
635-
self.params,
636-
self.filename_cleaner,
637-
self.lp_filename_generator,
634+
service_root,
635+
self.session,
636+
self.params,
637+
self.filename_cleaner,
638+
self.lp_filename_generator,
638639
self.raw_policy,
639640
self.file_match_policy
640641
)

src/pyicloud_ipd/session.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def filter(self, record: logging.LogRecord) -> bool:
3535
message = record.getMessage()
3636
if self.name in message:
3737
record.msg = message.replace(self.name, "********")
38-
record.args = [] # type: ignore[assignment]
38+
record.args = [] # type: ignore[assignment]
3939

4040
return True
4141

@@ -48,8 +48,8 @@ def __init__(self, service: Any):
4848
super().__init__()
4949

5050
@override
51-
# type: ignore
52-
def request(self, method: str, url, **kwargs):
51+
# type: ignore
52+
def request(self, method: str, url, **kwargs):
5353

5454
# Charge logging to the right service endpoint
5555
callee = inspect.stack()[2]
@@ -62,6 +62,8 @@ def request(self, method: str, url, **kwargs):
6262

6363
has_retried = kwargs.get("retried")
6464
kwargs.pop("retried", None)
65+
if "timeout" not in kwargs and self.service.http_timeout is not None:
66+
kwargs["timeout"] = self.service.http_timeout
6567
response = super().request(method, url, **kwargs)
6668

6769
content_type = response.headers.get("Content-Type", "").split(";")[0]
@@ -131,7 +133,7 @@ def request(self, method: str, url, **kwargs):
131133

132134
try:
133135
data = response.json() if response.status_code != 204 else {}
134-
except:
136+
except:
135137
request_logger.warning("Failed to parse response with JSON mimetype")
136138
return response
137139

tests/test_download_photos_id.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pytest
1414
from click.testing import CliRunner
1515
from piexif._exceptions import InvalidImageDataError
16-
from requests import Response
16+
from requests import Response, Timeout
1717
from requests.exceptions import ConnectionError
1818
from vcr import VCR
1919

@@ -543,11 +543,11 @@ def mocked_authenticate(self: PyiCloudService) -> None:
543543

544544
assert result.exit_code == 1
545545

546-
def test_handle_connection_error_name_id7(self) -> None:
546+
def test_timeout_error(self) -> None:
547547
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
548548

549549
def mock_raise_response_error(_arg: Any) -> NoReturn:
550-
raise ConnectionError("Connection Error")
550+
raise Timeout("Connection Error")
551551

552552
with mock.patch.object(PhotoAsset, "download") as pa_download:
553553
pa_download.side_effect = mock_raise_response_error
@@ -599,6 +599,63 @@ def mocked_authenticate(self: PyiCloudService) -> None:
599599
)
600600
assert result.exit_code == 0
601601

602+
def test_handle_connection_error_name_id7(self) -> None:
603+
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
604+
605+
def mock_raise_response_error(_arg: Any) -> NoReturn:
606+
raise ConnectionError("Connection Error")
607+
608+
with mock.patch.object(PhotoAsset, "download") as pa_download:
609+
pa_download.side_effect = mock_raise_response_error
610+
611+
# Let the initial authenticate() call succeed,
612+
# but do nothing on the second try.
613+
orig_authenticate = PyiCloudService.authenticate
614+
615+
def mocked_authenticate(self: PyiCloudService) -> None:
616+
if not hasattr(self, "already_authenticated"):
617+
orig_authenticate(self)
618+
setattr(self, "already_authenticated", True) # noqa: B010
619+
620+
with mock.patch("icloudpd.constants.WAIT_SECONDS", 0): # noqa: SIM117
621+
with mock.patch.object(PyiCloudService, "authenticate", new=mocked_authenticate):
622+
_, result = run_icloudpd_test(
623+
self.assertEqual,
624+
self.root_path,
625+
base_dir,
626+
"listing_photos.yml",
627+
[],
628+
[],
629+
[
630+
"--username",
631+
"jdoe@gmail.com",
632+
"--password",
633+
"password1",
634+
"--recent",
635+
"1",
636+
"--skip-videos",
637+
"--skip-live-photos",
638+
"--no-progress-bar",
639+
"--file-match-policy",
640+
"name-id7",
641+
],
642+
)
643+
644+
# Error msg should be repeated 5 times
645+
assert (
646+
self._caplog.text.count(
647+
"Error downloading IMG_7409_QVk2Yyt.JPG, retrying after 0 seconds..."
648+
)
649+
== 5
650+
)
651+
652+
self.assertIn(
653+
"ERROR Could not download IMG_7409_QVk2Yyt.JPG. Please try again later.",
654+
self._caplog.text,
655+
)
656+
assert result.exit_code == 0
657+
658+
602659
def test_handle_albums_error_name_id7(self) -> None:
603660
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
604661

0 commit comments

Comments
 (0)