Skip to content

Commit d3fa87a

Browse files
authored
Simplify error handling and move response checks to a response_handler module (#1977)
## Breaking change - OverkizClient.check_response was removed and replaced with the module-level `pyoverkiz.response_handler.check_response`
1 parent 8a09223 commit d3fa87a

15 files changed

+274
-143
lines changed

pyoverkiz/client.py

Lines changed: 6 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import logging
66
import ssl
77
import urllib.parse
8-
from json import JSONDecodeError
98
from pathlib import Path
109
from types import TracebackType
1110
from typing import Any, cast
@@ -14,7 +13,6 @@
1413
import humps
1514
from aiohttp import (
1615
ClientConnectorError,
17-
ClientResponse,
1816
ClientSession,
1917
ServerDisconnectedError,
2018
)
@@ -25,31 +23,13 @@
2523
from pyoverkiz.const import SUPPORTED_SERVERS
2624
from pyoverkiz.enums import APIType, CommandMode, Server
2725
from pyoverkiz.exceptions import (
28-
AccessDeniedToGatewayException,
29-
ActionGroupSetupNotFoundException,
30-
ApplicationNotAllowedException,
31-
BadCredentialsException,
32-
DuplicateActionOnDeviceException,
3326
ExecutionQueueFullException,
34-
InvalidCommandException,
3527
InvalidEventListenerIdException,
36-
InvalidTokenException,
37-
MaintenanceException,
38-
MissingAPIKeyException,
39-
MissingAuthorizationTokenException,
4028
NoRegisteredEventListenerException,
41-
NoSuchResourceException,
4229
NotAuthenticatedException,
43-
NotSuchTokenException,
4430
OverkizException,
45-
ServiceUnavailableException,
46-
SessionAndBearerInSameRequestException,
47-
TooManyAttemptsBannedException,
4831
TooManyConcurrentRequestsException,
4932
TooManyExecutionsException,
50-
TooManyRequestsException,
51-
UnknownObjectException,
52-
UnknownUserException,
5333
)
5434
from pyoverkiz.models import (
5535
Action,
@@ -69,6 +49,7 @@
6949
UIProfileDefinition,
7050
)
7151
from pyoverkiz.obfuscate import obfuscate_sensitive_data
52+
from pyoverkiz.response_handler import check_response
7253
from pyoverkiz.serializers import prepare_payload
7354
from pyoverkiz.types import JSON
7455

@@ -738,7 +719,7 @@ async def _get(self, path: str) -> Any:
738719
headers=headers,
739720
ssl=self._ssl,
740721
) as response:
741-
await self.check_response(response)
722+
await check_response(response)
742723

743724
# 204 has no body.
744725
if response.status == 204:
@@ -760,10 +741,12 @@ async def _post(
760741
headers=headers,
761742
ssl=self._ssl,
762743
) as response:
763-
await self.check_response(response)
744+
await check_response(response)
745+
764746
# 204 has no body.
765747
if response.status == 204:
766748
return None
749+
767750
return await response.json()
768751

