From 88956c981f03f821f56d5fa009b639b9727aff43 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Wed, 30 Sep 2026 01:57:36 -0600 Subject: [PATCH 1/2] test: cover a failed or undecodable read of an HTTP error body Both tests fail against the current handler: a socket.timeout from e.read() escapes unconverted, and non-UTF-8 bytes raise a UnicodeDecodeError that the JSONDecodeError clause does not catch. Pushed ahead of the fix so CI records the failure. Co-Authored-By: Claude Opus 5.5 --- python/tests/test_client.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/python/tests/test_client.py b/python/tests/test_client.py index 3b71449..2954869 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -524,6 +524,26 @@ def test_a_body_that_is_not_githubs_json_falls_back_to_the_status(self): self.assertEqual(ctx.exception.status, 502) self.assertEqual(str(ctx.exception), "GitHub API returned HTTP 502") + def test_a_timeout_while_reading_an_error_body_falls_back_to_the_status(self): + # The read happens inside the HTTPError handler, where the OSError + # clause beside it cannot catch what it raises. + error = _http_error(code=503, message="Service Unavailable") + error.read.side_effect = socket.timeout("timed out") + with mock.patch("urllib.request.urlopen", side_effect=error): + with self.assertRaises(GitHubDocsError) as ctx: + self.client.get_default_branch() + self.assertEqual(ctx.exception.status, 503) + self.assertEqual(str(ctx.exception), "GitHub API returned HTTP 503") + + def test_an_error_body_that_is_not_utf8_falls_back_to_the_status(self): + # Undecodable bytes fail as a UnicodeDecodeError, not a JSONDecodeError. + error = _http_error(code=502, message="Bad Gateway", raw=b"\x80\x81 not text") + with mock.patch("urllib.request.urlopen", side_effect=error): + with self.assertRaises(GitHubDocsError) as ctx: + self.client.get_default_branch() + self.assertEqual(ctx.exception.status, 502) + self.assertEqual(str(ctx.exception), "GitHub API returned HTTP 502") + def test_allow_404_turns_a_missing_ref_into_none_rather_than_an_error(self): with mock.patch("urllib.request.urlopen", side_effect=_http_error()): self.assertIsNone(self.client.get_ref_sha("no-such-branch", allow_404=True)) From 158b9614b0a535060c10e413fb7f082f9f04ad53 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Wed, 30 Sep 2026 01:58:17 -0600 Subject: [PATCH 2/2] fix: fall back to the status when an HTTP error body cannot be read e.read() ran inside the HTTPError handler, where the sibling OSError clause cannot catch what it raises, so a timeout or reset while reading the error body escaped save_file unconverted. The same handler caught only JSONDecodeError, so a non-UTF-8 body escaped as UnicodeDecodeError. Both now fall back to the existing status-only message. Co-Authored-By: Claude Opus 5.5 --- python/src/github_docs/client.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/python/src/github_docs/client.py b/python/src/github_docs/client.py index 0c54869..a4f24b2 100644 --- a/python/src/github_docs/client.py +++ b/python/src/github_docs/client.py @@ -251,10 +251,19 @@ def _request( ) from e return resp.status, parsed except urllib.error.HTTPError as e: - raw = e.read() + # The body is only a nicer message; the status is already in hand. + # A read that times out or resets here is raised inside this + # handler, where the OSError clause below cannot catch it, so it + # falls back to the status-only message instead of escaping. + try: + raw = e.read() + except OSError: + raw = b"" + # ValueError rather than JSONDecodeError, for the same reason as on + # the success path: bytes that are not UTF-8 fail before the parser. try: parsed = json.loads(raw) if raw else {} - except json.JSONDecodeError: + except ValueError: parsed = {} if e.code == 404 and allow_404: return 404, parsed