feat(router): add on_error handler for per-command failover - #150
Merged
Merged
Conversation
Router now accepts an optional on_error(exception, backend, method_name) handler, called after every dispatched call (BluetoothUnconfirmedCommand excepted). On a backend failure its return value decides whether failover proceeds to the next backend or stops and re-raises immediately; on a successful dispatch it's called with exception=None so a caller can also observe recovery (e.g. clear a repair). VehicleRouter/EnergySiteRouter inherit it unchanged since neither overrides __init__. Also adds exceptions.is_key_rejected(exc), a verified allowlist of the faults that mean the vehicle didn't recognize our signing key, so a caller like Home Assistant can gate a repair/no-cloud-fallback decision on it without reimplementing fault classification.
Merged
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.
Intent
Captain 2026-09-14, verbatim, about the Home Assistant PR home-assistant/core#181997 which today wraps VehicleBluetooth in a _KeyRejectionWatcher proxy to catch NotOnWhitelistFault and raise a repair: "i suspect this wrapper isnt going to be accepted and well need a way to pass an error handler into the Bluetooth vehicle class, or into the vehicle router (probably cleaner) so we can actually catch these. This error habdler can probably return a Boolean if the command should go to the second method or not, enabling the ability the prevent a command going to cloud if we actually know it would fail, and to inject the repair handler."
Standing rule he applies: library-first - machinery lives in tesla-fleet-api, Home Assistant keeps only platform glue.
Outcome: Router (tesla_fleet_api/router/base.py, and therefore VehicleRouter and EnergySiteRouter) accepts an optional error handler. When a backend raises during per-command failover, the router calls the handler with what happened and the handler's boolean decides whether the command proceeds to the next backend; a False stops failover and re-raises that backend's exception. Home Assistant will pass a handler that raises its repair on a key rejection and lets the command fail over to the cloud, which signs with its own key; it returns False only where the cloud path is known to fail too, so such a command is never sent.
Additions from the Home Assistant side, folded into the same PR: (a) the handler must also be told about primary SUCCESS so HA can clear a repair on the next good Bluetooth command - implemented by calling the same handler with exception=None on a successful dispatch (return value ignored). (b) export a predicate function named is_key_rejected taking one exception and returning bool, in tesla_fleet_api.exceptions, that says which faults mean the vehicle rejected our key: NotOnWhitelistFault plus the keychain / unknown-key / inactive-key message faults and CouldNotRetrieveKeyFault where that is what they mean - verified against proto fault meanings, not guessed. (c) the hook fires only for dispatched calls, never for attribute access such as listen_* seams.
What Changed
Router.__init__(tesla_fleet_api/router/base.py) accepts an optionalon_error: ErrorHandlercallback (newErrorHandlertype, re-exported fromtesla_fleet_api/router/__init__.py)._dispatchcalls it(exception, backend, method_name)after every dispatched call: on failure during per-command failover its (possibly async) return value decides whether to continue to the next backend (True) or stop and re-raise immediately (False); on success it is called withexception=None(return ignored) so a caller can clear state set by an earlier failure.BluetoothUnconfirmedCommandstill bypasses the handler and re-raises without failover, unchanged.is_key_rejected(exc)totesla_fleet_api/exceptions.py, a predicate over a newKEY_REJECTED_FAULTSallowlist (NotOnWhitelistFault,CouldNotRetrieveKeyFault, theirSignedMessageInformationFaultcounterparts, and theTeslaFleetMessageFaultunknown-key/inactive-key/invalid-key-handle faults) identifying faults meaning the vehicle rejected the signing key, verified against each fault's proto meaning rather than its name.tests/test_is_key_rejected.pyand expandedtests/test_router.pycovering the new handler's success/failure paths, theFalseveto, and theBluetoothUnconfirmedCommandexclusion; updatedAGENTS.mdandREADME.mddocumentation to describe the handler and predicate.Risk Assessment
✅ Low: The fix round correctly adds TeslaFleetMessageFaultInvalidKeyHandle (proto meaning: revoked/expired key) to KEY_REJECTED_FAULTS, resolving the sole round-1 finding; the on_error handler implementation in router/base.py precisely matches the intent (fires only for dispatched calls, success and failure, BluetoothUnconfirmedCommand bypasses it, boolean return gates failover), docs/README/AGENTS.md are consistent with the code, and the added tests are behavioral (exercise Router/is_key_rejected via real calls) rather than source-content matches.
Testing
Ran the targeted unit tests for the Router error-handler feature and the is_key_rejected predicate (44/44 passed), then wrote and ran a standalone manual script reproducing the actual Home Assistant PR scenario described in the user intent — a BLE-primary/cloud-fallback VehicleRouter with an on_error handler that raises a repair and vetoes cloud failover on key rejection, and clears the repair on the next successful command — confirming all three required behaviors (veto-prevents-cloud-call, success-clears-repair, unrelated-failures-still-fail-over) end-to-end; no issues found.
Evidence: HA key-rejection scenario CLI transcript
door_lock() raised NotOnWhitelistFault, cloud.calls=0 (failover vetoed), repair raised; subsequent good command cleared repair; unrelated BluetoothTimeout still failed over to cloud normally (cloud3.calls=1). ALL SCENARIO ASSERTIONS PASSED.Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 1 issue found → auto-fixed ✅
tesla_fleet_api/exceptions.py:1299- KEY_REJECTED_FAULTS (tesla_fleet_api/exceptions.py:1299-1306) omits TeslaFleetMessageFaultInvalidKeyHandle (code 27, proto MESSAGEFAULT_ERROR_INVALID_KEY_HANDLE), whose own docstring reads "The key handle is not valid. The key may have been revoked or expired" — the same 'this key is no longer accepted' meaning as the included UnknownKeyId/InactiveKey faults. The intent requires the allowlist be 'verified against proto fault meanings, not guessed,' and explicitly enumerates 'the keychain / unknown-key / inactive-key message faults' without mentioning this one, so it's unclear whether the omission was a deliberate scoping decision (e.g. 'key handle' meaning an ephemeral per-session handle rather than the long-lived signing key) or an oversight.🔧 Fix: Add InvalidKeyHandle fault to key-rejection allowlist
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
uv run pytest tests/test_router.py tests/test_is_key_rejected.py -v— 44 passedManual end-to-end script simulating the Home Assistant integration scenario from the user intent: VehicleRouter(ble_primary, cloud_fallback, on_error=handler) where the handler raises a repair and returns False on is_key_rejected(exc), verifying (1) a NotOnWhitelistFault on the BLE primary propagates without ever calling the cloud fallback, (2) a subsequent successful BLE command clears the repair via the exception=None success callback, and (3) an unrelated BluetoothTimeout still fails over to cloud normally✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.