1212
1313# Imports
1414import urllib3
15+ from urllib3 .util .retry import Retry
1516
1617import tuf
1718from tuf .api import exceptions
@@ -50,7 +51,19 @@ def __init__(
5051 if app_user_agent is not None :
5152 ua = f"{ app_user_agent } { ua } "
5253
53- self ._proxy_env = ProxyEnvironment (headers = {"User-Agent" : ua })
54+ # Configure retry strategy: retry on read timeouts and connection errors
55+ # This enables retries for streaming failures, not just initial connection
56+ retry_strategy = Retry (
57+ total = 3 ,
58+ read = 3 ,
59+ connect = 3 ,
60+ status_forcelist = [500 , 502 , 503 , 504 ],
61+ raise_on_status = False ,
62+ )
63+
64+ self ._proxy_env = ProxyEnvironment (
65+ headers = {"User-Agent" : ua }, retries = retry_strategy
66+ )
5467
5568 def _fetch (self , url : str ) -> Iterator [bytes ]:
5669 """Fetch the contents of HTTP/HTTPS url from a remote server.
@@ -82,6 +95,7 @@ def _fetch(self, url: str) -> Iterator[bytes]:
8295 except urllib3 .exceptions .MaxRetryError as e :
8396 if isinstance (e .reason , urllib3 .exceptions .TimeoutError ):
8497 raise exceptions .SlowRetrievalError from e
98+ raise
8599
86100 if response .status >= 400 :
87101 response .close ()
@@ -106,6 +120,59 @@ def _chunks(
106120 except urllib3 .exceptions .MaxRetryError as e :
107121 if isinstance (e .reason , urllib3 .exceptions .TimeoutError ):
108122 raise exceptions .SlowRetrievalError from e
123+ raise
124+ except (
125+ urllib3 .exceptions .ReadTimeoutError ,
126+ urllib3 .exceptions .ProtocolError ,
127+ ) as e :
128+ raise exceptions .SlowRetrievalError from e
109129
110130 finally :
111131 response .release_conn ()
132+
133+ def download_bytes (self , url : str , max_length : int ) -> bytes :
134+ """Download bytes from given ``url`` with retry on streaming failures.
135+
136+ This override adds retry logic for mid-stream timeout and connection
137+ errors that are not automatically retried by urllib3.
138+
139+ Args:
140+ url: URL string that represents the location of the file.
141+ max_length: Upper bound of data size in bytes.
142+
143+ Raises:
144+ exceptions.DownloadError: An error occurred during download.
145+ exceptions.DownloadLengthMismatchError: Downloaded bytes exceed
146+ ``max_length``.
147+ exceptions.DownloadHTTPError: An HTTP error code was received.
148+
149+ Returns:
150+ Content of the file in bytes.
151+ """
152+ max_retries = 3
153+ last_exception : Exception | None = None
154+
155+ for attempt in range (max_retries ):
156+ try :
157+ return super ().download_bytes (url , max_length )
158+ except exceptions .SlowRetrievalError as e :
159+ last_exception = e
160+ if attempt < max_retries - 1 :
161+ logger .debug (
162+ "Retrying download after streaming error "
163+ "(attempt %d/%d): %s" ,
164+ attempt + 1 ,
165+ max_retries ,
166+ url ,
167+ )
168+ continue
169+ raise
170+ except (
171+ exceptions .DownloadHTTPError ,
172+ exceptions .DownloadLengthMismatchError ,
173+ ):
174+ raise
175+
176+ if last_exception :
177+ raise last_exception
178+ raise exceptions .DownloadError (f"Failed to download { url } " )
0 commit comments