Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/model_router/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<html>oops</html>")
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()
Loading