Skip to content

Commit 10328d4

Browse files
authored
fix: handle DEVICE_COMMUNICATION_ERROR and return stale cache on transient errors (#704)
* Handle DEVICE_COMMUNICATION_ERROR and return stale cache on transient errors Add PyViCareDeviceCommunicationError for API responses with errorType "DEVICE_COMMUNICATION_ERROR" (device/gateway offline). Previously these were misclassified as PyViCareInvalidDataError. In the cached service, catch transient errors (device communication errors and internal server errors) and return stale cache data when available, only raising if no cache exists. This prevents callers from crashing on temporary API failures while rate limiting behavior is preserved. Fixes #361 * Match existing key check pattern for errorType
1 parent 11aa768 commit 10328d4

File tree

7 files changed

+151
-5
lines changed

7 files changed

+151
-5
lines changed

PyViCare/PyViCareAbstractOAuthManager.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from PyViCare import Feature
99
from PyViCare.PyViCareUtils import (PyViCareCommandError,
10+
PyViCareDeviceCommunicationError,
1011
PyViCareInternalServerError,
1112
PyViCareRateLimitError)
1213

@@ -39,6 +40,7 @@ def get(self, url: str) -> Any:
3940
logger.debug("Response to get request: %s", response)
4041
self.__handle_expired_token(response)
4142
self.__handle_rate_limit(response)
43+
self.__handle_device_communication_error(response)
4244
self.__handle_server_error(response)
4345
return response
4446
except TokenExpiredError:
@@ -59,6 +61,10 @@ def __handle_rate_limit(self, response):
5961
if ("statusCode" in response and response["statusCode"] == 429):
6062
raise PyViCareRateLimitError(response)
6163

64+
def __handle_device_communication_error(self, response):
65+
if ("errorType" in response and response["errorType"] == "DEVICE_COMMUNICATION_ERROR"):
66+
raise PyViCareDeviceCommunicationError(response)
67+
6268
def __handle_server_error(self, response):
6369
if ("statusCode" in response and response["statusCode"] >= 500):
6470
raise PyViCareInternalServerError(response)

PyViCare/PyViCareCachedService.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from PyViCare.PyViCareAbstractOAuthManager import AbstractViCareOAuthManager
66
from PyViCare.PyViCareService import (ViCareDeviceAccessor, ViCareService,
77
readFeature)
8-
from PyViCare.PyViCareUtils import PyViCareInvalidDataError, ViCareTimer
8+
from PyViCare.PyViCareUtils import (PyViCareDeviceCommunicationError,
9+
PyViCareInvalidDataError,
10+
PyViCareInternalServerError,
11+
ViCareTimer)
912

1013
logger = logging.getLogger('ViCare')
1114
logger.addHandler(logging.NullHandler())
@@ -33,13 +36,20 @@ def setProperty(self, property_name, action, data):
3336
def __get_or_update_cache(self):
3437
with self.__lock:
3538
if self.is_cache_invalid():
36-
# we always sett the cache time before we fetch the data
39+
# we always set the cache time before we fetch the data
3740
# to avoid consuming all the api calls if the api is down
3841
# see https://github.com/home-assistant/core/issues/67052
3942
# we simply return the old cache in this case
4043
self.__cacheTime = ViCareTimer().now()
4144

42-
data = self.fetch_all_features()
45+
try:
46+
data = self.fetch_all_features()
47+
except (PyViCareDeviceCommunicationError, PyViCareInternalServerError) as e:
48+
if self.__cache is not None:
49+
logger.warning("API error, returning stale cache: %s", e)
50+
return self.__cache
51+
raise
52+
4353
if "data" not in data:
4454
logger.error("Missing 'data' property when fetching data.")
4555
raise PyViCareInvalidDataError(data)

PyViCare/PyViCareUtils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ class PyViCareInvalidDataError(Exception):
105105
pass
106106

107107

108+
class PyViCareDeviceCommunicationError(Exception):
109+
def __init__(self, response):
110+
extended_payload = response.get("extendedPayload", {})
111+
reason = extended_payload.get("reason", "Unknown")
112+
113+
msg = f'Device communication error. Reason: {reason}'
114+
115+
super().__init__(self, msg)
116+
self.message = msg
117+
self.reason = reason
118+
119+
108120
class PyViCareRateLimitError(Exception):
109121

110122
def __init__(self, response):
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"viErrorId": "00-1083dbcd84b0d4d6df2ae553dda5c7d2-5ad4674667af26df-00",
3+
"statusCode": 400,
4+
"errorType": "DEVICE_COMMUNICATION_ERROR",
5+
"message": "Device communication error",
6+
"extendedPayload": {
7+
"httpStatusCode": "NotFound",
8+
"code": "404",
9+
"reason": "DEVICE_OFFLINE"
10+
}
11+
}

tests/test_PyViCareCachedService.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33

44
from PyViCare.PyViCareCachedService import ViCareCachedService
55
from PyViCare.PyViCareService import ViCareDeviceAccessor
6-
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
6+
from PyViCare.PyViCareUtils import (PyViCareDeviceCommunicationError,
7+
PyViCareInternalServerError,
8+
PyViCareInvalidDataError,
9+
PyViCareNotSupportedFeatureError)
710
from tests.helper import now_is
811

912

@@ -67,3 +70,67 @@ def test_setProperty_invalidateCache(self):
6770

