From 144f477fc0b8aca2f120fe0877d5b50649db2251 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 17 Aug 2026 12:24:06 +0200 Subject: [PATCH] Avoid stack trace for known API errors during realtime resubscribe When the access token expires during a resubscribe step (e.g. InvalidLoginError with "exp" claim timestamp check failed), the error is expected and known. Demote it from a full traceback (catch-all except Exception with exc_info=True) to a plain warning by catching HttpExceptionError explicitly and logging the error message without exc_info. Unknown/unexpected exceptions still emit the full traceback. Add tests covering both cases. --- test/test_home.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++ tibber/home.py | 5 ++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/test/test_home.py b/test/test_home.py index e3d1196..bc3bd77 100644 --- a/test/test_home.py +++ b/test/test_home.py @@ -12,6 +12,7 @@ import tibber from tibber.exceptions import ( + InvalidLoginError, SubscriptionFailedError, WebsocketReconnectedError, WebsocketTransportError, @@ -520,6 +521,7 @@ async def test_rt_subscription_reconnects_when_no_data_received( aiohttp.ClientError("boom"), TimeoutError(), ValueError("boom"), + InvalidLoginError(400, '"exp" claim timestamp check failed', "UNAUTHENTICATED"), ], ) @patch("tibber.home.RESUBSCRIBE_WAIT_TIME", 0) @@ -563,6 +565,57 @@ def callback(data: dict) -> None: home.rt_unsubscribe() +@patch("tibber.home.RESUBSCRIBE_WAIT_TIME", 0) +@pytest.mark.asyncio +async def test_resubscribe_step_known_error_logs_without_traceback( + home: tibber.TibberHome, + mock_realtime: MagicMock, + mock_websession: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Known API errors (HttpExceptionError) must log without a traceback. + + Unknown errors (e.g. ValueError) must still emit exc_info so the traceback + is preserved for debugging. + """ + # --- known error: no traceback --- + mock_websession.post.side_effect = InvalidLoginError( + 400, + '"exp" claim timestamp check failed', + "UNAUTHENTICATED", + ) + _, subscribe_fn = _make_blocking_subscribe([]) + mock_realtime.subscribe = subscribe_fn + + with caplog.at_level(logging.WARNING): + await home.rt_subscribe(MagicMock()) + + # The warning message contains the failure_message text; exc_info must be None. + known_records = [r for r in caplog.records if "keeping last known" in r.message] + assert known_records, "Expected warning records for the resubscribe step" + for rec in known_records: + assert rec.exc_info is None, "Known API error must not include traceback" + + home.rt_unsubscribe() + caplog.clear() + + # --- unknown error: traceback preserved --- + mock_websession.post.side_effect = ValueError("something unexpected") + _, subscribe_fn2 = _make_blocking_subscribe([]) + mock_realtime.subscribe = subscribe_fn2 + + with caplog.at_level(logging.WARNING): + await home.rt_subscribe(MagicMock()) + + # exc_info must be set so the traceback is visible in logs for unexpected errors. + unknown_records = [r for r in caplog.records if "keeping last known" in r.message] + assert unknown_records, "Expected warning records for the resubscribe step" + for rec in unknown_records: + assert rec.exc_info is not None, "Unknown error must include traceback" + + home.rt_unsubscribe() + + @patch("tibber.home.RT_SUBSCRIPTION_TIMEOUT", 0) async def test_rt_subscription_timeout_calls_on_error( home: tibber.TibberHome, diff --git a/tibber/home.py b/tibber/home.py index 8c00806..09e5f61 100644 --- a/tibber/home.py +++ b/tibber/home.py @@ -15,7 +15,7 @@ from gql import gql from .const import RESOLUTION_DAILY, RESOLUTION_HOURLY, RESOLUTION_MONTHLY, RESOLUTION_WEEKLY -from .exceptions import SubscriptionFailedError, WebsocketReconnectedError, WebsocketTransportError +from .exceptions import HttpExceptionError, SubscriptionFailedError, WebsocketReconnectedError, WebsocketTransportError from .gql_queries import ( HISTORIC_DATA, HISTORIC_DATA_DATE, @@ -591,6 +591,9 @@ async def _resubscribe_step(self, coro: Awaitable[Any], failure_message: str) -> except (TimeoutError, aiohttp.ClientError): # Transport errors are already logged at debug level inside execute. _LOGGER.warning("Home %s: %s", self.home_id, failure_message) + except HttpExceptionError as err: + # Known API errors (e.g. an expired token) are expected here and don't warrant a traceback. + _LOGGER.warning("Home %s: %s: %s", self.home_id, failure_message, err) except Exception: # noqa: BLE001 _LOGGER.warning("Home %s: %s", self.home_id, failure_message, exc_info=True)