Skip to content

fix(teslemetry): raise InvalidResponse on malformed authorized_clients body#79

Merged
Bre77 merged 2 commits into
mainfrom
fm/tfa-authclients-nullraise-p2
Jul 13, 2026
Merged

fix(teslemetry): raise InvalidResponse on malformed authorized_clients body#79
Bre77 merged 2 commits into
mainfrom
fm/tfa-authclients-nullraise-p2

Conversation

@Bre77

@Bre77 Bre77 commented Jul 13, 2026

Copy link
Copy Markdown
Member

Intent

Fix TeslemetryEnergySite.find_authorized_clients() to raise InvalidResponse on a malformed authorized_clients response body, instead of silently returning an empty client list. Tesla's upstream authorized_clients endpoint intermittently returns HTTP 200 with a literally null body (observed live: AuthorizedClients(clients=[], raw=None)); previously that was indistinguishable from a genuinely empty client list, so callers (e.g. Home Assistant) couldn't detect the malformed case. Changed _authorized_clients_list() to raise InvalidResponse (existing malformed-data exception, already used elsewhere in the library) for: (1) a null response body, and (2) any other unrecognized shape (not a dict/list, or an envelope that unwraps to no authorized_clients list). A genuinely empty authorized_clients list is deliberately left unchanged and still parses to AuthorizedClients(clients=[]) without raising - only malformed/absent data is now an error, not an empty result. Updated the AuthorizedClients/parser docstrings (which previously documented the collapse-to-[] behavior) and the AGENTS.md 'Typed accessor pattern' entry to match. Updated tests/test_teslemetry_authorized_clients.py: the two tests that asserted collapse-to-empty-list for null body and unrecognized shape were replaced with assertRaises(InvalidResponse) tests; added a case for a non-dict/non-list body (bare string); kept the genuinely-empty-list and populated-list tests unchanged. This is an intentional, caller-visible BREAKING CHANGE for this published library - find_authorized_clients() now raises where it previously returned an empty result - flagged as such in the commit message. list_authorized_clients() (the untyped raw accessor) is unchanged and still returns a null body unchanged rather than raising.

