Skip to content

Commit d924fa8

Browse files
committed
Apply finite network inactivity timeouts to shared downloads
1 parent c733582 commit d924fa8

2 files changed

Lines changed: 62 additions & 1 deletion

File tree

pypath/share/downloads.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ def __getattr__(self, name: str):
8080
return getattr(get_download_manager(), name)
8181

8282

83+
class _PypathDownloadManager(DownloadManager):
84+
"""Finite connect/read waits for shared downloads, including legacy callers."""
85+
86+
def download(self, url, *args, **kwargs):
87+
# Requests interprets timeout as socket inactivity, not total transfer
88+
# duration: a large download can continue while it is making progress.
89+
for key, env, default in (
90+
('connecttimeout', 'PYPATH_CONNECT_TIMEOUT', 30),
91+
('timeout', 'PYPATH_READ_TIMEOUT', 120),
92+
):
93+
value = float(os.environ.get(env, default))
94+
if not 0 < value < float('inf'):
95+
raise ValueError(f'{env} must be a finite positive number')
96+
kwargs.setdefault(key, value)
97+
kwargs.setdefault('retries', 1)
98+
return super().download(url, *args, **kwargs)
99+
100+
83101
def get_download_manager() -> DownloadManager:
84102
"""
85103
Get the shared DownloadManager instance configured with pypath's data folder.
@@ -89,7 +107,7 @@ def get_download_manager() -> DownloadManager:
89107
"""
90108
manager = getattr(_thread_local, 'download_manager', None)
91109
if manager is None:
92-
manager = DownloadManager(
110+
manager = _PypathDownloadManager(
93111
path=str(_resolve_data_dir()),
94112
config={'backend': 'requests'},
95113
)

test/test_download_timeouts.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Shared download timeouts also apply to legacy dm.download callers."""
2+
import threading
3+
import time
4+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5+
6+
import pytest
7+
from pypath.share import downloads
8+
9+
10+
def test_default_timeouts_and_explicit_override(monkeypatch):
11+
calls = []
12+
monkeypatch.setattr(downloads.DownloadManager, 'download', lambda self, url, *args, **kw: calls.append(kw))
13+
manager = object.__new__(downloads._PypathDownloadManager)
14+
manager.download('https://example.invalid/data')
15+
assert calls[-1]['connecttimeout'] == 30
16+
assert calls[-1]['timeout'] == 120
17+
assert calls[-1]['retries'] == 1
18+
manager.download('https://example.invalid/data', timeout=600)
19+
assert calls[-1]['timeout'] == 600
20+
monkeypatch.setenv('PYPATH_READ_TIMEOUT', '0.1')
21+
manager.download('https://example.invalid/data')
22+
assert calls[-1]['timeout'] == 0.1
23+
24+
25+
def test_unresponsive_http_source_times_out(tmp_path, monkeypatch):
26+
class StalledHandler(BaseHTTPRequestHandler):
27+
def do_GET(self):
28+
time.sleep(1)
29+
def log_message(self, *args):
30+
pass
31+
server = ThreadingHTTPServer(('127.0.0.1', 0), StalledHandler)
32+
thread = threading.Thread(target=server.serve_forever, daemon=True)
33+
thread.start()
34+
monkeypatch.setenv('PYPATH_READ_TIMEOUT', '0.1')
35+
manager = downloads._PypathDownloadManager(path=str(tmp_path / 'cache'), config={'backend': 'requests'})
36+
start = time.monotonic()
37+
try:
38+
with pytest.raises(Exception):
39+
manager.download(f'http://127.0.0.1:{server.server_port}/stalled', dest=str(tmp_path / 'result'))
40+
assert time.monotonic() - start < 1
41+
finally:
42+
server.shutdown()
43+
server.server_close()

0 commit comments

Comments
 (0)