Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
80 changes: 80 additions & 0 deletions sagemcom_api/action_error_exception_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Logic to spot and create ActionErrorExceptions."""

from sagemcom_api.const import (
XMO_ACCESS_RESTRICTION_ERR,
XMO_AUTHENTICATION_ERR,
XMO_LOGIN_RETRY_ERR,
XMO_MAX_SESSION_COUNT_ERR,
XMO_NO_ERR,
XMO_NON_WRITABLE_PARAMETER_ERR,
XMO_REQUEST_ACTION_ERR,
XMO_UNKNOWN_PATH_ERR,
)
from sagemcom_api.exceptions import (
AccessRestrictionException,
AuthenticationException,
LoginRetryErrorException,
MaximumSessionCountException,
NonWritableParameterException,
UnknownException,
UnknownPathException,
)

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module uses absolute imports (from sagemcom_api...) while the rest of the package (e.g., client.py) uses relative imports (from .const, from .exceptions). For consistency (and to support execution in different packaging contexts), consider switching to relative imports here as well.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: Changed to relative imports throughout action_error_exception_handler.py -- from sagemcom_api.const import ... is now from .const import ... and from sagemcom_api.exceptions import ... is now from .exceptions import ..., consistent with the rest of the package.



class ActionErrorHandler:
"""Raised when a requested action has an error."""

KNOWN_EXCEPTIONS = (
XMO_AUTHENTICATION_ERR,
XMO_ACCESS_RESTRICTION_ERR,
XMO_NON_WRITABLE_PARAMETER_ERR,
XMO_UNKNOWN_PATH_ERR,
XMO_MAX_SESSION_COUNT_ERR,
XMO_LOGIN_RETRY_ERR,
)

@staticmethod
def throw_if(response):
"""For anywhere that needs the old single-exception behaviour."""
if response["reply"]["error"]["description"] != XMO_REQUEST_ACTION_ERR:
return

actions = response["reply"]["actions"]
for action in actions:
action_error = action["error"]
action_error_desc = action_error["description"]

if action_error_desc == XMO_NO_ERR:
continue

raise ActionErrorHandler.from_error_description(action_error, action_error_desc)

@staticmethod
def is_unknown_exception(desc):
"""True/false if the ActionError is one of our known types."""
return False if desc == XMO_NO_ERR else desc not in ActionErrorHandler.KNOWN_EXCEPTIONS

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ActionErrorHandler.is_unknown_exception is introduced but not used anywhere in the codebase. If it’s not needed, removing it would reduce surface area; if it is intended for callers (e.g., selective suppression), consider wiring it into the new suppress_action_errors behavior and adding coverage for it.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: is_unknown_exception() has been removed entirely. It was never called anywhere in the codebase (confirmed by a full-codebase search), so it served no purpose and only added unnecessary surface area.


@staticmethod
def from_error_description(action_error, action_error_desc):
"""Create the correct exception from an error, for the caller to throw."""
# pylint: disable=too-many-return-statements

if action_error_desc == XMO_AUTHENTICATION_ERR:
return AuthenticationException(action_error)

if action_error_desc == XMO_ACCESS_RESTRICTION_ERR:
return AccessRestrictionException(action_error)

if action_error_desc == XMO_NON_WRITABLE_PARAMETER_ERR:
return NonWritableParameterException(action_error)

if action_error_desc == XMO_UNKNOWN_PATH_ERR:
return UnknownPathException(action_error)

if action_error_desc == XMO_MAX_SESSION_COUNT_ERR:
return MaximumSessionCountException(action_error)

if action_error_desc == XMO_LOGIN_RETRY_ERR:
return LoginRetryErrorException(action_error)

return UnknownException(action_error)
88 changes: 44 additions & 44 deletions sagemcom_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,24 @@
TCPConnector,
)

from .action_error_exception_handler import ActionErrorHandler
from .const import (
API_ENDPOINT,
DEFAULT_TIMEOUT,
DEFAULT_USER_AGENT,
UINT_MAX,
XMO_ACCESS_RESTRICTION_ERR,
XMO_AUTHENTICATION_ERR,
XMO_INVALID_SESSION_ERR,
XMO_LOGIN_RETRY_ERR,
XMO_MAX_SESSION_COUNT_ERR,
XMO_NO_ERR,
XMO_NON_WRITABLE_PARAMETER_ERR,
XMO_REQUEST_ACTION_ERR,
XMO_REQUEST_NO_ERR,
XMO_UNKNOWN_PATH_ERR,
)
from .enums import EncryptionMethod
from .exceptions import (
AccessRestrictionException,
AuthenticationException,
BadRequestException,
InvalidSessionException,
LoginRetryErrorException,
LoginTimeoutException,
MaximumSessionCountException,
NonWritableParameterException,
UnauthorizedException,
UnknownException,
UnknownPathException,
Expand Down Expand Up @@ -191,8 +183,19 @@ def __get_response(self, response, index=0):

return value

def __get_response_value(self, response, index=0):
def __get_response_value(self, response, index=0, throw_on_action_error: bool = False):
"""Retrieve response value from value."""
if throw_on_action_error:
try:
error = response["reply"]["actions"][index]["error"]
except (KeyError, IndexError):
error = None

if error is not None:
error_description = error["description"]
if error_description != XMO_NO_ERR:
raise ActionErrorHandler.from_error_description(error, error_description)

try:
value = self.__get_response(response, index)["value"]
except KeyError:
Expand Down Expand Up @@ -239,37 +242,10 @@ async def __post(self, url, data):
self._request_id = -1
raise InvalidSessionException(error)

# Error in one of the actions
# Unknown error in one of the actions
if error["description"] == XMO_REQUEST_ACTION_ERR:
# pylint:disable=fixme
# TODO How to support multiple actions + error handling?
actions = result["reply"]["actions"]
for action in actions:
action_error = action["error"]
action_error_desc = action_error["description"]

if action_error_desc == XMO_NO_ERR:
continue

if action_error_desc == XMO_AUTHENTICATION_ERR:
raise AuthenticationException(action_error)

if action_error_desc == XMO_ACCESS_RESTRICTION_ERR:
raise AccessRestrictionException(action_error)

if action_error_desc == XMO_NON_WRITABLE_PARAMETER_ERR:
raise NonWritableParameterException(action_error)

if action_error_desc == XMO_UNKNOWN_PATH_ERR:
raise UnknownPathException(action_error)

if action_error_desc == XMO_MAX_SESSION_COUNT_ERR:
raise MaximumSessionCountException(action_error)

if action_error_desc == XMO_LOGIN_RETRY_ERR:
raise LoginRetryErrorException(action_error)

raise UnknownException(action_error)
# leave this to the layer above as there may be multiple actions
pass

return result

Expand Down Expand Up @@ -331,6 +307,8 @@ async def login(self):

try:
response = await self.__api_request_async([actions], True)
ActionErrorHandler.throw_if(response)

except TimeoutError as exception:
raise LoginTimeoutException(
"Login request timed-out. This could be caused by using the wrong encryption method, or using a (non) SSL connection."
Expand All @@ -349,7 +327,8 @@ async def logout(self):
"""Log out of the Sagemcom F@st device."""
actions = {"id": 0, "method": "logOut"}

await self.__api_request_async([actions], False)
response = await self.__api_request_async([actions], False)
ActionErrorHandler.throw_if(response)

self._session_id = -1
self._server_nonce = ""
Expand Down Expand Up @@ -389,7 +368,12 @@ async def get_encryption_method(self):
max_tries=1,
on_backoff=retry_login,
)
async def get_value_by_xpath(self, xpath: str, options: dict | None = None) -> dict:
async def get_value_by_xpath(
self,
xpath: str,
options: dict | None = None,
suppress_action_errors: bool = False,
) -> dict:

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_value_by_xpath is annotated to return dict, but it returns self.__get_response_value(...) which can be a scalar (e.g., the unit test expects a string). This makes the public API typing misleading; consider changing the return type to something like Any / object (and similarly for other value getters) so type checkers and callers have accurate expectations.

Suggested change
) -> dict:
) -> Any:

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: return type has been changed from to , which correctly reflects that the method can return scalars, dicts, lists, or depending on the path requested.

"""Retrieve raw value from router using XPath.