What Changed

  • TeslemetryEnergySite.find_authorized_clients() now raises InvalidResponse when the authorized_clients response body is malformed - a null body (Tesla's endpoint intermittently returns HTTP 200 with literal null) or any unrecognized shape (non-dict/non-list body, or an envelope with no authorized_clients list) - instead of silently collapsing to an empty client list. A genuinely empty authorized_clients list still parses to AuthorizedClients(clients=[]) without raising, and the untyped list_authorized_clients() escape hatch is unchanged (still returns a null body as-is). This is a caller-visible breaking change: code that previously got [] for malformed data must now handle InvalidResponse.
  • Updated tests: the null-body and unrecognized-shape cases now assert InvalidResponse is raised, with a new bare-string-body case; empty-list and populated-list behavior is unchanged (verified in the pipeline's Test stage, 341 passed, plus an E2E check against a local server returning each malformed shape).
  • Updated docs/energy_local_control.md, docs/teslemetry.md, and the AGENTS.md typed-accessor entry to describe the new raising behavior (the two doc pages were caught still promising the old collapse-to-[] semantics by the pipeline review and fixed in a follow-up commit).

Risk Assessment

✅ Low: A deliberate, clearly-flagged breaking change confined to one typed accessor, with coherent raise semantics matching the existing InvalidResponse convention, full test coverage of the new paths, and docs/AGENTS.md already updated to match in HEAD.

Testing

Ran the updated authorized-clients unit tests and the full 341-test suite (all passing), then demonstrated the behavior end-to-end against a real local HTTP server reproducing Tesla's 200-with-null-body glitch: find_authorized_clients() raises InvalidResponse for null/unrecognized/non-dict bodies, a genuinely empty client list still parses without raising, a populated list round-trips typed, and the raw list_authorized_clients() accessor is unchanged.

Evidence: E2E transcript: find_authorized_clients() vs real HTTP server (null body, malformed shapes, empty list, populated list, raw accessor)

=== HTTP 200 + null body (live-observed Tesla glitch) === server returns: null find_authorized_clients() raised InvalidResponse: The response from Tesla was invalid. (data='authorized_clients response body was null') === HTTP 200 + envelope missing authorized_clients === server returns: {"response": {"foo": "bar"}} find_authorized_clients() raised InvalidResponse: The response from Tesla was invalid. (data={'response': {'foo': 'bar'}}) === HTTP 200 + bare string body === server returns: "not-a-valid-shape" find_authorized_clients() raised InvalidResponse: The response from Tesla was invalid. (data='not-a-valid-shape') === HTTP 200 + genuinely empty client list === server returns: {"response": {"authorized_clients": []}} find_authorized_clients() -> AuthorizedClients(clients=[], raw={'response': {'authorized_clients': []}}) === HTTP 200 + populated client list === find_authorized_clients() -> AuthorizedClients(clients=[AuthorizedClient(public_key='MIIBCgKCAQEAdemo==', state=<AuthorizedClientState.VERIFIED: 3>, ...)], ...) === raw escape hatch unchanged === list_authorized_clients() with null body -> None (no raise)


=== HTTP 200 + null body (live-observed Tesla glitch) ===
  server returns: null
  find_authorized_clients() raised InvalidResponse: The response from Tesla was invalid. (data='authorized_clients response body was null')

=== HTTP 200 + envelope missing authorized_clients ===
  server returns: {"response": {"foo": "bar"}}
  find_authorized_clients() raised InvalidResponse: The response from Tesla was invalid. (data={'response': {'foo': 'bar'}})

=== HTTP 200 + bare string body ===
  server returns: "not-a-valid-shape"
  find_authorized_clients() raised InvalidResponse: The response from Tesla was invalid. (data='not-a-valid-shape')

=== HTTP 200 + genuinely empty client list ===
  server returns: {"response": {"authorized_clients": []}}
  find_authorized_clients() -> AuthorizedClients(clients=[], raw={'response': {'authorized_clients': []}})

=== HTTP 200 + populated client list ===
  server returns: {"response": {"authorized_clients": [{"public_key": "MIIBCgKCAQEAdemo==", "state": 3}]}}
  find_authorized_clients() -> AuthorizedClients(clients=[AuthorizedClient(public_key='MIIBCgKCAQEAdemo==', state=<AuthorizedClientState.VERIFIED: 3>, raw={'public_key': 'MIIBCgKCAQEAdemo==', 'state': 3})], raw={'response': {'authorized_clients': [{'public_key': 'MIIBCgKCAQEAdemo==', 'state': 3}]}})

=== raw escape hatch unchanged ===
  list_authorized_clients() with null body -> None (no raise)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ⚠️ docs/energy_local_control.md:137 - docs/energy_local_control.md still promises find_authorized_clients() 'always returns a typed AuthorizedClients with clients == [] rather than raising' - now false. A caller following this doc won't catch InvalidResponse and will crash on the intermittent null-body case this change targets (and TeslaFleetError subclasses BaseException, so a generic 'except Exception' won't catch it either). Update the passage to say the method raises InvalidResponse on a null body or unrecognized shape.
  • ⚠️ docs/teslemetry.md:551 - docs/teslemetry.md still documents the old collapse behavior: 'a null response body, an unrecognized response shape, and an explicitly empty client list all mean "no authorized clients" and return []'. The first two now raise InvalidResponse; only a genuinely empty list returns []. Update to match the new semantics.
  • ℹ️ tesla_fleet_api/teslemetry/energysite.py:145 - Residual gap deliberately left in scope: non-dict entries inside a well-formed authorized_clients list are still silently skipped (_parse_authorized_clients, test-locked by test_non_dict_entries_are_skipped), so a list of garbage entries still parses to clients == [] indistinguishably from a genuinely empty list. Consistent with the tight-scope policy in AGENTS.md; noting the tradeoff only.

🔧 Fix: docs: match find_authorized_clients InvalidResponse raising behavior
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • uv run pytest tests/test_teslemetry_authorized_clients.py -v (10 passed, including new assertRaises(InvalidResponse) cases for null body, absent authorized_clients field, and bare-string body)
  • uv run pytest tests full suite (341 passed, 16 subtests, no regressions)
  • E2E manual check: ran find_authorized_clients() against a real local aiohttp server returning HTTP 200 with literal null, an envelope missing authorized_clients, a bare string, an empty list, and a populated list - captured transcript showing InvalidResponse raised for the three malformed shapes, empty list parsing to clients=[], populated entry typing state to AuthorizedClientState.VERIFIED, and list_authorized_clients() still returning None unchanged for a null body
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

firstmate crewmate added 2 commits July 14, 2026 09:28
…s body

Tesla's upstream authorized_clients endpoint intermittently returns HTTP
200 with a literally null body. find_authorized_clients() used to
collapse that, and any other unrecognized 200 shape, into an empty
client list indistinguishable from "no keys registered". Raise
InvalidResponse for both cases instead so callers (e.g. Home Assistant)
can tell malformed data from a genuinely empty list.

BREAKING CHANGE: find_authorized_clients() now raises InvalidResponse
on a null response body or an unrecognized response shape, where it
previously returned AuthorizedClients(clients=[]).
@Bre77 Bre77 merged commit 704281f into main Jul 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant