Repository navigation
fix(ha): ride out a Home Assistant restart instead of stopping after 10 errors - #5449
Open
chalfontchubby wants to merge 18 commits into
Open
chalfontchubby wants to merge 18 commits into
chalfontchubby wants to merge 18 commits into
Conversation
…10 errors A Home Assistant restart takes minutes to serve its API again, but Predbat treated the outage as fatal in several places that each tripped within seconds: - api_call() counted every timeout, connection error and non-JSON reply (a 502/404 HTML page) in a counter with no time decay and stopped the whole interface at 10. - The Web Socket loop stopped after 10 failed reconnects, about 50 seconds. - initialize() made one /api/services check and raised on any failure, leaving the HA interface permanently missing. Replace the failure counter with an outage timer (HA_OUTAGE_LIMIT_SECONDS, 15 minutes): any contact with Home Assistant ends the outage, and only an unbroken outage of that length is fatal. The REST calls and the Web Socket each keep their own timer, so one working cannot hide the other being down; the Web Socket's ends only when it authenticates. initialize() retries with backoff for the same period before raising, but fails at once if Home Assistant rejects the ha_key (401/403), which waiting cannot fix. Other requests failures (a connection reset part way through a reply) now count as an outage rather than escaping api_call(). Supervisor calls (core=False) are not Home Assistant being down and no longer count. Addresses #5437, #5354 and the start-up half of #5134. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicted files: - tools/debug-journal.md: the "Too many API errors" symptom row. This branch rewrote its lead to say the outage-timer change fixes it and to keep the old mechanism for older logs; main (#5416) appended a GH#5437 paragraph on one outage walking both the counter and the #5134 init-failure mechanism. Kept this branch's lead and main's paragraph, framing the latter as pre-fix behaviour, dropped main's now-stale "the fix is a maintainer design call" sentence, and added a closing note that initialize() now retries /api/services for up to 15 minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…SON body The gateway-status check in api_call() only ran when the outage timer was tracking, so initialize()'s untracked /api/services retries took a JSON error body from a restarting gateway as Home Assistant being ready. Check the status for every core call and only skip the timer update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inutes The catch-all for other requests errors also swallowed InvalidURL, MissingSchema, InvalidSchema, InvalidHeader and URLRequired, so a key with a trailing newline or a URL with no scheme was retried by initialize() as an outage for 15 minutes before a generic error. Let those escape as they did before, so the component fails at once naming the real problem. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
So a later reader can check which version the fix landed in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tage 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>
Re-raising HA_NOT_OUTAGE_ERRORS from every call put back main's behaviour of one bad runtime call (a NaN attribute in set_state, a proxy redirect loop) unwinding the whole update cycle. Only initialize() needs to fail fast, so re-raise only for its untracked calls; at runtime log the error and return None without touching the outage timer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The recovery logic matches the stated requirements and is covered across relevant failure modes.
0 open findings
What changed in this PR
Improves resilience during Home Assistant restarts by replacing rapid failure counters with duration-based outage handling.
Changes:
- Adds independent REST and WebSocket outage timers.
- Retries startup API checks with bounded backoff.
- Expands tests and updates troubleshooting guidance.
| File | Description |
|---|---|
apps/predbat/ha.py |
Implements outage tracking, retries, and error classification. |
apps/predbat/tests/test_hainterface_api.py |
Tests REST and initialization recovery behavior. |
apps/predbat/tests/test_hainterface_websocket.py |
Tests WebSocket outage and handshake handling. |
apps/predbat/tests/test_hainterface_lifecycle.py |
Adapts lifecycle tests for startup retries. |
apps/predbat/tests/test_hainterface_common.py |
Adds deterministic fake-time support. |
tools/debug-journal.md |
Documents current and legacy outage behavior. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Conflicted files: - tools/debug-journal.md: the "Too many API errors" symptom row. Main's only change to that row was adding the missing trailing `|` cell terminator; the PR side rewrites the row for the outage-timer fix and already ends with ` |`. Kept the PR's row; every other journal change from main (the 2026-10-11 queue slice, #5481) merged in unchanged. Main's gateway.py / test_gateway.py / cspell changes merged cleanly and do not touch ha.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the removed API-error counter This PR replaced the fatal API-error counter with an outage timer, so the History fetch row now describes the counter as pre-#5449 behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict in tools/debug-journal.md, the "Too many API errors, stopping" row: main left that row untouched and added a new row after it (GH#5530, the apps.yaml parse failure that loops on the add-on wrapper restart). Kept this branch's rewrite of the row (outage-timer behaviour, old counter kept for older logs) and appended main's new #5530 row below it. The rest of the file merged cleanly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the whole 15-minute retry The component-health row still said HAInterface raises on the first 502 from /api/services and that an HA core restart costs ~12.5 minutes. Since this PR initialize() retries for HA_OUTAGE_LIMIT_SECONDS first (failing at once only on a 401 or a malformed ha_url/ha_key), so frame the 12.5-minute cost as pre-fix behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
requests only wraps json's ValueError (NaN, infinity) as InvalidJSONError; a value json cannot serialise at all raises TypeError, which this tuple does not catch. The comment implied every unencodable body was covered. Comment only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/504 Proxies in front of a restarting Home Assistant answer the upgrade with other server errors too (a plain 500, Cloudflare's 520-530). Those went to error_count, so 10 attempts (about 50 seconds) still stopped Predbat during a restart - the #5437 failure. A 5xx is a server-side failure that waiting can fix, so it now feeds the Web Socket outage timer; a 4xx (wrong path, 401) is still counted and stops after 10. The handshake test now covers 500, 504 and 530; 500 and 530 fail without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t an outage fatal can mean The outage-timer row said api_call() and socketLoop() no longer count failures, but a REST 401 and, on the Web Socket, a 4xx upgrade refusal or an unexpected startup exception are still counted and stop at 10, each with its own log line. It also said a 15-minute 'unavailable' fatal means HA genuinely did not answer, but a Web Socket auth_invalid feeds that timer and a reverse proxy can keep a channel failing with HA up - the bracketed reason tells them apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- initialize() retries: drop the check that the runtime outage timer ended; initialize() never touches it (track_outage=False), so the check could not fail. - Gateway status: the comment said a second 503 does not clear the outage, but no second 503 was sent. Send one and check the outage start is kept. - Configuration error: MissingSchema subclasses ValueError, so catching ValueError could not tell the real error from initialize()'s generic one. Check the MissingSchema itself propagates. Each new assertion fails against a mutated ha.py (generic ValueError raised; outage reset by a 503). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th Cloudflare's 520-530 The previous commit made the Web Socket ride out any 5xx while api_call() still used 502/503/504 only, so the two channels disagreed: a JSON-bodied 530 on REST was returned as data and ended the REST outage, and on /api/services counted as Home Assistant being ready. It also turned a permanent 501 (a proxy that cannot do the upgrade) into a 15-minute wait logged as Home Assistant being unavailable. HA_RESTARTING_STATUSES is now 502-504 plus Cloudflare's 520-530, used by both channels. 500 and 501 stay rejections (stop after 10 on the Web Socket): a misconfigured proxy keeps sending them, and Home Assistant can send 500 itself. Tests: the handshake test pins 500/501 as counted and 520/530 as an outage (500/501 fail against the previous commit); the REST gateway test adds a JSON 530, which failed before this change. Also: - the second-503 check now runs on a fake clock, so a restarted outage cannot get the same start time on a coarse monotonic clock; - the initialize() retries test checks that no runtime outage warning is logged, which fails if a retry drops track_outage=False; - the socketLoop comment no longer says only the first failure is logged (the reconnect lines still log every attempt). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… status list Name HA_RESTARTING_STATUSES (502-504 and Cloudflare's 520-530) instead of 'gateway 5xx'; a Web Socket upgrade refused with any other status (not only a 4xx) is counted to 10 and logs its status each time; a REST 401 count is reset by the next good REST reply, not any reply; and the list of what is still counted is the main cases, not all of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… gateway statuses The previous commit took all of 520-530 as a restarting Home Assistant, which included 525/526 (Cloudflare's TLS to the origin failed - a certificate problem that waiting will not fix) and 527-529 (not origin-down codes). An expired origin certificate on the Web Socket upgrade would then be ridden out for 15 minutes and reported as Home Assistant being unavailable; it is now counted and stops after 10 attempts. (On REST, Cloudflare's HTML 525/526 page still fails to decode and is ridden out as an outage, as any non-JSON reply is.) The list is now 502-504 plus 520-524 and 530. The Web Socket handshake test adds 526 as counted; it fails against the previous commit. Its docstring is shortened to the line-length limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Opened by Claude on behalf of @chalfontchubby.
Fixes #5437
Fixes #5354
Addresses the start-up half of #5134 (a companion change for the 10 minute wait is separate).
Problem
A Home Assistant restart takes minutes to serve its API again, but Predbat treated the outage as fatal in several places that each trip within seconds:
HAInterface.api_call()counted every timeout, connection error and non-JSON reply (a 502/404 HTML page) in a counter with no time decay, and stopped the whole interface at 10.HAInterface.initialize()made one/api/servicescheck and raised on any failure, leaving the HA interface missing for the whole run.The reporter's log on #5437 shows all three in sequence:
Web Socket exception in startup: 502,Too many API errors, stopping,Cannot initialise Home Assistant Interface.Change
OutageTimer(ha.py). An outage is fatal only when failures run unbroken forHA_OUTAGE_LIMIT_SECONDS(15 minutes); any good reply ends it, and failures more thanHA_OUTAGE_GAP_SECONDS(10 minutes) apart are separate outages. REST calls and the Web Socket each have their own timer, so one working cannot hide the other being down.initialize()retries/api/serviceswith backoff (5s doubling to 30s) for the same period before raising.requestserrors (e.g. a reset part way through a reply), and a gateway status even with a JSON body: 502/503/504, plus Cloudflare's origin-down 520-524 and 530 (HA_RESTARTING_STATUSES, shared by REST and the Web Socket).initialize()also keeps retrying on a gateway status with a JSON body, rather than taking the gateway's error body as Home Assistant being ready.ha_url/ha_key(InvalidURL,MissingSchema,InvalidSchema,InvalidHeader,URLRequired), a redirect loop (TooManyRedirects) or a body that cannot be encoded (InvalidJSONError, e.g. a NaN attribute).initialize()fails at once on these, naming the error; at runtime the call is logged and dropped without touching the outage timer.initialize()fails at once on it (waiting will not fix a bad key), and 10 in a row at runtime is still fatal. A Web Socket handshake refused with any other status (404, 401, 500, 501, Cloudflare's 525/526 certificate failures...) is still counted and stops after 10. Supervisor calls (core=False) no longer count against Home Assistant.tools/debug-journal.md: the "Too many API errors" row now describes the new behaviour (including what is still counted to 10), cites this PR, and keeps the old mechanism for older logs; the GH#5134 start-up stall in the component-health row is framed as pre-fix behaviour. Main's GH#5437 paragraph (one outage walking both this mechanism and Failed HAInterface init blocks startup for 10 minutes in wait_api_started, so an HA restart costs ~12.5 min downtime #5134's) is merged in and framed as pre-fix behaviour.Tests
New and updated tests in
test_hainterface_api.py,_websocket.py,_lifecycle.py(fake clock viapatch("ha.time", ...), so asyncio and threading keep the real one). Covered: a burst of failures is tolerated, fatal exactly at the limit, success ends an outage, a long gap starts a new one, REST success does not hide a down Web Socket,auth_okends the Web Socket outage, retries then success (including a JSON-bodied 503 during theinitialize()retries), gives up after the limit, 401 fails fast and 10 in a row is fatal, gateway handshakes (502-504, 520, 530) versus counted ones (404, 401, 500, 501, 526), a JSON-bodied 503/530 on REST, and errors that are not an outage (malformed URL/header, redirect loop, unencodable body) raise frominitialize(), are dropped at runtime, and never start an outage../run_all --quickand pre-commit pass. I checked the main new tests fail if the fix they cover is removed.Known trade-offs
initialize()blocks start-up for up to 15 minutes while Home Assistant is unreachable (longer if each attempt hangs to its 300s timeout), so a wrongha_urlthat still connects now takes that long to report. Components started after the HA interface, including the web component, wait too, so the UI and/api/pingare unreachable for that time. A degraded start where the Web Socket loop does the waiting would avoid that, but is a bigger change.🤖 Generated with Claude Code