6871
self.service.getProperty("someprop")
6972
self.assertEqual(self.oauth_mock.get.call_count, 2)
73+
74+
def test_device_communication_error_returns_stale_cache(self):
75+
"""When device goes offline after successful fetch, return stale cache."""
76+
with now_is('2000-01-01 00:00:00'):
77+
self.service.getProperty("someprop")
78+
79+
# Device goes offline after cache expires
80+
self.oauth_mock.get.side_effect = PyViCareDeviceCommunicationError(
81+
{"errorType": "DEVICE_COMMUNICATION_ERROR",
82+
"extendedPayload": {"reason": "GATEWAY_OFFLINE"}})
83+
84+
with now_is('2000-01-01 00:01:10'):
85+
result = self.service.getProperty("someprop")
86+
87+
self.assertIsNotNone(result)
88+
89+
def test_server_error_returns_stale_cache(self):
90+
"""When server returns 500 after successful fetch, return stale cache."""
91+
with now_is('2000-01-01 00:00:00'):
92+
self.service.getProperty("someprop")
93+
94+
self.oauth_mock.get.side_effect = PyViCareInternalServerError(
95+
{"statusCode": 500, "message": "Internal server error",
96+
"viErrorId": "test"})
97+
98+
with now_is('2000-01-01 00:01:10'):
99+
result = self.service.getProperty("someprop")
100+
101+
self.assertIsNotNone(result)
102+
103+
def test_device_communication_error_raises_without_cache(self):
104+
"""When device is offline on first fetch (no cache), must raise."""
105+
self.oauth_mock.get.side_effect = PyViCareDeviceCommunicationError(
106+
{"errorType": "DEVICE_COMMUNICATION_ERROR",
107+
"extendedPayload": {"reason": "DEVICE_OFFLINE"}})
108+
109+
with now_is('2000-01-01 00:00:00'):
110+
self.assertRaises(
111+
PyViCareDeviceCommunicationError,
112+
self.service.getProperty, "someprop")
113+
114+
def test_server_error_raises_without_cache(self):
115+
"""When server errors on first fetch (no cache), must raise."""
116+
self.oauth_mock.get.side_effect = PyViCareInternalServerError(
117+
{"statusCode": 500, "message": "Internal server error",
118+
"viErrorId": "test"})
119+
120+
with now_is('2000-01-01 00:00:00'):
121+
self.assertRaises(
122+
PyViCareInternalServerError,
123+
self.service.getProperty, "someprop")
124+
125+
def test_invalid_data_still_raises_with_cache(self):
126+
"""PyViCareInvalidDataError (genuine bad data) must still raise even with cache."""
127+
with now_is('2000-01-01 00:00:00'):
128+
self.service.getProperty("someprop")
129+
130+
self.oauth_mock.get.side_effect = None
131+
self.oauth_mock.get.return_value = {"unexpected": "response"}
132+
133+
with now_is('2000-01-01 00:01:10'):
134+
self.assertRaises(
135+
PyViCareInvalidDataError,
136+
self.service.getProperty, "someprop")

tests/test_PyViCareExceptions.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import datetime
22
import unittest
33

4-
from PyViCare.PyViCareUtils import PyViCareCommandError, PyViCareRateLimitError
4+
from PyViCare.PyViCareUtils import (PyViCareCommandError,
5+
PyViCareDeviceCommunicationError,
6+
PyViCareRateLimitError)
57
from tests.helper import readJson
68

79

@@ -18,6 +20,27 @@ def test_createFromResponse(self):
1820
2020, 3, 17, 16, 20, 10, 106000))
1921

2022

23+
class TestPyViCareDeviceCommunicationError(unittest.TestCase):
24+
25+
def test_createFromGatewayOffline(self):
26+
mockResponse = readJson('response/errors/gateway_offline.json')
27+
28+
error = PyViCareDeviceCommunicationError(mockResponse)
29+
30+
self.assertEqual(
31+
error.message, 'Device communication error. Reason: GATEWAY_OFFLINE')
32+
self.assertEqual(error.reason, 'GATEWAY_OFFLINE')
33+
34+
def test_createFromDeviceOffline(self):
35+
mockResponse = readJson('response/errors/device_offline.json')
36+
37+
error = PyViCareDeviceCommunicationError(mockResponse)
38+
39+
self.assertEqual(
40+
error.message, 'Device communication error. Reason: DEVICE_OFFLINE')
41+
self.assertEqual(error.reason, 'DEVICE_OFFLINE')
42+
43+
2144
class TestPyViCareCommandError(unittest.TestCase):
2245

2346
def test_createFromResponse(self):

tests/test_ViCareOAuthManager.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from PyViCare.PyViCareOAuthManager import AbstractViCareOAuthManager
55
from PyViCare.PyViCareUtils import (PyViCareCommandError,
6+
PyViCareDeviceCommunicationError,
67
PyViCareInternalServerError,
78
PyViCareRateLimitError)
89
from tests.helper import readJson
@@ -54,6 +55,22 @@ def func():
5455
return self.manager.get("/")
5556
self.assertRaises(PyViCareInternalServerError, func)
5657

58+
def test_get_raisedevicecommunicationerror_gateway_offline(self):
59+
self.oauth_mock.get.return_value = FakeResponse(
60+
'response/errors/gateway_offline.json')
61+
62+
def func():
63+
return self.manager.get("/")
64+
self.assertRaises(PyViCareDeviceCommunicationError, func)
65+
66+
def test_get_raisedevicecommunicationerror_device_offline(self):
67+
self.oauth_mock.get.return_value = FakeResponse(
68+
'response/errors/device_offline.json')
69+
70+
def func():
71+
return self.manager.get("/")
72+
self.assertRaises(PyViCareDeviceCommunicationError, func)
73+
5774
def test_get_renewtoken_ifexpired(self):
5875
self.oauth_mock.get.side_effect = [
5976
FakeResponse('response/errors/expired_token.json'), # first call expired

0 commit comments

Comments
 (0)