Skip to content

Commit 87e9cd1

Browse files
committed
Add comprehensive tests for streaming retry logic and fix misleading comment
Signed-off-by: Hemang Sharrma <[email protected]>
1 parent 36b46b9 commit 87e9cd1

File tree

2 files changed

+106
-3
lines changed

2 files changed

+106
-3
lines changed

tests/test_fetcher_ng.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,108 @@ def test_session_get_timeout(self, mock_session_get: Mock) -> None:
137137
self.fetcher.fetch(self.url)
138138
mock_session_get.assert_called_once()
139139

140+
# Test retry on ReadTimeoutError during streaming
141+
@patch.object(urllib3.PoolManager, "request")
142+
def test_download_bytes_retry_on_streaming_timeout(
143+
self, mock_request: Mock
144+
) -> None:
145+
"""Test that download_bytes retries when ReadTimeoutError occurs during streaming."""
146+
mock_response_fail = Mock()
147+
mock_response_fail.status = 200
148+
mock_response_fail.stream.side_effect = (
149+
urllib3.exceptions.ReadTimeoutError(
150+
urllib3.connectionpool.ConnectionPool("localhost"),
151+
"",
152+
"Read timed out",
153+
)
154+
)
155+
156+
mock_response_success = Mock()
157+
mock_response_success.status = 200
158+
mock_response_success.stream.return_value = iter(
159+
[self.file_contents[:4], self.file_contents[4:]]
160+
)
161+
162+
mock_request.side_effect = [
163+
mock_response_fail,
164+
mock_response_fail,
165+
mock_response_success,
166+
]
167+
168+
data = self.fetcher.download_bytes(self.url, self.file_length)
169+
self.assertEqual(self.file_contents, data)
170+
self.assertEqual(mock_request.call_count, 3)
171+
172+
# Test retry exhaustion
173+
@patch.object(urllib3.PoolManager, "request")
174+
def test_download_bytes_retry_exhaustion(self, mock_request: Mock) -> None:
175+
"""Test that download_bytes fails after exhausting all retries."""
176+
# All attempts fail
177+
mock_response = Mock()
178+
mock_response.status = 200
179+
mock_response.stream.side_effect = urllib3.exceptions.ReadTimeoutError(
180+
urllib3.connectionpool.ConnectionPool("localhost"),
181+
"",
182+
"Read timed out",
183+
)
184+
mock_request.return_value = mock_response
185+
186+
with self.assertRaises(exceptions.SlowRetrievalError):
187+
self.fetcher.download_bytes(self.url, self.file_length)
188+
# Should have been called 3 times (max_retries=3)
189+
self.assertEqual(mock_request.call_count, 3)
190+
191+
# Test retry on ProtocolError during streaming
192+
@patch.object(urllib3.PoolManager, "request")
193+
def test_download_bytes_retry_on_protocol_error(
194+
self, mock_request: Mock
195+
) -> None:
196+
"""Test that download_bytes retries when ProtocolError occurs during streaming."""
197+
# First attempt fails with protocol error, second succeeds
198+
mock_response_fail = Mock()
199+
mock_response_fail.status = 200
200+
mock_response_fail.stream.side_effect = (
201+
urllib3.exceptions.ProtocolError("Connection broken")
202+
)
203+
204+
mock_response_success = Mock()
205+
mock_response_success.status = 200
206+
mock_response_success.stream.return_value = iter(
207+
[self.file_contents[:4], self.file_contents[4:]]
208+
)
209+
210+
mock_request.side_effect = [
211+
mock_response_fail,
212+
mock_response_success,
213+
]
214+
215+
data = self.fetcher.download_bytes(self.url, self.file_length)
216+
self.assertEqual(self.file_contents, data)
217+
self.assertEqual(mock_request.call_count, 2)
218+
219+
# Test that non-timeout errors are not retried
220+
@patch.object(urllib3.PoolManager, "request")
221+
def test_download_bytes_no_retry_on_http_error(
222+
self, mock_request: Mock
223+
) -> None:
224+
"""Test that download_bytes does not retry on HTTP errors like 404."""
225+
mock_response = Mock()
226+
mock_response.status = 404
227+
mock_request.return_value = mock_response
228+
229+
with self.assertRaises(exceptions.DownloadHTTPError):
230+
self.fetcher.download_bytes(self.url, self.file_length)
231+
# Should only be called once, no retries
232+
mock_request.assert_called_once()
233+
234+
# Test that length mismatch errors are not retried
235+
def test_download_bytes_no_retry_on_length_mismatch(self) -> None:
236+
"""Test that download_bytes does not retry on length mismatch errors."""
237+
# Try to download more data than the file contains
238+
with self.assertRaises(exceptions.DownloadLengthMismatchError):
239+
# File is self.file_length bytes, asking for less should fail
240+
self.fetcher.download_bytes(self.url, self.file_length - 4)
241+
140242
# Simple bytes download
141243
def test_download_bytes(self) -> None:
142244
data = self.fetcher.download_bytes(self.url, self.file_length)

tuf/ngclient/urllib3_fetcher.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ def __init__(
5151
if app_user_agent is not None:
5252
ua = f"{app_user_agent} {ua}"
5353

54-
# Configure retry strategy: retry on read timeouts and
55-
# connection errors. This enables retries for streaming failures,
56-
# not just initial connection.
54+
# Configure retry strategy for connection-level retries.
55+
# Note: This only retries at the HTTP request level (before streaming
56+
# begins). Streaming failures are handled by the retry loop in
57+
# download_bytes().
5758
retry_strategy = Retry(
5859
total=3,
5960
read=3,

0 commit comments

Comments
 (0)