diff --git a/AGENTS.md b/AGENTS.md index f3a1203..11fa2e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,16 +67,25 @@ in `__init__`; scope flags on `TeslaFleetApi.__init__` control which are built. ### Router (command side) `Router` (`router/base.py`) is an entity-agnostic composition wrapper, not part -of the inheritance chain: `Router(primary, secondary, *more, health=None)` chains -backends sharing a method surface and dispatches each call down the chain with -per-command failover — first backend that has the method, retried on the next on -any exception, returning the first success (last error if all fail, -`AttributeError` if none has the method). Non-callable attributes resolve to the -first backend that has them. +of the inheritance chain: `Router(primary, secondary, *more, health=None, +on_error=None)` chains backends sharing a method surface and dispatches each +call down the chain with per-command failover — first backend that has the +method, retried on the next on any exception, returning the first success +(last error if all fail, `AttributeError` if none has the method). +Non-callable attributes resolve to the first backend that has them. - The health check gates **only the primary**; the rest of the chain is reached purely through per-command failover. There is deliberately no per-backend health matrix. +- `on_error(exception, backend, method_name)` (sync or async) is called after + every dispatched call, success (`exception=None`, return ignored — the only + success hook) and failure alike; on failure (every per-command failover + exception except `BluetoothUnconfirmedCommand`) returning `False` stops + failover and re-raises immediately instead of trying the next backend, + letting a caller (e.g. Home Assistant reacting to a BLE key rejection via + `exceptions.is_key_rejected`) veto a retry it already knows will fail and run + its own side effect at the point of failure. Never fires for plain attribute + access, only for a dispatched callable. - Failover can double-execute a non-idempotent command that failed mid-flight. `BluetoothUnconfirmedCommand` is the one exception: it propagates without replay. @@ -169,6 +178,11 @@ would not match. Sibling repos each carry their own copy rather than calling it. `raise_for_status()` raises the right one. Signed-command faults have separate hierarchies (`TeslaFleetInformationFault`, `TeslaFleetMessageFault`, `SignedMessageInformationFault`, `WhitelistOperationStatus`). +`exceptions.is_key_rejected(exc)` is the positive allowlist of faults across +those hierarchies that specifically mean the vehicle didn't recognize/accept +our signing key (verified against each fault's proto enum meaning, not its +name) — e.g. excludes `TeslaFleetMessageFaultKeychainIsFull`, whose proto +meaning is "no room for another key", not "this key was rejected". **All exceptions inherit from `TeslaFleetError(BaseException)`, deliberately not `Exception`.** A bare `except Exception` (e.g. a retry loop around BLE reads) diff --git a/README.md b/README.md index 358a45f..1ae348d 100644 --- a/README.md +++ b/README.md @@ -243,10 +243,29 @@ async def main(): asyncio.run(main()) ``` -The constructor is `Router(primary, secondary, *more_backends, health=None)`; the two-argument form shown above is fully backward compatible, and any number of extra backends may follow to extend the chain. Each call is tried on the first backend that has the method and, on any exception except `BluetoothUnconfirmedCommand`, retried on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails). Non-callable attributes (e.g. `vin`) resolve to the first backend that has them. +The constructor is `Router(primary, secondary, *more_backends, health=None, on_error=None)`; the two-argument form shown above is fully backward compatible, and any number of extra backends may follow to extend the chain. Each call is tried on the first backend that has the method and, on any exception except `BluetoothUnconfirmedCommand`, retried on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails). Non-callable attributes (e.g. `vin`) resolve to the first backend that has them. By default the router attempts the primary and fails over on any error, with no up-front probe. You can also pass an explicit `health` check — a `bool`, a sync callable, or an async callable returning `bool` — to decide up front whether to route to the primary or skip straight to the rest of the chain. The health check gates **only the primary** (the first backend); later backends are reached purely through per-command failover. +You can also pass `on_error` — a sync or async callable `(exception, backend, method_name) -> bool` — to hook into every dispatched call's outcome, success or failure. On a backend exception during failover (every one except `BluetoothUnconfirmedCommand`) returning `True` lets failover continue to the next backend as normal, while `False` stops it and re-raises that exception immediately, so a command already known to fail on the next backend never gets sent there. On a successful call it's called the same way with `exception=None` and its return value ignored — the only hook available to observe a dispatch succeeding, e.g. to clear a repair a prior failure raised. For example, treating a BLE key rejection as terminal instead of falling over to the cloud, and clearing the repair once a command succeeds again: + +```python +from tesla_fleet_api.exceptions import is_key_rejected + +def on_error(exc, backend, method_name): + if exc is None: + clear_repair(backend) # a dispatched call just succeeded + return True + if is_key_rejected(exc): + raise_repair(backend) # your own repair/notification logic + return False # don't send this command to the cloud too + return True + +vehicle = VehicleRouter(primary, secondary, on_error=on_error) +``` + +`tesla_fleet_api.exceptions.is_key_rejected(exc)` reports whether a fault means the vehicle didn't recognize our signing key as paired/authorized (e.g. `NotOnWhitelistFault`), as opposed to any other signed-command fault. + `EnergySiteRouter` follows the same pattern for energy sites, pairing a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback: ```python diff --git a/tesla_fleet_api/exceptions.py b/tesla_fleet_api/exceptions.py index acc62e6..5ea6e20 100644 --- a/tesla_fleet_api/exceptions.py +++ b/tesla_fleet_api/exceptions.py @@ -1290,6 +1290,34 @@ class WhitelistOperationLocalEntityAuthFailedCancelled(WhitelistOperationStatus) ] +# Every fault a vehicle can return that means it did not recognize/accept our +# signing key as authorized to issue commands, verified against each fault's +# proto enum meaning (``MessageFault_E``/``SignedMessage_information_E`` in +# tesla-protocol's vcsec/universal_message protos) rather than its name alone. +# ``TeslaFleetMessageFaultKeychainIsFull`` is deliberately excluded: its proto +# meaning is "no room to add another key", not "this key was rejected". +KEY_REJECTED_FAULTS: tuple[type[TeslaFleetError], ...] = ( + NotOnWhitelistFault, + CouldNotRetrieveKeyFault, + SignedMessageInformationFaultNotOnWhitelist, + SignedMessageInformationFaultCouldNotRetrieveKey, + TeslaFleetMessageFaultUnknownKeyId, + TeslaFleetMessageFaultInactiveKey, + TeslaFleetMessageFaultInvalidKeyHandle, +) + + +def is_key_rejected(exc: BaseException) -> bool: + """Whether ``exc`` means the vehicle rejected our signing key. + + True for a fault meaning the vehicle does not recognize our key as + whitelisted/paired (or could not retrieve/validate it), false for every + other fault, including transport errors and faults about an unrelated + signed-command condition (e.g. a full keychain). + """ + return isinstance(exc, KEY_REJECTED_FAULTS) + + async def raise_for_status(resp: aiohttp.ClientResponse) -> None: """Raise an exception if the response status code is >=400.""" # https://developer.tesla.com/docs/fleet-api#response-codes diff --git a/tesla_fleet_api/router/__init__.py b/tesla_fleet_api/router/__init__.py index cdf2bfd..76aeb82 100644 --- a/tesla_fleet_api/router/__init__.py +++ b/tesla_fleet_api/router/__init__.py @@ -1,6 +1,6 @@ """Routing wrappers with per-command failover across backends.""" -from tesla_fleet_api.router.base import HealthCheck, Router +from tesla_fleet_api.router.base import ErrorHandler, HealthCheck, Router from tesla_fleet_api.router.vehicle import VehicleRouter from tesla_fleet_api.router.energysite import ( EnergySiteRouter, @@ -16,6 +16,7 @@ "VehicleRouter", "EnergySiteRouter", "HealthCheck", + "ErrorHandler", "LOCAL_LIVE_STATUS_KEYS", "LOCAL_SITE_INFO_KEYS", "merge_local_into_cloud", diff --git a/tesla_fleet_api/router/base.py b/tesla_fleet_api/router/base.py index 4071584..28d0e64 100644 --- a/tesla_fleet_api/router/base.py +++ b/tesla_fleet_api/router/base.py @@ -21,6 +21,18 @@ # directly and fails over to the next backend on exception (no up-front probe). HealthCheck = Union[bool, Callable[[], bool], Callable[[], Awaitable[bool]]] +# Called with (exception, backend, method_name) after every dispatched call. +# On failure (any backend exception during per-command failover except +# BluetoothUnconfirmedCommand) its return decides whether failover proceeds to +# the next backend (True) or stops and re-raises that exception immediately +# (False). On success it is called with exception=None and its return is +# ignored - this is the only way to observe a *successful* dispatch (e.g. to +# clear a repair raised by an earlier failure), since Router has no other +# success hook. +ErrorHandler = Callable[ + [Union[BaseException, None], Any, str], Union[bool, Awaitable[bool]] +] + async def _maybe_await(value: Any) -> Any: """Await ``value`` if it is awaitable, otherwise return it unchanged.""" @@ -88,6 +100,20 @@ class Router(Generic[PrimaryT, SecondaryT]): are always reached purely through per-command failover — there is deliberately no per-backend health matrix. + An optional ``on_error`` handler is called ``(exception, backend, method_name)`` + after every dispatched call, success or failure — it is the only hook into + dispatch outcomes, so it doubles as the success notification. On failure + (a backend raising during per-command failover; ``BluetoothUnconfirmedCommand`` + excepted — it never reaches the handler) its return value, sync or async, + decides whether failover continues to the next backend (``True``) or stops + and re-raises that exception immediately (``False``), which lets a caller + both suppress a failover it knows would also fail and run side effects (e.g. + raising a repair) at the point of failure. On success it is called with + ``exception=None`` and its return value is ignored — e.g. to clear a repair + raised by an earlier failure once a command goes through again. A handler + that itself raises propagates as-is, uncaught. The handler only ever fires + for a dispatched (callable) attribute, never for plain attribute access. + Dispatch is implemented via :meth:`__getattr__`, which does **not** proxy special/dunder methods (Python looks those up on the type, not the instance). In particular ``async with Router(...)`` does *not* enter a backend's async @@ -104,6 +130,7 @@ class Router(Generic[PrimaryT, SecondaryT]): _backends: tuple[Any, ...] _health: HealthCheck | None + _on_error: ErrorHandler | None def __init__( self, @@ -111,11 +138,13 @@ def __init__( secondary: SecondaryT, *more_backends: Any, health: HealthCheck | None = None, + on_error: ErrorHandler | None = None, ) -> None: # The two-argument ``Router(primary, secondary, health=...)`` form is # preserved exactly; additional positional backends extend the chain. self._backends = (primary, secondary, *more_backends) self._health = health + self._on_error = on_error async def is_healthy(self) -> bool: """Resolve an explicit health check to a bool. @@ -180,10 +209,16 @@ async def _routed(*args: Any, **kwargs: Any) -> Any: type(e).__name__, e, ) + if self._on_error is not None and not await _maybe_await( + self._on_error(e, backend, name) + ): + raise continue LOGGER.debug( "command=%s backend=%s result=success", name, type(backend).__name__ ) + if self._on_error is not None: + await _maybe_await(self._on_error(None, backend, name)) return result # The loop always runs at least once (``start`` only advances past # the primary when a later backend remains), so a failure here means @@ -199,7 +234,7 @@ def __getattr__(self, name: str) -> Any: # __getattr__ is only reached when normal lookup fails. Guard the private # attributes so an access before __init__ completes raises rather than # recursing infinitely through this method. - if name in ("_backends", "_health"): + if name in ("_backends", "_health", "_on_error"): raise AttributeError(name) backends = self._backends diff --git a/tests/test_is_key_rejected.py b/tests/test_is_key_rejected.py new file mode 100644 index 0000000..c178b83 --- /dev/null +++ b/tests/test_is_key_rejected.py @@ -0,0 +1,52 @@ +"""Unit tests for exceptions.is_key_rejected.""" + +from unittest import TestCase + +from tesla_fleet_api.exceptions import ( + BluetoothTimeout, + CouldNotRetrieveKeyFault, + NotOnWhitelistFault, + SignedMessageInformationFaultCouldNotRetrieveKey, + SignedMessageInformationFaultNotOnWhitelist, + SignedMessageInformationFaultTimeExpired, + TeslaFleetMessageFaultInactiveKey, + TeslaFleetMessageFaultInvalidKeyHandle, + TeslaFleetMessageFaultKeychainIsFull, + TeslaFleetMessageFaultUnknownKeyId, + TimeExpiredFault, + is_key_rejected, +) + + +class IsKeyRejectedTests(TestCase): + def test_true_for_not_on_whitelist_fault(self): + self.assertTrue(is_key_rejected(NotOnWhitelistFault())) + + def test_true_for_signed_message_not_on_whitelist(self): + self.assertTrue(is_key_rejected(SignedMessageInformationFaultNotOnWhitelist())) + + def test_true_for_could_not_retrieve_key_faults(self): + self.assertTrue(is_key_rejected(CouldNotRetrieveKeyFault())) + self.assertTrue( + is_key_rejected(SignedMessageInformationFaultCouldNotRetrieveKey()) + ) + + def test_true_for_unknown_key_id_and_inactive_key(self): + self.assertTrue(is_key_rejected(TeslaFleetMessageFaultUnknownKeyId())) + self.assertTrue(is_key_rejected(TeslaFleetMessageFaultInactiveKey())) + + def test_true_for_invalid_key_handle(self): + self.assertTrue(is_key_rejected(TeslaFleetMessageFaultInvalidKeyHandle())) + + def test_false_for_keychain_full(self): + # A full keychain means no room to add another key, not that this + # already-paired key was rejected. + self.assertFalse(is_key_rejected(TeslaFleetMessageFaultKeychainIsFull())) + + def test_false_for_unrelated_faults(self): + self.assertFalse(is_key_rejected(TimeExpiredFault())) + self.assertFalse(is_key_rejected(SignedMessageInformationFaultTimeExpired())) + + def test_false_for_transport_errors(self): + self.assertFalse(is_key_rejected(BluetoothTimeout())) + self.assertFalse(is_key_rejected(ConnectionError())) diff --git a/tests/test_router.py b/tests/test_router.py index f4d7cfd..69e08d1 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -245,6 +245,134 @@ async def test_primary_and_secondary_properties(self): self.assertIs(router.secondary, fallback) +class RouterErrorHandlerTests(IsolatedAsyncioTestCase): + """Behavioural tests for the optional ``on_error`` handler.""" + + async def test_handler_called_with_exception_backend_and_name_on_primary_failure( + self, + ): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback() + calls = [] + + def on_error(exc, backend, name): + calls.append((exc, backend, name)) + return True + + router = VehicleRouter(primary, fallback, on_error=on_error) + + result = await router.shared(1) + + self.assertEqual(result, "fallback:1") + # Called once for the primary's failure, once for the fallback's success. + self.assertEqual(len(calls), 2) + exc, backend, name = calls[0] + self.assertIsInstance(exc, ConnectionError) + self.assertIs(backend, primary) + self.assertEqual(name, "shared") + + async def test_handler_returning_true_continues_to_secondary(self): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback() + router = VehicleRouter( + primary, fallback, on_error=lambda exc, backend, name: True + ) + + result = await router.shared(2) + + self.assertEqual(result, "fallback:2") + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 1) + + async def test_handler_returning_false_reraises_and_skips_secondary(self): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback() + router = VehicleRouter( + primary, fallback, on_error=lambda exc, backend, name: False + ) + + with self.assertRaises(ConnectionError): + await router.shared(3) + + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 0) + + async def test_async_handler_is_awaited(self): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback() + + async def on_error(exc, backend, name): + return False + + router = VehicleRouter(primary, fallback, on_error=on_error) + + with self.assertRaises(ConnectionError): + await router.shared(4) + + self.assertEqual(fallback.shared_calls, 0) + + async def test_no_handler_is_unchanged_behaviour(self): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + result = await router.shared(5) + + self.assertEqual(result, "fallback:5") + + async def test_handler_called_with_none_exception_on_success(self): + primary = _FakePrimary() + fallback = _FakeFallback() + calls = [] + + def on_error(exc, backend, name): + calls.append((exc, backend, name)) + return True + + router = VehicleRouter(primary, fallback, on_error=on_error) + + result = await router.shared(7) + + self.assertEqual(result, "primary:7") + self.assertEqual(calls, [(None, primary, "shared")]) + + async def test_handler_called_with_none_exception_on_fallback_success(self): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback() + calls = [] + + def on_error(exc, backend, name): + calls.append((exc, backend, name)) + return True + + router = VehicleRouter(primary, fallback, on_error=on_error) + + result = await router.shared(8) + + self.assertEqual(result, "fallback:8") + self.assertEqual(len(calls), 2) + self.assertIsInstance(calls[0][0], ConnectionError) + self.assertIs(calls[0][1], primary) + self.assertEqual(calls[1], (None, fallback, "shared")) + + async def test_unconfirmed_command_bypasses_handler(self): + primary = _FakePrimary(exc=BluetoothUnconfirmedCommand()) + fallback = _FakeFallback() + calls = [] + + def on_error(exc, backend, name): + calls.append((exc, backend, name)) + return True + + router = VehicleRouter(primary, fallback, on_error=on_error) + + with self.assertRaises(BluetoothUnconfirmedCommand): + await router.shared(6) + + self.assertEqual(calls, []) + self.assertEqual(fallback.shared_calls, 0) + + class _FakeBackend: """A generic ordered backend for N-way routing tests.