From 4aaf239756e7e9828192bc72910daae6f7c0453f Mon Sep 17 00:00:00 2001 From: Amandeep Singh Date: Fri, 21 Aug 2026 05:36:33 +0000 Subject: [PATCH] ngclient: raise SlowRetrievalError on mid-stream read timeout Signed-off-by: Amandeep Singh --- tests/test_fetcher_ng.py | 40 +++++++++++++++++++++++++++++++++ tuf/ngclient/urllib3_fetcher.py | 7 ++++++ 2 files changed, 47 insertions(+) diff --git a/tests/test_fetcher_ng.py b/tests/test_fetcher_ng.py index 43c5fc61fc..c956de20ac 100644 --- a/tests/test_fetcher_ng.py +++ b/tests/test_fetcher_ng.py @@ -122,6 +122,46 @@ def test_response_read_timeout(self, mock_session_get: Mock) -> None: next(self.fetcher.fetch(self.url)) mock_response.stream.assert_called_once() + # urllib3 raises ReadTimeoutError directly when the gap timeout expires + # mid-stream: it is a TimeoutError but not a MaxRetryError, so it is not + # covered by the MaxRetryError case above. + @patch.object(urllib3.PoolManager, "request") + def test_response_read_timeout_error(self, mock_session_get: Mock) -> None: + mock_response = Mock() + mock_response.status = 200 + attr = { + "stream.side_effect": urllib3.exceptions.ReadTimeoutError( + urllib3.connectionpool.ConnectionPool("localhost"), + "", + "Read timed out.", + ) + } + mock_response.configure_mock(**attr) + mock_session_get.return_value = mock_response + + with self.assertRaises(exceptions.SlowRetrievalError): + next(self.fetcher.fetch(self.url)) + mock_response.stream.assert_called_once() + + # The public download_* API documents DownloadError: a mid-stream timeout + # must not escape as a raw urllib3 error. + @patch.object(urllib3.PoolManager, "request") + def test_download_bytes_read_timeout(self, mock_session_get: Mock) -> None: + mock_response = Mock() + mock_response.status = 200 + attr = { + "stream.side_effect": urllib3.exceptions.ReadTimeoutError( + urllib3.connectionpool.ConnectionPool("localhost"), + "", + "Read timed out.", + ) + } + mock_response.configure_mock(**attr) + mock_session_get.return_value = mock_response + + with self.assertRaises(exceptions.SlowRetrievalError): + self.fetcher.download_bytes(self.url, self.file_length) + # Read/connect session timeout error @patch.object( urllib3.PoolManager, diff --git a/tuf/ngclient/urllib3_fetcher.py b/tuf/ngclient/urllib3_fetcher.py index 1e10e54dcb..63cd9916d2 100644 --- a/tuf/ngclient/urllib3_fetcher.py +++ b/tuf/ngclient/urllib3_fetcher.py @@ -106,9 +106,16 @@ def _chunks( try: yield from response.stream(self.chunk_size) + except urllib3.exceptions.TimeoutError as e: + # Raised directly when the gap timeout expires mid-stream: + # ReadTimeoutError is a TimeoutError but not a MaxRetryError. + raise exceptions.SlowRetrievalError from e except urllib3.exceptions.MaxRetryError as e: if isinstance(e.reason, urllib3.exceptions.TimeoutError): raise exceptions.SlowRetrievalError from e + # Any other reason: propagate rather than ending the stream + # silently, which would look like a complete download. + raise finally: response.release_conn()