From bcc214c0694fcdbfb29556261da0d9b2732c192e Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:34:29 +0200 Subject: [PATCH 1/5] Add method and path attributes to response error exceptions WLEDEmptyResponseError and WLEDInvalidResponseError now expose `method` and `path` attributes, allowing consumers like Home Assistant to distinguish which endpoint failed (e.g. /presets.json vs /json) without fragile string matching on the exception message. Also fixes WLEDEmptyResponseError inheriting from bare Exception instead of WLEDError, and corrects accidental tuple construction in error message strings. Co-Authored-By: Claude Sonnet 4.6 --- src/wled/exceptions.py | 26 +++++++++++++++++++++++++- src/wled/wled.py | 35 +++++++++++++++++++++++++---------- tests/test_wled.py | 14 +++++++++++--- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/wled/exceptions.py b/src/wled/exceptions.py index 78ad6e24..c6a372e6 100644 --- a/src/wled/exceptions.py +++ b/src/wled/exceptions.py @@ -5,13 +5,37 @@ class WLEDError(Exception): """Generic WLED exception.""" -class WLEDEmptyResponseError(Exception): +class WLEDEmptyResponseError(WLEDError): """WLED empty API response exception.""" + def __init__( + self, + message: str, + *, + method: str | None = None, + path: str | None = None, + ) -> None: + """Initialize WLEDEmptyResponseError.""" + super().__init__(message) + self.method = method + self.path = path + class WLEDInvalidResponseError(WLEDError): """WLED invalid API response exception.""" + def __init__( + self, + message: str, + *, + method: str | None = None, + path: str | None = None, + ) -> None: + """Initialize WLEDInvalidResponseError.""" + super().__init__(message) + self.method = method + self.path = path + class WLEDConnectionError(WLEDError): """WLED connection exception.""" diff --git a/src/wled/wled.py b/src/wled/wled.py index 65b88ef6..9a51ac87 100644 --- a/src/wled/wled.py +++ b/src/wled/wled.py @@ -118,6 +118,10 @@ async def listen(self, callback: Callable[[Device], None]) -> None: to the WLED device. WLEDConnectionClosedError: The WebSocket connection to the remote WLED has been closed. + WLEDEmptyResponseError: The WLED device returned an empty response + when fetching presets. + WLEDInvalidResponseError: The WLED device returned an invalid response + when fetching presets. """ if not self._client or not self.connected or not self._device: @@ -138,9 +142,11 @@ async def listen(self, callback: Callable[[Device], None]) -> None: if not (presets := await self.request("/presets.json")): msg = ( f"WLED device at {self.host} returned an empty API" - " response on presets update", + " response on presets update" + ) + raise WLEDEmptyResponseError( + msg, method="GET", path="/presets.json" ) - raise WLEDEmptyResponseError(msg) message_data["presets"] = presets device = self._device.update_from_dict(data=message_data) @@ -232,7 +238,9 @@ async def request( "Received an invalid JSON error response " f"from request: {method} {uri}" ) - raise WLEDInvalidResponseError(msg) from exception + raise WLEDInvalidResponseError( + msg, method=method, path=uri + ) from exception raise WLEDError(response.status, error_body) try: message = contents.decode("utf-8") @@ -241,7 +249,9 @@ async def request( "Received a non-UTF-8 error response " f"from request: {method} {uri}" ) - raise WLEDInvalidResponseError(msg) from exception + raise WLEDInvalidResponseError( + msg, method=method, path=uri + ) from exception raise WLEDError( response.status, {"message": message}, @@ -251,7 +261,9 @@ async def request( response_data = await response.text() except UnicodeDecodeError as exception: msg = f"Received a non-UTF-8 response from request: {method} {uri}" - raise WLEDInvalidResponseError(msg) from exception + raise WLEDInvalidResponseError( + msg, method=method, path=uri + ) from exception if "application/json" in content_type: try: response_data = orjson.loads(response_data) @@ -260,7 +272,9 @@ async def request( "Received an invalid JSON response " f"from request: {method} {uri}" ) - raise WLEDInvalidResponseError(msg) from exception + raise WLEDInvalidResponseError( + msg, method=method, path=uri + ) from exception except TimeoutError as exception: msg = f"Timeout occurred while connecting to WLED device at {self.host}" raise WLEDConnectionTimeoutError(msg) from exception @@ -297,23 +311,24 @@ async def update(self) -> Device: Raises ------ WLEDEmptyResponseError: The WLED device returned an empty response. + WLEDInvalidResponseError: The WLED device returned an invalid response. """ if not (data := await self.request("/json")): msg = ( f"WLED device at {self.host} returned an empty API" - " response on full update", + " response on full update" ) - raise WLEDEmptyResponseError(msg) + raise WLEDEmptyResponseError(msg, method="GET", path="/json") changed, new_version = self._check_presets_changed(data) if changed: if not (presets := await self.request("/presets.json")): msg = ( f"WLED device at {self.host} returned an empty API" - " response on presets update", + " response on presets update" ) - raise WLEDEmptyResponseError(msg) + raise WLEDEmptyResponseError(msg, method="GET", path="/presets.json") data["presets"] = presets if not self._device: diff --git a/tests/test_wled.py b/tests/test_wled.py index 1a4649fc..fcacc1ab 100644 --- a/tests/test_wled.py +++ b/tests/test_wled.py @@ -246,8 +246,12 @@ async def test_update_corrupt_presets_response( body=body, content_type="application/json", ) - with pytest.raises(WLEDInvalidResponseError, match=r"GET /presets\.json"): + with pytest.raises( + WLEDInvalidResponseError, match=r"GET /presets\.json" + ) as exc_info: await wled.update() + assert exc_info.value.method == "GET" + assert exc_info.value.path == "/presets.json" async def test_update_empty_presets_response( @@ -269,8 +273,10 @@ async def test_update_empty_presets_response( content_type="text/plain", ) - with pytest.raises(WLEDEmptyResponseError): + with pytest.raises(WLEDEmptyResponseError) as exc_info: await wled.update() + assert exc_info.value.method == "GET" + assert exc_info.value.path == "/presets.json" async def test_update_skips_presets_when_unchanged( @@ -406,8 +412,10 @@ async def test_listen_preset_change_empty_response( content_type="text/plain", ) - with pytest.raises(WLEDEmptyResponseError): + with pytest.raises(WLEDEmptyResponseError) as exc_info: await wled.listen(MagicMock()) + assert exc_info.value.method == "GET" + assert exc_info.value.path == "/presets.json" # ========================================================================= From a1e8daef3fe27f6ad426d1c615d3b18d7f1a3930 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:41:25 +0200 Subject: [PATCH 2/5] Make message argument optional to preserve backward compatibility Raised in code review: requiring `message` breaks `raise WLEDEmptyResponseError` and no-arg construction, which worked before this change. Default to "" to keep backward compatibility. Co-Authored-By: Claude Sonnet 4.6 --- src/wled/exceptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wled/exceptions.py b/src/wled/exceptions.py index c6a372e6..64c65eb4 100644 --- a/src/wled/exceptions.py +++ b/src/wled/exceptions.py @@ -10,7 +10,7 @@ class WLEDEmptyResponseError(WLEDError): def __init__( self, - message: str, + message: str = "", *, method: str | None = None, path: str | None = None, @@ -26,7 +26,7 @@ class WLEDInvalidResponseError(WLEDError): def __init__( self, - message: str, + message: str = "", *, method: str | None = None, path: str | None = None, From 04a94008c679dfeb3a54c1ac3ea37f08817da4ad Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:18:17 +0200 Subject: [PATCH 3/5] Restructure exception hierarchy for response errors Introduces a cleaner separation between HTTP-level and response-body errors: - WLEDResponseError: new base for 2xx responses with unusable bodies - WLEDEmptyResponseError (was a direct WLEDError subclass) - WLEDInvalidResponseError (was a direct WLEDError subclass) - WLEDStatusError: HTTP 4xx/5xx errors, direct child of WLEDError - WLEDResponseError/WLEDStatusError expose .method and .path attributes using *args to preserve full backward compatibility with Exception contract Co-Authored-By: Claude Sonnet 4.6 --- src/wled/__init__.py | 4 ++++ src/wled/exceptions.py | 50 +++++++++++++++++++++++++----------------- src/wled/wled.py | 13 +++++++---- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/wled/__init__.py b/src/wled/__init__.py index d9ff9387..1b9d88e1 100644 --- a/src/wled/__init__.py +++ b/src/wled/__init__.py @@ -14,6 +14,8 @@ WLEDEmptyResponseError, WLEDError, WLEDInvalidResponseError, + WLEDResponseError, + WLEDStatusError, WLEDUnsupportedVersionError, WLEDUpgradeError, ) @@ -66,6 +68,8 @@ "WLEDError", "WLEDInvalidResponseError", "WLEDReleases", + "WLEDResponseError", + "WLEDStatusError", "WLEDUnsupportedVersionError", "WLEDUpgradeError", "Wifi", diff --git a/src/wled/exceptions.py b/src/wled/exceptions.py index 64c65eb4..9fc9ed11 100644 --- a/src/wled/exceptions.py +++ b/src/wled/exceptions.py @@ -5,48 +5,58 @@ class WLEDError(Exception): """Generic WLED exception.""" -class WLEDEmptyResponseError(WLEDError): - """WLED empty API response exception.""" +class WLEDConnectionError(WLEDError): + """WLED connection exception.""" + + +class WLEDConnectionTimeoutError(WLEDConnectionError): + """WLED connection timeout exception.""" + + +class WLEDConnectionClosedError(WLEDConnectionError): + """WLED WebSocket connection has been closed.""" + + +class WLEDStatusError(WLEDError): + """WLED HTTP status error exception (4xx/5xx status codes).""" def __init__( self, - message: str = "", - *, + *args: object, method: str | None = None, path: str | None = None, + status: int | None = None, + body: object = None, ) -> None: - """Initialize WLEDEmptyResponseError.""" - super().__init__(message) + """Initialize WLEDStatusError.""" + super().__init__(*args) self.method = method self.path = path + self.status = status + self.body = body -class WLEDInvalidResponseError(WLEDError): - """WLED invalid API response exception.""" +class WLEDResponseError(WLEDError): + """WLED response error exception.""" def __init__( self, - message: str = "", - *, + *args: object, method: str | None = None, path: str | None = None, ) -> None: - """Initialize WLEDInvalidResponseError.""" - super().__init__(message) + """Initialize WLEDResponseError.""" + super().__init__(*args) self.method = method self.path = path -class WLEDConnectionError(WLEDError): - """WLED connection exception.""" - - -class WLEDConnectionTimeoutError(WLEDConnectionError): - """WLED connection timeout exception.""" +class WLEDEmptyResponseError(WLEDResponseError): + """WLED empty API response exception.""" -class WLEDConnectionClosedError(WLEDConnectionError): - """WLED WebSocket connection has been closed.""" +class WLEDInvalidResponseError(WLEDResponseError): + """WLED invalid API response exception.""" class WLEDUnsupportedVersionError(WLEDError): diff --git a/src/wled/wled.py b/src/wled/wled.py index 9a51ac87..8735af2b 100644 --- a/src/wled/wled.py +++ b/src/wled/wled.py @@ -22,6 +22,7 @@ WLEDEmptyResponseError, WLEDError, WLEDInvalidResponseError, + WLEDStatusError, WLEDUpgradeError, ) from .models import Device, Playlist, Preset, Releases @@ -241,7 +242,9 @@ async def request( raise WLEDInvalidResponseError( msg, method=method, path=uri ) from exception - raise WLEDError(response.status, error_body) + raise WLEDStatusError( + method=method, path=uri, status=response.status, body=error_body + ) try: message = contents.decode("utf-8") except UnicodeDecodeError as exception: @@ -252,9 +255,11 @@ async def request( raise WLEDInvalidResponseError( msg, method=method, path=uri ) from exception - raise WLEDError( - response.status, - {"message": message}, + raise WLEDStatusError( + method=method, + path=uri, + status=response.status, + body={"message": message}, ) try: From 338835972e67142401764e2f7bad125e81bd8c18 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:32:16 +0200 Subject: [PATCH 4/5] Preserve exc.args backward compatibility and add WLEDStatusError test Pass (status, body) as positional args when raising WLEDStatusError so that exc.args and str(exc) remain identical to the previous WLEDError(status, body) behavior. Add test asserting WLEDStatusError with .method, .path, .status, .body and .args to lock in the structured error contract. Co-Authored-By: Claude Sonnet 4.6 --- src/wled/wled.py | 9 ++++++++- tests/test_wled.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/wled/wled.py b/src/wled/wled.py index 8735af2b..d7dd8589 100644 --- a/src/wled/wled.py +++ b/src/wled/wled.py @@ -243,7 +243,12 @@ async def request( msg, method=method, path=uri ) from exception raise WLEDStatusError( - method=method, path=uri, status=response.status, body=error_body + response.status, + error_body, + method=method, + path=uri, + status=response.status, + body=error_body, ) try: message = contents.decode("utf-8") @@ -256,6 +261,8 @@ async def request( msg, method=method, path=uri ) from exception raise WLEDStatusError( + response.status, + {"message": message}, method=method, path=uri, status=response.status, diff --git a/tests/test_wled.py b/tests/test_wled.py index fcacc1ab..622ebf14 100644 --- a/tests/test_wled.py +++ b/tests/test_wled.py @@ -20,6 +20,7 @@ WLEDEmptyResponseError, WLEDError, WLEDInvalidResponseError, + WLEDStatusError, WLEDUpgradeError, ) from wled.wled import WLEDReleases @@ -150,6 +151,39 @@ async def test_http_error( assert await wled.request("/") +@pytest.mark.parametrize( + ("status", "body", "content_type", "expected_body"), + [ + (404, "Not Found", "text/plain", {"message": "Not Found"}), + (500, '{"error":"oops"}', "application/json", {"error": "oops"}), + ], + ids=["404-text", "500-json"], +) +async def test_http_error_raises_status_error( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments + responses: aioresponses, + wled: WLED, + status: int, + body: str, + content_type: str, + expected_body: dict, +) -> None: + """Test HTTP error raises WLEDStatusError with structured attributes.""" + responses.get( + "http://example.com/json", + status=status, + body=body, + content_type=content_type, + ) + with pytest.raises(WLEDStatusError) as exc_info: + await wled.request("/json") + err = exc_info.value + assert err.method == "GET" + assert err.path == "/json" + assert err.status == status + assert err.body == expected_body + assert err.args == (status, expected_body) + + @pytest.mark.parametrize( ("body", "content_type"), [ From 34af9d84c3cdf43e511d28ad0d5e557a647d1af4 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:43:02 +0200 Subject: [PATCH 5/5] Document WLEDStatusError in listen() and update() docstrings Co-Authored-By: Claude Sonnet 4.6 --- src/wled/wled.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wled/wled.py b/src/wled/wled.py index d7dd8589..51f74bfc 100644 --- a/src/wled/wled.py +++ b/src/wled/wled.py @@ -123,6 +123,8 @@ async def listen(self, callback: Callable[[Device], None]) -> None: when fetching presets. WLEDInvalidResponseError: The WLED device returned an invalid response when fetching presets. + WLEDStatusError: The WLED device returned a 4xx/5xx HTTP status + when fetching presets. """ if not self._client or not self.connected or not self._device: @@ -324,6 +326,7 @@ async def update(self) -> Device: ------ WLEDEmptyResponseError: The WLED device returned an empty response. WLEDInvalidResponseError: The WLED device returned an invalid response. + WLEDStatusError: The WLED device returned a 4xx/5xx HTTP status. """ if not (data := await self.request("/json")):