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
2 changes: 2 additions & 0 deletions src/haclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
ConnectionClosedError,
EntityNotFoundError,
HAClientError,
HTTPError,
TimeoutError,
)
from haclient.ports import Clock, RestPort, WebSocketPort
Expand All @@ -55,6 +56,7 @@
"EventBus",
"HAClient",
"HAClientError",
"HTTPError",
"RestPort",
"ServiceCaller",
"ServicePolicy",
Expand Down
38 changes: 38 additions & 0 deletions src/haclient/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,44 @@ def __init__(self, code: str, message: str) -> None:
self.message = message


class HTTPError(HAClientError):
"""Raised when Home Assistant returns an HTTP error response.

Parameters
----------
status : int
The HTTP status code (e.g. 404, 500).
method : str
The HTTP method used (e.g. ``"GET"``).
path : str
The relative API path that was requested.
body : str
The response body text.

Attributes
----------
status : int
The HTTP status code.
method : str
The HTTP method.
path : str
The relative API path.
body : str
The response body text.

Examples
--------
>>> raise HTTPError(404, "GET", "/api/states/light.missing", "not found")
"""

def __init__(self, status: int, method: str, path: str, body: str) -> None:
super().__init__(f"HTTP {status} from {method} {path}: {body.strip()}")
self.status = status
self.method = method
self.path = path
self.body = body


class TimeoutError(HAClientError): # noqa: A001
"""Raised when a request to Home Assistant does not complete in time."""

Expand Down
19 changes: 14 additions & 5 deletions src/haclient/infra/rest_aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import aiohttp

from haclient.exceptions import AuthenticationError, HAClientError
from haclient.exceptions import AuthenticationError, HAClientError, HTTPError
from haclient.exceptions import TimeoutError as HATimeoutError

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -110,8 +110,10 @@ async def _request(
------
AuthenticationError
On HTTP 401.
HTTPError
On any other HTTP error (status >= 400).
HAClientError
On any other HTTP error or connection failure.
On connection failure.
TimeoutError
On request timeout.
"""
Expand All @@ -130,7 +132,7 @@ async def _request(
raise AuthenticationError("Invalid or expired access token")
if resp.status >= 400:
body = await resp.text()
raise HAClientError(f"HTTP {resp.status} from {method} {path}: {body.strip()}")
raise HTTPError(resp.status, method, path, body)
if resp.status == 200 and resp.content_type == "application/json":
return await resp.json()
return await resp.text()
Expand Down Expand Up @@ -182,11 +184,18 @@ async def get_state(self, entity_id: str) -> dict[str, Any] | None:
-------
dict or None
The state object, or ``None`` on HTTP 404.

Raises
------
HTTPError
On any HTTP error other than 404.
AuthenticationError
On HTTP 401.
"""
try:
data = await self._request("GET", f"/api/states/{entity_id}")
except HAClientError as err:
if "HTTP 404" in str(err):
except HTTPError as err:
if err.status == 404:
return None
raise
if isinstance(data, dict):
Expand Down
63 changes: 60 additions & 3 deletions tests/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from haclient.exceptions import AuthenticationError, HAClientError
from haclient.exceptions import AuthenticationError, HAClientError, HTTPError
from haclient.infra.rest_aiohttp import AiohttpRestAdapter

from .fake_ha import FakeHA
Expand Down Expand Up @@ -65,11 +65,68 @@ async def test_call_service_error(fake_ha: FakeHA) -> None:


async def test_request_server_error(fake_ha: FakeHA) -> None:
"""Trigger a non-auth error path by hitting an unknown REST endpoint."""
"""Non-auth HTTP errors are raised as HTTPError (subclass of HAClientError)."""
rc = AiohttpRestAdapter(fake_ha.base_url, fake_ha.token)
try:
with pytest.raises(HAClientError):
with pytest.raises(HTTPError) as exc_info:
await rc._request("GET", "/api/does-not-exist")
assert exc_info.value.status == 404
assert exc_info.value.method == "GET"
assert exc_info.value.path == "/api/does-not-exist"
# HTTPError is still a HAClientError
assert isinstance(exc_info.value, HAClientError)
finally:
await rc.close()


async def test_http_error_attributes(fake_ha: FakeHA) -> None:
"""HTTPError exposes status, method, path, and body as structured attributes."""
rc = AiohttpRestAdapter(fake_ha.base_url, fake_ha.token)
try:
with pytest.raises(HTTPError) as exc_info:
await rc._request("GET", "/api/does-not-exist")
err = exc_info.value
assert err.status == 404
assert err.method == "GET"
assert err.path == "/api/does-not-exist"
assert isinstance(err.body, str)
# String representation should include status code
assert "404" in str(err)
finally:
await rc.close()


async def test_get_state_returns_none_only_for_404(fake_ha: FakeHA) -> None:
"""get_state() returns None for a real 404, not for other HTTP errors."""
fake_ha.states = [{"entity_id": "light.kitchen", "state": "on", "attributes": {}}]
rc = AiohttpRestAdapter(fake_ha.base_url, fake_ha.token)
try:
# Known entity returns state dict
state = await rc.get_state("light.kitchen")
assert state is not None
assert state["state"] == "on"

# Unknown entity → 404 → None (not an exception)
missing = await rc.get_state("light.does_not_exist")
assert missing is None
finally:
await rc.close()


async def test_get_state_reraises_non_404_http_error(
fake_ha: FakeHA, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_state() re-raises HTTPError for status codes other than 404."""
rc = AiohttpRestAdapter(fake_ha.base_url, fake_ha.token)

async def fake_request(method: str, path: str, **_: object) -> object:
raise HTTPError(500, method, path, "internal server error")

monkeypatch.setattr(rc, "_request", fake_request)
try:
with pytest.raises(HTTPError) as exc_info:
await rc.get_state("light.kitchen")
assert exc_info.value.status == 500
finally:
await rc.close()

Expand Down
Loading