769752
async def _delete(self, path: str) -> None:
@@ -776,125 +759,7 @@ async def _delete(self, path: str) -> None:
776759
headers=headers,
777760
ssl=self._ssl,
778761
) as response:
779-
await self.check_response(response)
780-
781-
@staticmethod
782-
async def check_response(response: ClientResponse) -> None:
783-
"""Check the response returned by the OverKiz API."""
784-
if response.status in [200, 204]:
785-
return
786-
787-
try:
788-
result = await response.json(content_type=None)
789-
except JSONDecodeError as error:
790-
result = await response.text()
791-
792-
if "is down for maintenance" in result:
793-
raise MaintenanceException("Server is down for maintenance") from error
794-
795-
if response.status == 503:
796-
raise ServiceUnavailableException(result) from error
797-
798-
raise OverkizException(
799-
f"Unknown error while requesting {response.url}. {response.status} - {result}"
800-
) from error
801-
802-
if result.get("errorCode"):
803-
# Error messages between cloud and local Overkiz servers can be slightly different
804-
# To make it easier to have a strict match for these errors, we remove the double quotes and the period at the end.
805-
806-
# An error message can have an empty (None) message
807-
message = message.strip('".') if (message := result.get("error")) else ""
808-
809-
# {"errorCode": "DUPLICATE_FIELD_OR_VALUE", "error": "Another action exists on the same device : rts://1234-5689-1234/123456"}
810-
if message.startswith("Another action exists on the same device"):
811-
raise DuplicateActionOnDeviceException(message)
812-
813-
# {"errorCode": "INVALID_FIELD_VALUE", "error": "Unable to determine action group setup (no setup for gateway #1234-5678-1234)"}
814-
if message.startswith("Unable to determine action group setup"):
815-
raise ActionGroupSetupNotFoundException(message)
816-
817-
# {"errorCode": "AUTHENTICATION_ERROR", "error": "Too many requests, try again later : login with xxx@xxx.tld"}
818-
if "Too many requests" in message:
819-
raise TooManyRequestsException(message)
820-
821-
# {"errorCode": "AUTHENTICATION_ERROR", "error": "Bad credentials"}
822-
if message == "Bad credentials":
823-
raise BadCredentialsException(message)
824-
825-
# {"errorCode": "RESOURCE_ACCESS_DENIED", "error": "Not authenticated"}
826-
if message == "Not authenticated":
827-
raise NotAuthenticatedException(message)
828-
829-
# {"errorCode": "AUTHENTICATION_ERROR", "error": "An API key is required to access this setup"}
830-
if message == "An API key is required to access this setup":
831-
raise MissingAPIKeyException(message)
832-
833-
# {"error":"Missing authorization token.","errorCode":"RESOURCE_ACCESS_DENIED"}
834-
if message == "Missing authorization token":
835-
raise MissingAuthorizationTokenException(message)
836-
837-
# {"error": "Server busy, please try again later. (Too many executions)"}
838-
if message == "Server busy, please try again later. (Too many executions)":
839-
raise TooManyExecutionsException(message)
840-
841-
# {"error": "UNSUPPORTED_OPERATION", "error": "No such command : ..."}
842-
if "No such command" in message:
843-
raise InvalidCommandException(message)
844-
845-
# {"errorCode": "UNSPECIFIED_ERROR", "error": "Invalid event listener id : ..."}
846-
if "Invalid event listener id" in message:
847-
raise InvalidEventListenerIdException(message)
848-
849-
# {"errorCode": "UNSPECIFIED_ERROR", "error": "No registered event listener"}
850-
if message == "No registered event listener":
851-
raise NoRegisteredEventListenerException(message)
852-
853-
# {"errorCode": "AUTHENTICATION_ERROR", "error": "No such user account : xxxxx"}
854-
if "No such user account" in message:
855-
raise UnknownUserException(message)
856-
857-
# {"errorCode": "INVALID_API_CALL", "error": "No such resource"}
858-
if message == "No such resource":
859-
raise NoSuchResourceException(message)
860-
861-
# {"errorCode": "RESOURCE_ACCESS_DENIED", "error": "too many concurrent requests"}
862-
if message == "too many concurrent requests":
863-
raise TooManyConcurrentRequestsException(message)
864-
865-
# {"errorCode": "EXEC_QUEUE_FULL", "error": "Execution queue is full on gateway: #xxx-yyyy-zzzz (soft limit: 10)"}
866-
if "Execution queue is full on gateway" in message:
867-
raise ExecutionQueueFullException(message)
868-
869-
if message == "Cannot use JSESSIONID and bearer token in same request":
870-
raise SessionAndBearerInSameRequestException(message)
871-
872-
if message == "Too many attempts with an invalid token, temporarily banned":
873-
raise TooManyAttemptsBannedException(message)
874-
875-
if "Invalid token : " in message:
876-
raise InvalidTokenException(message)
877-
878-
if "Not such token with UUID: " in message:
879-
raise NotSuchTokenException(message)
880-
881-
if "Unknown user :" in message:
882-
raise UnknownUserException(message)
883-
884-
# {"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}
885-
if message == "Unknown object":
886-
raise UnknownObjectException(message)
887-
888-
# {"errorCode": "RESOURCE_ACCESS_DENIED", "error": "Access denied to gateway #1234-5678-1234 for action ADD_TOKEN"}
889-
if "Access denied to gateway" in message:
890-
raise AccessDeniedToGatewayException(message)
891-
892-
# {"errorCode": "RESOURCE_ACCESS_DENIED", "error": "Your setup cannot be accessed through this application"}
893-
if message == "Your setup cannot be accessed through this application":
894-
raise ApplicationNotAllowedException(message)
895-
896-
# Undefined Overkiz exception
897-
raise OverkizException(result)
762+
await check_response(response)
898763

