fix(ble): wrap transport failures in library errors#55
Merged
Conversation
added 2 commits
July 10, 2026 07:50
connect_if_needed()/connect() and the mid-command GATT write in _send() previously let raw bleak.exc.BleakError escape uncaught, so a caller doing except TeslaFleetError missed transport failures entirely (only the response-wait timeout was wrapped, as BluetoothTimeout). Wrap both paths in a new BluetoothTransportError(TeslaFleetError), chaining the original BleakError as the cause, so VehicleBluetooth failures are catchable through one hierarchy.
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
TeslaFleetErrorsubclass so callers get one catchable hierarchytfa-ble-edgecase-rel-n4§2a-2 (deterministic, real_send) — a mid-write GATT failure (write_gatt_char, before theasyncio.timeoutguard) and aconnect/connect_if_neededfailure both propagated as rawbleak.exc.BleakError, uncaught byexcept TeslaFleetError. Only the response-wait timeout was wrapped (BluetoothTimeout).BluetoothTransportError(TeslaFleetError)inexceptions.pyconnect()BleakErrorpath (whichconnect_if_needed()also goes through) and the mid-commandwrite_gatt_charBleakErrorpath in_send(), both re-raisingfrom eto preserve the chained causeVehicleBluetoothclass docstring and one line inAGENTS.md(the existing "catchTeslaFleetErrororBaseException" guidance) to state the new catch contractBleakErrordirectly will now seeBluetoothTransportErrorinstead, with the originalBleakErrorchained as__cause__aioesphomeapierrors are left unwrapped —aioesphomeapiis not a dependency of this library (only reachable transitively via an ESPHome BLE proxy), so there is no clean import path without adding a new dependency; accepted scope boundary, not an oversightWhat Changed
BluetoothTransportError, aTeslaFleetErrorsubclass, and wrapped BLE connection and GATT writeBleakErrorfailures so callers can catch transport failures through the library error hierarchy while preserving the original cause.VehicleBluetoothcallers.connect_if_needed()propagation.Risk Assessment
✅ Low: The change is tightly scoped to wrapping existing BLE connect/write transport failures in the library exception hierarchy, preserves exception causes, and adds focused regression coverage without broad behavioral churn.
Testing
Exercised the targeted BLE transport tests, the full pytest suite, and a consumer-style runtime probe showing both GATT write and connect failures are caught by
except TeslaFleetErrorasBluetoothTransportErrorwith the originalBleakErrorpreserved as__cause__; all checks passed.Evidence: BLE transport catch-contract transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
✅ **Test** - passed
✅ No issues found.
uv run pytest tests/test_ble_send_transport.py -quv run pytest tests -quv run python - <<'PY' | tee /tmp/no-mistakes-evidence/01KX4DTQ8YYQRAEXN51K8QWVB9/ble_transport_error_contract.txt✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
Full narrative / original brief
Wrap BLE transport-layer failures in a TeslaFleetError subclass so callers get one catchable hierarchy. Evidence (scout report tfa-ble-edgecase-rel-n4, section 2a-2, deterministic real _send probes): a mid-write GATT failure (write_gatt_char, bluetooth.py, before the asyncio.timeout guard) and a connect_if_needed/connect failure both propagated as raw bleak.exc.BleakError, not caught by 'except TeslaFleetError'. Only the response-wait timeout was wrapped (BluetoothTimeout). This is a small, scoped hardening PR (exception wrapping ONLY - no retry changes, no timeout changes, no other behavior changes): added BluetoothTransportError(TeslaFleetError) in exceptions.py, wrapped the connect() BleakError path (which connect_if_needed() also goes through) and the mid-command write_gatt_char BleakError path in _send(), both re-raising 'from e' to preserve the chained cause. Deliberately left aioesphomeapi errors unwrapped since aioesphomeapi is not a dependency of this library (only reachable transitively via an ESPHome BLE proxy) and there's no clean import path without adding a new dependency - this is a known, accepted scope boundary, not an oversight. Updated the VehicleBluetooth class docstring and one line in AGENTS.md (the existing 'catch TeslaFleetError or BaseException' guidance) to state the new catch contract - callers catching just TeslaFleetError now also catch BLE transport failures, not only BluetoothTimeout. This is a behavioral change worth flagging: callers who were previously catching raw BleakError directly will now see BluetoothTransportError instead (with the original BleakError chained as cause). Added 3 new tests to tests/test_ble_send_transport.py using the existing mocked-transport pattern (mocked GATT client / mocked establish_connection) covering: the mid-write GATT failure path, the connect() establish_connection failure path, and confirming connect_if_needed() propagates the same wrapped error. Ran ruff check, ruff format, pyright (strict), and the full pytest suite locally - all clean/passing (156 tests).