Skip to content

Commit 9540722

Browse files
committed
: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)
1 parent c4a6322 commit 9540722

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)