Skip to content

Commit 31648ea

Browse files
fix(ha): do not treat a redirect loop or an unencodable body as an outage
TooManyRedirects is a configuration problem and InvalidJSONError is raised locally when a POST body (e.g. a NaN attribute) cannot be encoded; neither is Home Assistant being down, but the RequestException catch-all logged them as an outage and swallowed them. Let them escape like the other not-an-outage errors (tuple renamed HA_NOT_OUTAGE_ERRORS). Also correct test failure messages that still named the removed unavailable_since. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5421e87 commit 31648ea

2 files changed

Lines changed: 35 additions & 26 deletions

File tree

‎apps/predbat/ha.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,17 @@
5252
HA_RETRY_SECONDS = 5
5353
HA_RETRY_MAX_SECONDS = 30
5454

55-
# requests errors that mean ha_url or ha_key is malformed rather than Home Assistant being down
56-
HA_CONFIG_ERRORS = (
55+
# requests errors that are not Home Assistant being down, so waiting will not fix them: a malformed ha_url or ha_key
56+
# (e.g. a trailing newline), a redirect loop, or a body we cannot encode (InvalidJSONError; its JSONDecodeError
57+
# subclass, a reply we cannot decode, is caught before these in api_call())
58+
HA_NOT_OUTAGE_ERRORS = (
5759
requests.exceptions.InvalidURL,
5860
requests.exceptions.MissingSchema,
5961
requests.exceptions.InvalidSchema,
6062
requests.exceptions.InvalidHeader,
6163
requests.exceptions.URLRequired,
64+
requests.exceptions.TooManyRedirects,
65+
requests.exceptions.InvalidJSONError,
6266
)
6367

6468

@@ -1307,8 +1311,8 @@ def api_call(self, endpoint, data_in=None, post=False, core=True, silent=False,
13071311
if track:
13081312
self.ha_unavailable("connection error from {}".format(endpoint))
13091313
data = None
1310-
except HA_CONFIG_ERRORS:
1311-
# A malformed ha_url or ha_key (e.g. a trailing newline) is not an outage and waiting will not fix it, so let it fail loudly as before
1314+
except HA_NOT_OUTAGE_ERRORS:
1315+
# Not an outage and waiting will not fix it, so let it fail loudly as before
13121316
raise
13131317
except requests.exceptions.RequestException as e:
13141318
# Anything else requests raises while Home Assistant is going down, e.g. a connection reset part way through the body

‎apps/predbat/tests/test_hainterface_api.py‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def test_hainterface_api_call_json_decode_error(my_predbat=None):
169169

170170
# Verify an outage has been recorded
171171
if ha_interface.outage.since is None:
172-
print("ERROR: unavailable_since should be set")
172+
print("ERROR: outage.since should be set")
173173
failed += 1
174174
else:
175175
print("✓ outage recorded")
@@ -199,7 +199,7 @@ def test_hainterface_api_call_timeout(my_predbat=None):
199199

200200
# Verify an outage has been recorded
201201
if ha_interface.outage.since is None:
202-
print("ERROR: unavailable_since should be set")
202+
print("ERROR: outage.since should be set")
203203
failed += 1
204204
else:
205205
print("✓ outage recorded")
@@ -229,7 +229,7 @@ def test_hainterface_api_call_read_timeout(my_predbat=None):
229229

230230
# Verify an outage has been recorded
231231
if ha_interface.outage.since is None:
232-
print("ERROR: unavailable_since should be set")
232+
print("ERROR: outage.since should be set")
233233
failed += 1
234234
else:
235235
print("✓ outage recorded")
@@ -343,7 +343,7 @@ def test_hainterface_api_call_outage_cleared(my_predbat=None):
343343
mock_get.return_value = create_mock_requests_response(200, {"result": "success"})
344344
ha_interface.api_call("/api/states")
345345
if ha_interface.outage.since is not None:
346-
print("ERROR: unavailable_since should be cleared by a successful call")
346+
print("ERROR: outage.since should be cleared by a successful call")
347347
failed += 1
348348
else:
349349
print("✓ outage cleared on success")
@@ -691,26 +691,31 @@ def test_hainterface_api_call_auth_rejected_repeatedly_is_fatal(my_predbat=None)
691691

692692

693693
def test_hainterface_api_call_config_error_not_outage(my_predbat=None):
694-
"""Test api_call() lets a malformed ha_url/ha_key error escape rather than recording it as an outage"""
695-
print("\n=== Testing HAInterface api_call() configuration error ===")
694+
"""Test api_call() lets errors that are not Home Assistant being down (bad ha_url/ha_key, redirect loop, unencodable body) escape rather than recording an outage"""
695+
print("\n=== Testing HAInterface api_call() errors that are not an outage ===")
696696
failed = 0
697697

698-
mock_base = MockBase()
699-
ha_interface = create_ha_interface(mock_base, ha_key="test_key", db_enable=False, db_mirror_ha=False, db_primary=False)
700-
701-
with patch("ha.requests.get") as mock_get:
702-
mock_get.side_effect = requests.exceptions.InvalidHeader("Invalid leading whitespace, reserved character(s), or return character(s) in header value")
703-
raised = False
704-
try:
705-
ha_interface.api_call("/api/states")
706-
except requests.exceptions.InvalidHeader:
707-
raised = True
708-
709-
if not raised or ha_interface.outage.since is not None:
710-
print(f"ERROR: expected the error to escape without an outage, got raised={raised} / {ha_interface.outage.since}")
711-
failed += 1
712-
else:
713-
print("✓ configuration error escaped and was not an outage")
698+
cases = [
699+
("get", requests.exceptions.InvalidHeader("Invalid leading whitespace, reserved character(s), or return character(s) in header value")),
700+
("get", requests.exceptions.TooManyRedirects("Exceeded 30 redirects.")),
701+
("post", requests.exceptions.InvalidJSONError("Out of range float values are not JSON compliant")),
702+
]
703+
for method, error in cases:
704+
mock_base = MockBase()
705+
ha_interface = create_ha_interface(mock_base, ha_key="test_key", db_enable=False, db_mirror_ha=False, db_primary=False)
706+
with patch("ha.requests." + method) as mock_call:
707+
mock_call.side_effect = error
708+
raised = False
709+
try:
710+
ha_interface.api_call("/api/states/sensor.test", data_in={"state": 1} if method == "post" else None, post=method == "post")
711+
except type(error):
712+
raised = True
713+
714+
if not raised or ha_interface.outage.since is not None:
715+
print(f"ERROR: expected {type(error).__name__} to escape without an outage, got raised={raised} / {ha_interface.outage.since}")
716+
failed += 1
717+
else:
718+
print(f"✓ {type(error).__name__} escaped and was not an outage")
714719

715720
return failed
716721

0 commit comments

Comments
 (0)