From 22772538a9cbc3a952dff9eb781f2d6fa50ae311 Mon Sep 17 00:00:00 2001 From: elliot1377 <114384093+elliot1377@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:24:37 -0700 Subject: [PATCH 1/3] Wrap non-JSON responses, read timeouts and connection errors in RouterError request_json only converted HTTPError and URLError, so a 200 with an HTML body raised json.JSONDecodeError and a read timeout raised TimeoutError. Both escaped the documented `except RouterError:` fallback. Catch them (plus OSError for connection resets) and re-raise as RouterError with the original exception chained. --- src/model_router/_http.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/model_router/_http.py b/src/model_router/_http.py index bca7766..38bb318 100644 --- a/src/model_router/_http.py +++ b/src/model_router/_http.py @@ -19,3 +19,9 @@ def request_json(url, api_key=None, body=None, timeout=60): raise RouterError(f"{url} -> HTTP {e.code}: {e.read().decode(errors='replace')[:500]}") from e except urllib.error.URLError as e: raise RouterError(f"{url} -> {e.reason}") from e + except TimeoutError as e: + raise RouterError(f"{url} -> timed out after {timeout}s") from e + except OSError as e: + raise RouterError(f"{url} -> {e}") from e + except (json.JSONDecodeError, UnicodeDecodeError) as e: + raise RouterError(f"{url} -> response was not valid JSON: {e}") from e From eca9acd1d59ad73c6351df0bb65487a3ef186ecb Mon Sep 17 00:00:00 2001 From: elliot1377 <114384093+elliot1377@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:25:07 -0700 Subject: [PATCH 2/3] Add tests for non-JSON body, read timeout and connection reset --- tests/test_http.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_http.py b/tests/test_http.py index 416c317..9e067b7 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -53,6 +53,29 @@ def test_network_error_becomes_router_error(self): request_json("https://x.test/a") self.assertIn("no route", str(ctx.exception)) + def test_non_json_body_becomes_router_error(self): + resp = MagicMock() + resp.__enter__.return_value = io.BytesIO(b"oops") + with patch("urllib.request.urlopen", return_value=resp): + with self.assertRaises(RouterError) as ctx: + request_json("https://x.test/a") + self.assertIsInstance(ctx.exception.__cause__, json.JSONDecodeError) + + def test_read_timeout_becomes_router_error(self): + resp = MagicMock() + resp.__enter__.return_value.read.side_effect = TimeoutError("The read operation timed out") + with patch("urllib.request.urlopen", return_value=resp): + with self.assertRaises(RouterError) as ctx: + request_json("https://x.test/a", timeout=5) + self.assertIn("timed out", str(ctx.exception)) + self.assertIsInstance(ctx.exception.__cause__, TimeoutError) + + def test_connection_reset_becomes_router_error(self): + with patch("urllib.request.urlopen", side_effect=ConnectionResetError("reset by peer")): + with self.assertRaises(RouterError) as ctx: + request_json("https://x.test/a") + self.assertIsInstance(ctx.exception.__cause__, ConnectionResetError) + if __name__ == "__main__": unittest.main() From c90b75407fbabe758a03b901b0a3378052599236 Mon Sep 17 00:00:00 2001 From: elliot1377 <114384093+elliot1377@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:25:36 -0700 Subject: [PATCH 3/3] Add changelog entry for RouterError wrapping fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5204def..2458c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project are documented here. The format follows [Kee ## [Unreleased] +### Fixed +- `request_json` now raises `RouterError` (with the original exception chained) for non-JSON responses, read timeouts and connection errors, so `except RouterError:` fallbacks work as documented. + ## [0.1.0] - 2026-09-23 ### Added