:param xpath: path expression
Expand All @@ -403,6 +387,9 @@ async def get_value_by_xpath(self, xpath: str, options: dict | None = None) -> d
}

response = await self.__api_request_async([actions], False)
if not suppress_action_errors:
ActionErrorHandler.throw_if(response)

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suppress_action_errors=True skips action-error handling entirely. That can silently swallow critical action-level errors (e.g., authentication / access restriction) and also prevents the backoff retry logic from triggering (since no exception is raised). Consider only suppressing specific error types (e.g., unknown-path) while still raising for other action errors.

Suggested change
if not suppress_action_errors:
ActionErrorHandler.throw_if(response)
try:
ActionErrorHandler.throw_if(response)
except UnknownPathException:
if not suppress_action_errors:
raise

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: The blanket if not suppress_action_errors: throw_if() pattern has been replaced with throw_if_error(response, ignore_unknown_path=suppress_action_errors). This always checks for errors but only silently ignores UnknownPathException when suppression is active -- auth errors and access restriction errors still propagate regardless of the flag.


data = self.__get_response_value(response)

return data
Expand All @@ -418,7 +405,12 @@ async def get_value_by_xpath(self, xpath: str, options: dict | None = None) -> d
max_tries=1,
on_backoff=retry_login,
)
async def get_values_by_xpaths(self, xpaths, options: dict | None = None) -> dict:
async def get_values_by_xpaths(
self,
xpaths,
options: dict | None = None,
suppress_action_errors: bool = False,
) -> dict:
Comment on lines +396 to +401

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior (suppress_action_errors) and the new ActionErrorHandler logic are not covered by unit tests. Given existing unit tests for action-level authentication errors, add tests that (1) verify unknown-path errors can be suppressed for optional fields, and (2) verify non-unknown-path action errors (e.g., authentication) still raise even when suppression is enabled for other fields.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: Added 5 new unit tests in tests/unit/test_client_basic.py covering:

  1. Unknown-path errors are suppressed and return None (single xpath)
  2. Unknown-path errors raise when suppress_action_errors=False
  3. Auth errors still raise even when suppress_action_errors=True (single xpath)
  4. Per-action suppression: a mix of success + unknown-path returns value + None
  5. Auth errors still raise even when suppress_action_errors=True (multi xpath)

