Skip to content

Commit db78b80

Browse files
committed
Harden fetch error handling per review (HTTPException, charset, backoff cap)
1 parent 5c72faa commit db78b80

2 files changed

Lines changed: 42 additions & 4 deletions

File tree

breach_scraper/wa_atg_scraper.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from collections.abc import Iterable
1717
from dataclasses import dataclass, field
1818
from html.parser import HTMLParser
19+
from http.client import HTTPException
1920
from pathlib import Path
2021
from urllib.error import HTTPError, URLError
2122
from urllib.parse import urljoin
@@ -159,7 +160,11 @@ def fetch_html(
159160
with urlopen(request, timeout=timeout) as response: # nosec B310
160161
charset = response.headers.get_content_charset() or "utf-8"
161162
body: bytes = response.read()
162-
return body.decode(charset, errors="replace")
163+
try:
164+
return body.decode(charset, errors="replace")
165+
except LookupError:
166+
# Server advertised an unknown charset; fall back to UTF-8.
167+
return body.decode("utf-8", errors="replace")
163168
except HTTPError as exc:
164169
if exc.code == 403:
165170
raise RuntimeError(
@@ -172,10 +177,10 @@ def fetch_html(
172177
last_error = exc
173178
else:
174179
raise RuntimeError(f"Failed to fetch source page: HTTP {exc.code}.") from exc
175-
except (URLError, TimeoutError) as exc:
180+
except (URLError, TimeoutError, HTTPException) as exc:
176181
last_error = exc
177182
if attempt < attempts - 1:
178-
time.sleep(backoff * (2**attempt))
183+
time.sleep(min(backoff * (2**attempt), 30.0))
179184
raise RuntimeError(
180185
f"Failed to fetch source page after {attempts} attempt(s): {last_error}"
181186
) from last_error
@@ -285,7 +290,7 @@ def main(argv: list[str] | None = None) -> int:
285290
records = records[: args.limit]
286291

287292
write_output(records, output_format=args.output, out_file=args.out_file)
288-
except (RuntimeError, OSError) as exc:
293+
except (RuntimeError, OSError, ValueError) as exc:
289294
print(f"error: {exc}", file=sys.stderr)
290295
return 1
291296
return 0

tests/test_wa_atg_scraper.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55
import unittest
66
from contextlib import redirect_stderr, redirect_stdout
7+
from http.client import BadStatusLine
78
from pathlib import Path
89
from unittest import mock
910
from urllib.error import HTTPError, URLError
@@ -81,6 +82,33 @@ def test_http_403_raises_actionable_error(self, mock_urlopen: mock.Mock) -> None
8182
self.assertIn("--input-html", message)
8283
self.assertEqual(mock_urlopen.call_count, 1)
8384

85+
@mock.patch("breach_scraper.wa_atg_scraper.time.sleep", return_value=None)
86+
@mock.patch("breach_scraper.wa_atg_scraper.urlopen")
87+
def test_retries_on_http_protocol_error(
88+
self, mock_urlopen: mock.Mock, _sleep: mock.Mock
89+
) -> None:
90+
mock_urlopen.side_effect = [BadStatusLine("oops"), _FakeResponse(b"<html>ok</html>")]
91+
html = fetch_html("https://example.test", retries=3, backoff=0)
92+
self.assertEqual(html, "<html>ok</html>")
93+
self.assertEqual(mock_urlopen.call_count, 2)
94+
95+
@mock.patch("breach_scraper.wa_atg_scraper.urlopen")
96+
def test_unknown_charset_falls_back_to_utf8(self, mock_urlopen: mock.Mock) -> None:
97+
mock_urlopen.return_value = _FakeResponse(
98+
b"<html>caf\xc3\xa9</html>", charset="bogus-charset"
99+
)
100+
html = fetch_html("https://example.test", retries=1, backoff=0)
101+
self.assertIn("caf", html)
102+
103+
@mock.patch("breach_scraper.wa_atg_scraper.time.sleep")
104+
@mock.patch("breach_scraper.wa_atg_scraper.urlopen")
105+
def test_backoff_is_capped(self, mock_urlopen: mock.Mock, mock_sleep: mock.Mock) -> None:
106+
mock_urlopen.side_effect = URLError("down")
107+
with self.assertRaises(RuntimeError):
108+
fetch_html("https://example.test", retries=20, backoff=1.0)
109+
max_sleep = max(call.args[0] for call in mock_sleep.call_args_list)
110+
self.assertLessEqual(max_sleep, 30.0)
111+
84112

85113
class TestMainCli(unittest.TestCase):
86114
def _run(self, argv: list[str]) -> tuple[int, str, str]:
@@ -119,6 +147,11 @@ def test_missing_input_file_returns_error_code(self) -> None:
119147
self.assertEqual(rc, 1)
120148
self.assertIn("error:", err)
121149

150+
def test_malformed_url_returns_error_code(self) -> None:
151+
rc, _, err = self._run(["--url", "not a url"])
152+
self.assertEqual(rc, 1)
153+
self.assertIn("error:", err)
154+
122155

123156
if __name__ == "__main__":
124157
unittest.main()

0 commit comments

Comments
 (0)