fix: survive a transient HA failure in the charging-power tick (#643) - #675
Conversation
A Supervisor 502 on `Check grid charge state` escaped the 5-minute `adjust_charging_power` cron job. `adjust_battery_charging` makes two HA reads and both could kill the tick, by different routes: - `grid_charge_enabled()` re-raises `requests.RequestException` once `_api_request`'s retries are exhausted, and the caller's except clause named only AttributeError/ValueError/KeyError. - `get_charging_power_rate()` already degrades to None via `_get_raw_state`, and that None then reached `abs(target_power - current_power)` as a TypeError. A real outage hits both entities, so covering one read would have left which read the outage lands on deciding whether the tick survives. Behaviour is unchanged apart from where the failure is reported: the tick skips and the inverter keeps its current rate. Deliberately NOT fixed by catching inside `grid_charge_enabled()` and returning False — False selects the "solar-only, no fuse risk" branch in power_monitor, which would grant full target charging power precisely when grid charging can't be confirmed off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EswRHZDV575RZYPBq7L7o
|
@claude-bot review |
There was a problem hiding this comment.
Review: PR #675 (fix/issue-643-grid-charge-read)
Root-cause match: Confirmed against Stage 2's analysis and the source. grid_charge_enabled() (ha_api_controller.py:1630-1652) only catches ValueError, so a RequestException from _api_request() (retries exhausted) propagates through adjust_battery_charging() into adjust_charging_power()'s except (AttributeError, ValueError, KeyError) (battery_system_manager.py:3467, pre-fix) — unguarded, killing the APScheduler tick. Verified get_charging_power_rate() → _get_sensor_value → _get_raw_state (ha_api_controller.py:1413-1417) already degrades RequestException to None, and that None reaches abs(target_power - current_power) (power_monitor.py:264 pre-fix) as an unguarded TypeError — a second, independent crash path from the same outage class. Both match the PR description exactly.
Good deviation from Stage 2, well justified: Stage 2 proposed catching inside grid_charge_enabled() and returning False. The PR correctly rejects this — False there means "grid charging confirmed off," which routes power_monitor.py's if not self.controller.grid_charge_enabled(): into the solar-only, no-fuse-risk branch and grants full target charging power precisely when the state is actually unknown, not off. That's a real correctness bug the proposed fix would have introduced. Catching at the adjust_charging_power() call site instead (skip the tick, keep current rate) is the safer contract and fits that method's existing swallow-and-log responsibility — this is a local fix, not a structural one, and the PR body states the scope assessment clearly per rules.md step 9.
Minimal, no scope creep: Two narrow changes — one exception class added to an existing tuple, one None guard with early return — both fitting their target methods' existing contracts. No new classes, no new exception types (correctly, requests.RequestException is a stdlib type, doesn't belong in core/bess/exceptions.py), no hardcoded entity IDs, no exception-message string matching. set_grid_charge() (write-side) and backend/api.py:1064's use of grid_charge_enabled() are correctly left untouched — out of scope, per the analysis.
Tests — would catch a regression: Both new tests follow the existing test_unavailable_phase_sensor_does_not_crash_adjust_charging_power pattern (call-level, "must not raise"), which is appropriate here since the outcome under test is tick survival and there's no execution-model axis for a mid-tick HA outage. I independently verified both are RED without their fix:
- Reverting the
requests.RequestExceptionaddition tobattery_system_manager.py's except tuple →test_transient_ha_failure_does_not_crash_adjust_charging_powerfails withrequests.exceptions.RequestException: 502 Server Error...escaping, as claimed. - Reverting the
current_power is Noneguard inpower_monitor.py→test_unreadable_charge_rate_does_not_crash_adjust_charging_powerfails withTypeError: unsupported operand type(s) for -: 'int' and 'NoneType', as claimed.
Restored tree: both pass. Full fast suite green (pytest -m "not slow": 2227 passed, 50 skipped, 0 failed).black,ruff, and the mypy ratchet (scripts/mypy-changed.sh --include-worktree) all pass clean on the touched files.
Checklist: No Optional[x], no hasattr/getattr-with-default, no silent fallbacks beyond the explicitly-logged degrade-and-skip this fix adds by design, all sensor access already routes through ha_api_controller (unchanged), convert_keys_to_camel_case n/a (no API layer touched). CHANGELOG entry correctly added once under ## [Unreleased] → ### Fixed.
No blockers. Approving.
Summary
adjust_charging_powercron tick now survives a transient Home Assistant outage instead of crashing and being silently skipped.Root cause
grid_charge_enabled()reads through_api_request, which re-raisesrequests.RequestExceptiononce its retries are exhausted (onlyValueErroris caught inside the method). The calleradjust_charging_power()had an except clause naming onlyAttributeError/ValueError/KeyError, so a Supervisor 502 onCheck grid charge stateescaped to APScheduler and killed the tick. A real outage hits a second read too:get_charging_power_rate()already degrades aRequestExceptiontoNone, and thatNonereachedabs(target_power - current_power)as aTypeError, escaping the same tick.Fix
Two narrow changes that match the graceful-degradation contract already used by
_get_raw_state()and every other sensor-read getter inha_api_controller.py:adjust_charging_power()catchesrequests.RequestExceptionalongside the existing tuple — a transient HA failure logs and skips the tick, and the inverter keeps its current rate.adjust_battery_charging()logs and returns early when the charge-rate read degrades toNone, instead of computing on it.Deliberately not fixed by catching inside
grid_charge_enabled()and returningFalse(as the Stage 2 diagnosis suggested):Falseselects the "solar-only, no fuse risk" branch inpower_monitor.py, which would grant full target charging power precisely when grid charging can't be confirmed off.Test plan
./scripts/quality-check.shpasses locally (0 errors, mypy ratchet clean).venv/bin/pytest -m slowpasses (554 passed, 8 skipped)adjust_charging_power()through a realHomeAssistantAPIController:Check grid charge state→ 4 real retries (2s/4s/8s backoff) → failure tracker records[sensor_read]: Check grid charge state→Failed to adjust charging power: 502 Server Error...logged → tick survives._get_raw_statedegrades it toNone→Charging power rate unreadable; leaving the inverter rate unchangedlogged → tick survives.Evidence the test discriminates
requests.RequestExceptionadded to theexcepttuple inadjust_charging_power()test_transient_ha_failure_does_not_crash_adjust_charging_powerFAILED —requests.exceptions.RequestException: 502 Server Error...escaped (1 failed)current_power is Noneearly-return inadjust_battery_charging()test_unreadable_charge_rate_does_not_crash_adjust_charging_powerFAILED —TypeError: unsupported operand type(s) for -: 'int' and 'NoneType'(1 failed)Outcome-level coverage
The two new tests are call-level by design, and each docstring states why: the outcome under test is whether the scheduled tick survives the exception, and there is no execution-model axis for an HA outage mid-tick (the inverter simulator has no HA entity-failure input). No
expected_results/golden pin applies — this change is pure resilience; the optimizer's economics and the rate-mapping math are byte-identical.Documentation check
Neither
docs/agents/bess-knowledge.mdnordocs/SOFTWARE_DESIGN.mddescribes the exception-handling path this fix changes. Both mentionadjust_charging_poweronly for its purpose (fuse-limited charging) and a known cosmetic-setting limitation — neither invalidated.Refs #643