As a bonus improvement, the throw_if() pattern (raise then catch at call site) was replaced with throw_if_error() and throw_if_error_at() methods that raise directly, avoiding the cost of constructing and catching exceptions at every call site.

"""Retrieve raw values from router using XPath.

:param xpaths: Dict of key to xpath expression
Expand All @@ -435,6 +427,9 @@ async def get_values_by_xpaths(self, xpaths, options: dict | None = None) -> dic
]

response = await self.__api_request_async(actions, False)
if not suppress_action_errors:
ActionErrorHandler.throw_if(response)

values = [self.__get_response_value(response, i) for i in range(len(xpaths))]

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as get_value_by_xpath: when suppress_action_errors=True, this method will return None values for any action-level error (including auth failures), which can hide real failures and bypass the backoff retry mechanism. A safer approach is to ignore only the intended error(s) per-action (e.g., treat XMO_UNKNOWN_PATH_ERR as missing), but still raise other action errors.

Suggested change
values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
else:
values = []
for i in range(len(xpaths)):
try:
values.append(self.__get_response_value(response, i))
except UnknownPathException:
values.append(None)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: The suppress path now does per-action error checking using throw_if_error_at(response, i, ignore_unknown_path=True) in a loop over each action result. Non-unknown-path errors (auth failures, access restrictions, etc.) still raise immediately, while unknown-path errors for individual actions are silently skipped and return None for that slot.

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions improved handling for multiple action errors by deferring errors until reading values, but get_values_by_xpaths still calls ActionErrorHandler.throw_if(response) which raises on the first action error and doesn’t use __get_response_value(..., throw_on_action_error=True) at all. If the intent is per-action handling, consider either (a) removing the throw_if call for multi-action requests and handling errors per index when reading values, or (b) updating the PR description to match the implemented behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: The implementation now genuinely does per-action error handling in the suppress path, matching the PR description intent. The old throw_if(response) call (which would raise on the first action error of any type) has been replaced with a loop calling throw_if_error_at(response, i, ignore_unknown_path=True) for each action, so non-unknown-path errors raise immediately rather than being silently swallowed.

data = dict(zip(xpaths.keys(), values, strict=True))

Expand Down Expand Up @@ -467,6 +462,7 @@ async def set_value_by_xpath(self, xpath: str, value: str, options: dict | None
}

response = await self.__api_request_async([actions], False)
ActionErrorHandler.throw_if(response)

return response

Expand Down Expand Up @@ -495,7 +491,9 @@ async def get_device_info(self) -> DeviceInfo:
"product_class": "Device/DeviceInfo/ProductClass",
"serial_number": "Device/DeviceInfo/SerialNumber",
"software_version": "Device/DeviceInfo/SoftwareVersion",
}
},
# missing values converted to empty string

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "missing values converted to empty string", but __get_response_value returns None on missing value (KeyError/IndexError). Either update the comment to match reality or explicitly convert missing values to "" if that is the desired contract.

Suggested change
# missing values converted to empty string
# missing values are returned as None when action errors are suppressed

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: The comment has been updated from "missing values converted to empty string" to "missing values returned as None when action errors are suppressed", which accurately reflects the actual behaviour of __get_response_value.

suppress_action_errors=True,
)
Comment on lines +480 to 491

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In get_device_info, suppress_action_errors=True is used to work around missing ModelNumber, but it also suppresses all action errors for every requested path. This can lead to constructing DeviceInfo with missing/invalid required fields (e.g., mac_address) and can mask authentication/access issues. Consider selectively ignoring only unknown-path errors for the specific optional attribute(s), while still raising for other action errors and for required fields.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: This is now covered by the fix to get_values_by_xpaths -- the suppress path uses per-action error checking with throw_if_error_at(response, i, ignore_unknown_path=True), so auth failures and access restriction errors still propagate even when suppress_action_errors=True. Only UnknownPathException (e.g. for ModelNumber on some firmware versions) is silently skipped.

data["manufacturer"] = "Sagemcom"

Expand Down Expand Up @@ -562,6 +560,8 @@ async def reboot(self):
}

response = await self.__api_request_async([action], False)
ActionErrorHandler.throw_if(response)

data = self.__get_response_value(response)

return data