899764
async def _refresh_token_if_expired(self) -> None:
900765
"""Check if token is expired and request a new one."""

pyoverkiz/response_handler.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Dispatch logic for mapping Overkiz API error responses to specific exceptions."""
2+
3+
from __future__ import annotations
4+
5+
from json import JSONDecodeError
6+
7+
from aiohttp import ClientResponse
8+
9+
from pyoverkiz.exceptions import (
10+
AccessDeniedToGatewayException,
11+
ActionGroupSetupNotFoundException,
12+
ApplicationNotAllowedException,
13+
BadCredentialsException,
14+
BaseOverkizException,
15+
DuplicateActionOnDeviceException,
16+
ExecutionQueueFullException,
17+
InvalidCommandException,
18+
InvalidEventListenerIdException,
19+
InvalidTokenException,
20+
MaintenanceException,
21+
MissingAPIKeyException,
22+
MissingAuthorizationTokenException,
23+
NoRegisteredEventListenerException,
24+
NoSuchResourceException,
25+
NotAuthenticatedException,
26+
NotSuchTokenException,
27+
OverkizException,
28+
ResourceAccessDeniedException,
29+
ServiceUnavailableException,
30+
SessionAndBearerInSameRequestException,
31+
TooManyAttemptsBannedException,
32+
TooManyConcurrentRequestsException,
33+
TooManyExecutionsException,
34+
TooManyRequestsException,
35+
UnknownObjectException,
36+
UnknownUserException,
37+
)
38+
39+
# Primary dispatch: (errorCode, message_substring) -> exception class.
40+
# Checked in order; first match wins. Use errorCode as the primary key to
41+
# reduce brittleness across cloud vs. local API variants.
42+
_ERROR_CODE_MESSAGE_MAP: list[tuple[str, str | None, type[BaseOverkizException]]] = [
43+
# --- errorCode is the sole discriminator ---
44+
("DUPLICATE_FIELD_OR_VALUE", None, DuplicateActionOnDeviceException),
45+
("INVALID_FIELD_VALUE", None, ActionGroupSetupNotFoundException),
46+
("INVALID_API_CALL", None, NoSuchResourceException),
47+
("EXEC_QUEUE_FULL", None, ExecutionQueueFullException),
48+
# --- errorCode + message substring ---
49+
("AUTHENTICATION_ERROR", "Too many requests", TooManyRequestsException),
50+
("AUTHENTICATION_ERROR", "Bad credentials", BadCredentialsException),
51+
("AUTHENTICATION_ERROR", "API key is required", MissingAPIKeyException),
52+
("AUTHENTICATION_ERROR", "No such user account", UnknownUserException),
53+
("RESOURCE_ACCESS_DENIED", "Not authenticated", NotAuthenticatedException),
54+
(
55+
"RESOURCE_ACCESS_DENIED",
56+
"Missing authorization token",
57+
MissingAuthorizationTokenException,
58+
),
59+
(
60+
"RESOURCE_ACCESS_DENIED",
61+
"too many concurrent requests",
62+
TooManyConcurrentRequestsException,
63+
),
64+
("RESOURCE_ACCESS_DENIED", "Too many executions", TooManyExecutionsException),
65+
(
66+
"RESOURCE_ACCESS_DENIED",
67+
"Access denied to gateway",
68+
AccessDeniedToGatewayException,
69+
),
70+
(
71+
"RESOURCE_ACCESS_DENIED",
72+
"cannot be accessed through this application",
73+
ApplicationNotAllowedException,
74+
),
75+
("UNSUPPORTED_OPERATION", "No such command", InvalidCommandException),
76+
(
77+
"UNSPECIFIED_ERROR",
78+
"Invalid event listener id",
79+
InvalidEventListenerIdException,
80+
),
81+
(
82+
"UNSPECIFIED_ERROR",
83+
"No registered event listener",
84+
NoRegisteredEventListenerException,
85+
),
86+
("UNSPECIFIED_ERROR", "Unknown object", UnknownObjectException),
87+
]
88+
89+
# Message-only fallback patterns for responses where the errorCode alone is
90+
# not enough or may vary across API versions.
91+
_MESSAGE_FALLBACK_MAP: list[tuple[str, type[BaseOverkizException]]] = [
92+
("Too many executions", TooManyExecutionsException),
93+
(
94+
"Cannot use JSESSIONID and bearer token",
95+
SessionAndBearerInSameRequestException,
96+
),
97+
("Too many attempts with an invalid token", TooManyAttemptsBannedException),
98+
("Invalid token", InvalidTokenException),
99+
("Not such token with UUID", NotSuchTokenException),
100+
("Unknown user", UnknownUserException),
101+
("No such command", InvalidCommandException),
102+
("No registered event listener", NoRegisteredEventListenerException),
103+
("Unknown object", UnknownObjectException),
104+
("Access denied to gateway", AccessDeniedToGatewayException),
105+
]
106+
107+
# Fallback when errorCode is recognized but no message pattern matched.
108+
_ERROR_CODE_FALLBACK_MAP: dict[str, type[BaseOverkizException]] = {
109+
"AUTHENTICATION_ERROR": BadCredentialsException,
110+
"RESOURCE_ACCESS_DENIED": ResourceAccessDeniedException,
111+
}
112+
113+
114+
async def check_response(response: ClientResponse) -> None:
115+
"""Check the response returned by the OverKiz API."""
116+
if response.status in [200, 204]:
117+
return
118+
119+
try:
120+
result = await response.json(content_type=None)
121+
except JSONDecodeError as error:
122+
result = await response.text()
123+
124+
if "is down for maintenance" in result:
125+
raise MaintenanceException("Server is down for maintenance") from error
126+
127+
if response.status == 503:
128+
raise ServiceUnavailableException(result) from error
129+
130+
raise OverkizException(
131+
f"Unknown error while requesting {response.url}. {response.status} - {result}"
132+
) from error
133+
134+
error_code = result.get("errorCode", "")
135+
136+
if error_code:
137+
# Error messages between cloud and local Overkiz servers can be slightly different.
138+
# To make it easier to have a strict match for these errors, we remove the double quotes and the period at the end.
139+
140+
# An error message can have an empty (None) message
141+
message = message.strip('".') if (message := result.get("error")) else ""
142+
143+
# 1. Primary dispatch: match on errorCode (+ optional message substring)
144+
for code, pattern, exc_class in _ERROR_CODE_MESSAGE_MAP:
145+
if error_code == code and (pattern is None or pattern in message):
146+
raise exc_class(message)
147+
148+
# 2. Message-only fallback for patterns that may appear under varying errorCodes
149+
for pattern, exc_class in _MESSAGE_FALLBACK_MAP:
150+
if pattern in message:
151+
raise exc_class(message)
152+
153+
# 3. errorCode category fallback (known code, unknown message)
154+
if error_code in _ERROR_CODE_FALLBACK_MAP:
155+
raise _ERROR_CODE_FALLBACK_MAP[error_code](message or str(result))
156+
157+
# Undefined Overkiz exception
158+
raise OverkizException(result)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"errorCode": "EXEC_QUEUE_FULL",
3+
"error": "Execution queue is full on gateway: #1234-5678-9012 (soft limit: 10)"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"errorCode": "UNSPECIFIED_ERROR",
3+
"error": "Invalid event listener id : abc-123-def"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"errorCode": "UNSPECIFIED_ERROR",
3+
"error": "Invalid token : abc-123-def"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"errorCode": "AUTHENTICATION_ERROR",
3+
"error": "An API key is required to access this setup"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"errorCode": "UNSUPPORTED_OPERATION",
3+
"error": "No such command : fakeCommand"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"errorCode": "INVALID_API_CALL",
3+
"error": "No such resource"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"errorCode": "UNSPECIFIED_ERROR",
3+
"error": "Not such token with UUID: abc-123-def"
4+
}

0 commit comments

Comments
 (0)