From f1f143ff0db70031f4e47a9975af6370d107a355 Mon Sep 17 00:00:00 2001 From: Nameet Pai <10625366+nameetpai@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:01:05 -0700 Subject: [PATCH 1/2] Raise AuthenticationException on runtime auth failures send_request now raises AuthenticationException when a request fails because authentication is no longer valid, including auth-error payloads returned with an HTTP 200. Adds AuthenticationException.detect/from_response helpers so downstream consumers (e.g. Home Assistant) can trigger reauthentication. --- jaraco/abode/client.py | 16 ++- jaraco/abode/exceptions.py | 66 +++++++++++- newsfragments/+runtime-auth.feature.rst | 1 + tests/test_auth_handling.py | 131 ++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 newsfragments/+runtime-auth.feature.rst create mode 100644 tests/test_auth_handling.py diff --git a/jaraco/abode/client.py b/jaraco/abode/client.py index d78388a..d45a122 100644 --- a/jaraco/abode/client.py +++ b/jaraco/abode/client.py @@ -314,11 +314,21 @@ def _send_request(self, method, path, headers, data): try: response = getattr(self._session, method)(path, headers=headers, json=data) - - if response and response.status_code < 400: - return response except RequestException: log.info("Abode connection reset...") + raise jaraco.abode.Exception(ERROR.REQUEST) + + # Surface authentication failures as a dedicated exception so callers can + # trigger reauthentication. This covers auth status codes (400/401/403) + # as well as auth-error payloads that Abode occasionally returns with an + # HTTP 200. AuthenticationException is a subclass of the general + # ``jaraco.abode.Exception`` and is still trapped by ``send_request``'s + # single retry (re-login), so transient token expiry keeps recovering. + if AuthenticationException.detect(response): + raise AuthenticationException.from_response(response) + + if response.status_code < 400: + return response raise jaraco.abode.Exception(ERROR.REQUEST) diff --git a/jaraco/abode/exceptions.py b/jaraco/abode/exceptions.py index 581d1d9..c636e78 100644 --- a/jaraco/abode/exceptions.py +++ b/jaraco/abode/exceptions.py @@ -1,7 +1,27 @@ import builtins +from http import HTTPStatus import requests +#: HTTP status codes Abode uses to signal an authentication/authorization failure. +AUTH_STATUS_CODES = frozenset( + {HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN} +) + +#: Abode application-level error codes that are returned (sometimes with an +#: HTTP 200) when the stored authentication is expired or otherwise invalid. +AUTH_ERROR_CODES = frozenset({11002, 13027}) + +_AUTH_MESSAGE_MARKERS = ('unauthorized', 'invalid credentials') + + +def _looks_like_auth_message(message): + """Return True if a free-form message string indicates an auth failure.""" + message = str(message or '').lower() + return any(marker in message for marker in _AUTH_MESSAGE_MARKERS) or ( + 'password' in message and 'match' in message + ) + class Exception(builtins.Exception): """Class to throw general abode exception.""" @@ -30,10 +50,52 @@ def raise_for(cls, response): except requests.exceptions.HTTPError as exc: raise cls((response.status_code, cls.best_message(response))) from exc + @classmethod + def detect(cls, response): + """Return True if ``response`` indicates an authentication failure. + + Abode signals authentication problems both through HTTP status codes + (400/401/403) and, in some cases, through an error payload returned + alongside an HTTP 200. Detecting the latter lets callers trigger + reauthentication instead of treating the bogus payload as a success. + """ + if response.status_code in AUTH_STATUS_CODES: + return True + if response.status_code != HTTPStatus.OK: + return False + return cls._auth_error_in_payload(response) + + @classmethod + def from_response(cls, response): + """Build an :class:`AuthenticationException` from ``response``.""" + return cls((response.status_code, cls.best_message(response))) + + @classmethod + def _auth_error_in_payload(cls, response): + # Check the content type first rather than blindly parsing the body. + content_type = response.headers.get('Content-Type', '') + if 'application/json' not in content_type.lower(): + return False + try: + payload = response.json() + except ValueError: + return False + if not isinstance(payload, dict): + return False + return payload.get('errorCode') in AUTH_ERROR_CODES or _looks_like_auth_message( + payload.get('message') + ) + @staticmethod def best_message(response): - if response.headers.get('Content-Type') == 'application/json': - return response.json()['message'] + content_type = response.headers.get('Content-Type', '') + if 'application/json' in content_type.lower(): + try: + payload = response.json() + except ValueError: + return response.text + if isinstance(payload, dict) and 'message' in payload: + return payload['message'] return response.text diff --git a/newsfragments/+runtime-auth.feature.rst b/newsfragments/+runtime-auth.feature.rst new file mode 100644 index 0000000..3ed605e --- /dev/null +++ b/newsfragments/+runtime-auth.feature.rst @@ -0,0 +1 @@ +``Client.send_request`` now raises ``AuthenticationException`` when a request fails because authentication is no longer valid -- including auth-error payloads that Abode sometimes returns with an HTTP 200 status. This lets downstream consumers such as Home Assistant reliably trigger reauthentication instead of treating the failure as a generic error. New ``AuthenticationException.detect`` and ``AuthenticationException.from_response`` helpers expose this behavior. diff --git a/tests/test_auth_handling.py b/tests/test_auth_handling.py new file mode 100644 index 0000000..86c995d --- /dev/null +++ b/tests/test_auth_handling.py @@ -0,0 +1,131 @@ +""" +Tests for runtime authentication-failure handling in ``Client.send_request``. + +These cover the dedicated ``AuthenticationException`` that is raised when a +request fails because authentication is no longer valid, including the case +where Abode returns an auth-error payload alongside an HTTP 200 status. +""" + +from http import HTTPStatus + +import pytest + +import jaraco.abode +from jaraco.abode.exceptions import AuthenticationException +from jaraco.abode.helpers import urls + +from . import mock as MOCK +from .mock import devices as DEVICES +from .mock import login as LOGIN +from .mock import oauth_claims as OAUTH_CLAIMS +from .mock import panel as PANEL + + +class _FakeResponse: + """Minimal stand-in for ``requests.Response`` for unit-testing helpers.""" + + def __init__(self, status_code, *, content_type='application/json', payload=None): + self.status_code = status_code + self.headers = {'Content-Type': content_type} + self._payload = payload + self.text = '' if payload is None else str(payload) + + def json(self): + if self._payload is None: + raise ValueError("No JSON payload") + return self._payload + + +@pytest.mark.parametrize( + "status_code", + [HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN], +) +def test_detect_auth_status_codes(status_code): + """Auth status codes are detected regardless of body.""" + assert AuthenticationException.detect(_FakeResponse(status_code)) is True + + +def test_detect_non_auth_status_code(): + """Non-auth errors (e.g. 500) are not treated as auth failures.""" + response = _FakeResponse(HTTPStatus.INTERNAL_SERVER_ERROR) + assert AuthenticationException.detect(response) is False + + +@pytest.mark.parametrize( + "payload", + [ + {"errorCode": 11002}, + {"errorCode": 13027}, + {"message": "Unauthorized token"}, + {"message": "Invalid credentials"}, + {"message": "Username and password do not match"}, + ], +) +def test_detect_auth_like_200_payload(payload): + """Auth-error payloads returned with HTTP 200 are detected.""" + response = _FakeResponse(HTTPStatus.OK, payload=payload) + assert AuthenticationException.detect(response) is True + + +@pytest.mark.parametrize( + "response", + [ + _FakeResponse(HTTPStatus.OK, payload={"message": "ok"}), + _FakeResponse(HTTPStatus.OK, payload=["not", "a", "dict"]), + _FakeResponse(HTTPStatus.OK, content_type="text/plain"), + _FakeResponse(HTTPStatus.OK, payload=None), + ], +) +def test_detect_ok_responses_are_not_auth(response): + """Ordinary 200 responses (and unparseable bodies) are not auth failures.""" + assert AuthenticationException.detect(response) is False + + +def test_from_response_carries_status_and_message(): + """``from_response`` preserves the status code and best message.""" + response = _FakeResponse( + HTTPStatus.UNAUTHORIZED, payload={"message": "Unauthorized token"} + ) + exc = AuthenticationException.from_response(response) + assert isinstance(exc, AuthenticationException) + assert exc.errcode == HTTPStatus.UNAUTHORIZED + assert exc.message == "Unauthorized token" + + +class TestRuntimeAuthHandling: + """Integration tests around ``send_request`` raising the dedicated error.""" + + def test_runtime_forbidden_raises_authentication(self, m): + """A persistent 403 at runtime raises AuthenticationException.""" + m.post(urls.LOGIN, json=LOGIN.post_response_ok()) + m.get(urls.OAUTH_TOKEN, json=OAUTH_CLAIMS.get_response_ok()) + m.get(urls.DEVICES, json=MOCK.response_forbidden(), status=403) + + with pytest.raises(jaraco.abode.AuthenticationException): + self.client.get_devices() + + def test_auth_error_payload_with_http_200(self, m): + """An auth-error payload returned with HTTP 200 raises AuthenticationException.""" + m.post(urls.LOGIN, json=LOGIN.post_response_ok()) + m.get(urls.OAUTH_TOKEN, json=OAUTH_CLAIMS.get_response_ok()) + m.get( + urls.DEVICES, + json={"errorCode": 11002, "message": "Unauthorized token"}, + status=200, + ) + + with pytest.raises(jaraco.abode.AuthenticationException): + self.client.get_devices() + + def test_transient_token_expiry_still_recovers(self, m): + """A single 403 followed by success still recovers via re-login.""" + new_token = "REFRESHED" + m.post(urls.LOGIN, json=LOGIN.post_response_ok(auth_token=new_token)) + m.get(urls.OAUTH_TOKEN, json=OAUTH_CLAIMS.get_response_ok()) + m.get(urls.DEVICES, json=MOCK.response_forbidden(), status=403) + m.get(urls.DEVICES, json=DEVICES.EMPTY_DEVICE_RESPONSE) + m.get(urls.PANEL, json=PANEL.get_response_ok()) + + self.client.get_devices() + + assert self.client._token == new_token From 85b26f8d247b9afe301cf48dec19a68992b94f0a Mon Sep 17 00:00:00 2001 From: Nameet Pai <10625366+nameetpai@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:12:47 -0700 Subject: [PATCH 2/2] Sort imports and format per ruff (I001) --- jaraco/abode/client.py | 8 ++++---- jaraco/abode/exceptions.py | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/jaraco/abode/client.py b/jaraco/abode/client.py index d45a122..650df0e 100644 --- a/jaraco/abode/client.py +++ b/jaraco/abode/client.py @@ -6,15 +6,15 @@ import logging import uuid +from jaraco.collections import Everything +from jaraco.functools import retry +from jaraco.itertools import always_iterable +from jaraco.net.http import cookies from more_itertools import consume from requests.exceptions import RequestException from requests_toolbelt import sessions import jaraco -from jaraco.collections import Everything -from jaraco.functools import retry -from jaraco.itertools import always_iterable -from jaraco.net.http import cookies from . import config, settings from .automation import Automation diff --git a/jaraco/abode/exceptions.py b/jaraco/abode/exceptions.py index c636e78..a31fae8 100644 --- a/jaraco/abode/exceptions.py +++ b/jaraco/abode/exceptions.py @@ -4,9 +4,11 @@ import requests #: HTTP status codes Abode uses to signal an authentication/authorization failure. -AUTH_STATUS_CODES = frozenset( - {HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN} -) +AUTH_STATUS_CODES = frozenset({ + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, +}) #: Abode application-level error codes that are returned (sometimes with an #: HTTP 200) when the stored authentication is expired or otherwise invalid.