From 71912b8358da4647ca15e306843e2eb9eee3d1cc Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Tue, 7 Apr 2026 16:06:54 +0800 Subject: [PATCH] Wrap additional network errors in factor download --- factor_attribution.py | 12 ++++++++- tests/test_factor_attribution.py | 44 +++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/factor_attribution.py b/factor_attribution.py index 7aa6c92..a5439a4 100644 --- a/factor_attribution.py +++ b/factor_attribution.py @@ -1,5 +1,6 @@ from __future__ import annotations +import http.client import io import socket import ssl @@ -35,7 +36,16 @@ def _download_kf_zip_bytes() -> bytes: try: with urlopen(request, timeout=30) as response: return response.read() - except (URLError, TimeoutError, ConnectionError, socket.timeout, ssl.SSLError) as exc: + except ( + URLError, + TimeoutError, + ConnectionError, + socket.timeout, + socket.gaierror, + ssl.SSLError, + http.client.IncompleteRead, + http.client.RemoteDisconnected, + ) as exc: raise ExternalFactorDownloadError(f"Failed to download external factor data: {exc}") from exc diff --git a/tests/test_factor_attribution.py b/tests/test_factor_attribution.py index 4c3a80b..dc0fc07 100644 --- a/tests/test_factor_attribution.py +++ b/tests/test_factor_attribution.py @@ -1,3 +1,4 @@ +import http.client import io import socket import ssl @@ -41,6 +42,7 @@ class ExternalFactorLoaderTests(unittest.TestCase): TimeoutError("timed out"), ConnectionError("conn reset"), socket.timeout("socket timed out"), + socket.gaierror("dns failed"), ssl.SSLError("tls failed"), ): with self.subTest(error_type=type(error).__name__): @@ -48,6 +50,16 @@ class ExternalFactorLoaderTests(unittest.TestCase): with self.assertRaises(ExternalFactorDownloadError): _download_kf_zip_bytes() + def test_download_kf_zip_bytes_wraps_incomplete_read_errors(self): + response = mock.MagicMock() + response.read.side_effect = http.client.IncompleteRead(b"partial", 10) + response.__enter__.return_value = response + response.__exit__.return_value = False + + with mock.patch("factor_attribution.urlopen", return_value=response): + with self.assertRaises(ExternalFactorDownloadError): + _download_kf_zip_bytes() + def test_load_external_us_factors_parses_percent_values_and_dates_from_zip_payload(self): csv_text = ( "This line is ignored\n" @@ -92,10 +104,34 @@ class ExternalFactorLoaderTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: cache_dir = Path(tmpdir) cached.to_csv(cache_dir / "ff5_us_daily.csv") - with mock.patch( - "factor_attribution._download_kf_zip_bytes", - side_effect=ExternalFactorDownloadError("boom"), - ): + with mock.patch("factor_attribution.urlopen", side_effect=socket.gaierror("dns failed")): + with self.assertWarnsRegex(UserWarning, "cached data"): + factors = load_external_us_factors(cache_dir=cache_dir) + + self.assertEqual(len(factors), 1) + self.assertAlmostEqual(factors.iloc[0]["MKT_RF"], 0.01) + + def test_load_external_us_factors_falls_back_to_cache_when_download_read_is_incomplete(self): + cached = pd.DataFrame( + { + "MKT_RF": [0.01], + "SMB": [0.0], + "HML": [0.0], + "RMW": [0.0], + "CMA": [0.0], + "RF": [0.0001], + }, + index=pd.to_datetime(["2026-01-02"]), + ) + response = mock.MagicMock() + response.read.side_effect = http.client.IncompleteRead(b"partial", 10) + response.__enter__.return_value = response + response.__exit__.return_value = False + + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) + cached.to_csv(cache_dir / "ff5_us_daily.csv") + with mock.patch("factor_attribution.urlopen", return_value=response): with self.assertWarnsRegex(UserWarning, "cached data"): factors = load_external_us_factors(cache_dir=cache_dir)