Skip to content

fix(transport): auth / retry / MQTT hardening from v5.7.1 review - #44

Merged
tis24dev merged 8 commits into
tis24dev:devfrom
telard-pixel:fix/transport-auth-retry-mqtt
Jul 4, 2026
Merged

fix(transport): auth / retry / MQTT hardening from v5.7.1 review#44
tis24dev merged 8 commits into
tis24dev:devfrom
telard-pixel:fix/transport-auth-retry-mqtt

Conversation

@telard-pixel

@telard-pixel telard-pixel commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Greptile Summary

This PR fixes six transport-layer bugs identified in a cross-review of v5.7.1, covering auth error mis-routing, double command delivery, silent 5xx pass-through, refresh-generation accounting, MQTT subscription races, and inventory duplication on MFA-resumed setup.

  • Findings 1–3 (connection.py _intercept): Non-JSON (HTML) 200s now carry DECODE_ERROR so _requires_reauth returns False and the coordinator retries rather than prompting a 2FA re-entry; 5xx/429 responses are now raised as RuntimeError before the JSON decode so the 3-attempt backoff is no longer dead code; the replay-on-rejection condition is narrowed to real 401/403 rejections, preventing a successful POST from being sent twice when token_expires_soon is stuck True.
  • Finding 5 (_check_headers / _refresh_after_rejection): _refresh_gen is now advanced only on a successful refresh(), so a failed refresh no longer lets concurrent siblings skip their own refresh by trusting a falsely-bumped generation counter.
  • Finding 6 (mqtt.py _subscribe_missing): The subscribed-topics set identity is snapshotted before the await; a topic ACKed by a dropped session is no longer committed to the fresh empty set that a same-generation auto-reconnect created mid-SUBACK.
  • Finding 7 (hon_client.py): The fallback load_* path now tolerates a non-auth/non-retryable load_statistics failure, matching the primary update() path's existing behavior.
  • Finding 9 (session.py): setup() clears _appliances in-place before loading, preventing duplicates when MFA causes setup() to be called a second time. The appliances setter is removed since mutating the list in-place is the only safe operation given MQTT's by-reference binding.
  • Finding 8 (auth.py): refresh_token is now sent in the form body (data=) instead of the query string (params=), keeping it out of proxy logs and aiohttp exception representations.

Confidence Score: 5/5

Safe to merge — all six targeted bugs have targeted fixes with dedicated regression tests, and no new paths are introduced without coverage.

Each fix is narrow and self-contained: the error-routing changes are validated end-to-end against the existing _requires_reauth / classify() chain, the gen-counter fix is covered by two independent test cases (pre-request and rejection paths), the MQTT race fix correctly uses object identity rather than value equality to detect mid-await rebinds, and the session clear is verified to operate in-place. No pre-existing invariants are broken and no new failure modes are opened.

No files require special attention — the most complex change (_intercept in connection.py) is thoroughly commented and covered by tests for every new branch.

Important Files Changed

Filename Overview
custom_components/addhon/client/transport/connection.py Three correctness fixes in _intercept (non-JSON → DECODE_ERROR, double-POST on token_expires_soon, silent 5xx delivery) and one in _refresh_after_rejection/_check_headers (gen bump only on successful refresh). All routing paths verified against _requires_reauth / classify() logic.
custom_components/addhon/client/transport/auth.py Single-line change: params=data= on the OAuth2 token POST, moving the refresh_token from the URL query string into the form body. _ua() sets no Content-Type, so aiohttp's automatic application/x-www-form-urlencoded header is uncontested.
custom_components/addhon/client/transport/mqtt.py Snapshot-before-await identity guard in _subscribe_missing: a SUBACK from a dropped session is not committed to the fresh set created by the same-generation auto-reconnect. The ref.add(topic) path is equivalent to the original self._subscribed_topics_set.add(topic) when the identity check passes.
custom_components/addhon/hon_client.py Non-auth load_statistics failures in the fallback path are now tolerated (matching the primary update() path), while auth/retryable errors still propagate. The loaded flag is already True from prior successful loads, so the if not loaded: guard is unaffected.
custom_components/addhon/client/session.py Adds _appliances.clear() at the top of setup() (in-place, preserving MQTT's by-reference binding) and removes the appliances setter to enforce the in-place-only contract.
tests/test_transport_connection.py Adds 6 new test cases covering all three _intercept routing fixes and both refresh-generation paths.
tests/test_transport_mqtt.py New SubscribeMissingRebindRaceTest faithfully simulates same-generation disconnect + reconnect mid-SUBACK, correctly verifying the set is empty after the race.
tests/test_hon_client_realtime.py Adds FallbackLoadStatisticsToleranceTest with four cases covering non-auth tolerance, retryable/auth propagation, and the regression guard for load_attributes.
tests/test_native_session.py New test_setup_twice_does_not_duplicate_appliances verifies both the no-duplication invariant and that the list is mutated in-place (same object identity).
tests/test_transport_auth.py New test_refresh_sends_token_in_body_not_query_string captures POST kwargs and verifies the token is in data, not params.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[_intercept: loop=0] --> B[_check_headers]
    B -->|refresh succeeds| C[gen bumped, fresh tokens]
    B -->|refresh fails| D[gen unchanged, stale tokens]
    C --> E[Send request]
    D --> E

    E --> F{response status}

    F -->|401 or 403, loop=0| G[_refresh_after_rejection]
    G -->|refresh succeeds| H[gen bumped]
    G -->|refresh fails| I[gen unchanged]
    H --> J[recurse loop=1]
    I --> J

    F -->|401 or 403, loop=1| K[create: full re-auth]
    K --> L[recurse loop=2]

    L --> M{still 401 or 403?}
    M -->|Yes| N[raise NativeAuthError Login failure]
    M -->|No| P

    F -->|429| Q[raise RuntimeError: rate limited]
    F -->|500 or above| R[raise RuntimeError: server error]
    F -->|2xx non-401/403| P{JSON decode?}

    P -->|OK| S[yield response]
    P -->|Fails: HTML or CDN page| T[NativeAuthError plus DECODE_ERROR requires_reauth=False coordinator retries via UpdateFailed]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[_intercept: loop=0] --> B[_check_headers]
    B -->|refresh succeeds| C[gen bumped, fresh tokens]
    B -->|refresh fails| D[gen unchanged, stale tokens]
    C --> E[Send request]
    D --> E

    E --> F{response status}

    F -->|401 or 403, loop=0| G[_refresh_after_rejection]
    G -->|refresh succeeds| H[gen bumped]
    G -->|refresh fails| I[gen unchanged]
    H --> J[recurse loop=1]
    I --> J

    F -->|401 or 403, loop=1| K[create: full re-auth]
    K --> L[recurse loop=2]

    L --> M{still 401 or 403?}
    M -->|Yes| N[raise NativeAuthError Login failure]
    M -->|No| P

    F -->|429| Q[raise RuntimeError: rate limited]
    F -->|500 or above| R[raise RuntimeError: server error]
    F -->|2xx non-401/403| P{JSON decode?}

    P -->|OK| S[yield response]
    P -->|Fails: HTML or CDN page| T[NativeAuthError plus DECODE_ERROR requires_reauth=False coordinator retries via UpdateFailed]
Loading

Comments Outside Diff (1)

  1. custom_components/addhon/client/transport/connection.py, line 193-200 (link)

    P2 loop >= 2 branch can shadow the new 5xx guard on the error message

    The new if response.status >= 500 check lives in the else branch, but elif loop >= 2 and (self.auth.token_is_expired or response.status in (401, 403)) is evaluated first. If token_is_expired is True at loop 2 and the server returns 5xx, this branch fires and emits NativeAuthError("Login failure (status 502)") rather than the cleaner RuntimeError from the 5xx guard. The routing is still correct — _is_retryable_server_error matches "500"/"502"/"server error" in the message, so _requires_reauth returns False — but the log line reads "Login failure" for what is a server-side fault, which can mislead log analysis.

    In practice token_is_expired should be False immediately after create() (which initialises _expires = now), so this is an edge case. Worth keeping in mind if the loop-2 condition is ever revisited.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (4): Last reviewed commit: "fix(transport): label a 429 as rate-limi..." | Re-trigger Greptile

Three related mis-routings in HonConnection._intercept, all where a transient
cloud response was either delivered as success or escalated to a reauth flow:

1. A non-JSON body (HTML maintenance page, Cloudflare challenge, captive portal)
   raised NativeAuthError("Decode Error"), which _is_auth_error matched by class
   name while the message matched no retryable pattern -> _requires_reauth True ->
   ConfigEntryAuthFailed -> HA opened a spurious reauth (and, with 2FA on, asked
   for a fresh OTP) for a transient hiccup. Attach the existing DECODE_ERROR code
   (requires_reauth=False, ADDHON-470) so the duck-typed _requires_reauth routes
   it as UpdateFailed and the coordinator just retries. Aligns with classify(),
   which already maps "decode error" -> DECODE_ERROR.

2. The 401/403 retry branch also fired on token_expires_soon/token_is_expired,
   so a *successful* 200 was discarded and re-sent whenever the pre-request
   refresh had failed silently (refresh() -> False leaves _expires stale). For a
   POST /commands/v1/send that delivered the appliance command twice. Replay now
   triggers only on a real 401/403; expiry stays handled by _check_headers.

3. A 5xx/429 with a JSON body decoded cleanly and was delivered as "success"
   (empty attributes, empty AWS token downstream), and the 3-attempt backoff in
   async_get_appliances_data could never see a real server error. Raise a
   transient RuntimeError carrying the status (no "auth" in the name) so
   _is_retryable_server_error and classify() route it to that backoff.

Tests: non-JSON body carries DECODE_ERROR and does not re-login; 502/429 raise a
retryable non-auth error; a 200 under sticky expiry is sent once, not replayed.
HonAuth.refresh() returns False on a failed refresh without touching the tokens,
but both call sites (_check_headers pre-request and _refresh_after_rejection on
401/403 recovery) ignored the result and bumped _refresh_gen anyway. After a
failed recovery-refresh a concurrent sibling in the same burst would observe the
advanced generation, skip its own refresh under the CR#3 single-flight guard,
and reuse the still-stale tokens -- so its next request 401s too, costing an
extra round-trip and an avoidable full re-login. Bump the generation (and copy
the possibly-rotated token back) only when refresh() actually succeeds.

Tests: a False refresh on both paths leaves _refresh_gen and the token untouched.
…SUBACK

In _subscribe_missing, a disconnect + awscrt auto-reconnect on the SAME generation
(no _start()) can land while a SUBACK is in flight. The lifecycle handlers rebind
_subscribed_topics_set to a fresh empty set (the reconnected session carries no
subscriptions), but the old code then added the topic to whatever set was bound at
that moment -- the new one -- marking it "subscribed" on a session that never
subscribed it. Because the reconnect restores _connection=True, the watchdog's
post-await guard sees a healthy connection and never clears it, so that topic's
realtime push stays dead until the next disconnect.

Snapshot the set identity before the await and commit the topic only if the set was
not rebound in the meantime; otherwise leave it missing so the next watchdog tick
re-subscribes it on the fresh session.

Test: a disconnect+reconnect injected during the SUBACK leaves the topic uncommitted
(verified to fail without the guard).
…ack path

The primary update() path already tolerates a failed load_statistics (it only
carries the consumption counters) for non-auth/non-retryable errors, logging and
moving on. The load_* FALLBACK path did not: ANY load_statistics failure -- even
with load_attributes already loaded -- raised and made the whole appliance
unavailable until the next poll. Apply the same tolerance in the fallback loop so
the two paths behave consistently; load_attributes/load_commands stay fatal (that
is the appliance's actual data), and auth/retryable errors still propagate so
reauth and the 3-attempt backoff can act on them.

Tests: a non-auth load_statistics failure is tolerated; retryable/auth failures
and a load_attributes failure still fail the appliance.
If a 2FA challenge surfaces from a re-auth mid-setup (some appliances already
built), submit_mfa_code() resumes by calling setup() again, which re-appends the
full inventory -- duplicate appliance objects, each hydrated on every poll. The
coordinator dedupes by id so no double entities appear, but the duplicated work is
real. Clear the list in place at the start of setup() (never rebind: the MQTT
client binds it by reference).

Test: two consecutive setup() calls leave one entry per appliance, on the same
list object.
…ring

HonAuth.refresh() posted the token grant with params=, which puts client_id and
refresh_token in the request URL -- where the secret leaks into proxy/access logs
and aiohttp exception reprs (request_info.real_url). The OAuth2 token endpoint
expects application/x-www-form-urlencoded; switch to data= so the parameters are
form-encoded into the request body. Salesforce accepts both encodings.

NOTE: verify on live before merging -- the refresh flow has no test against the
real Salesforce token service, only this offline encoding assertion.

Test: refresh() sends the refresh_token via data= (body), never params= (query).
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: edd8e294-92a5-4fa1-a48e-937f8a733d43

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Transport: fix auth retry routing, refresh generation, and MQTT subscribe races

🐞 Bug fix 🧪 Tests ✨ Enhancement 🕐 40+ Minutes

Grey Divider

AI Description

• Route transient cloud failures as retries, not reauth, and avoid replaying successful 200s.
• Harden token refresh generation tracking and MQTT subscribe bookkeeping across reconnects.
• Add regression tests for auth refresh privacy, fallback stats tolerance, and setup idempotency.
Diagram

graph TD
  sess["NativeHon.setup"] --> hc["HonClient"] --> conn["HonConnection._intercept"] --> api{{"hOn Cloud API"}}
  conn --> auth["HonAuth (refresh/auth)"] --> api
  conn --> codes["ErrorCodes (DECODE_ERROR)"]
  sess --> mqtt["NativeMqttClient"] --> broker{{"MQTT Broker"}}
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use typed transport exceptions + HonCodedError for 5xx/429
  • ➕ Keeps all routing decisions (reauth vs retry) consistently code-driven, not class-name-driven
  • ➕ Avoids relying on RuntimeError string matching in _is_retryable_server_error
  • ➕ Makes status-specific UI/diagnostics (e.g., RATE_LIMITED vs SERVER_ERROR) easier
  • ➖ Slightly larger change footprint across classify()/routing predicates and tests
  • ➖ Requires picking/maintaining an exception taxonomy and mapping rules
2. Raise aiohttp.ClientResponseError via response.raise_for_status()
  • ➕ Standard library behavior; less custom branching code
  • ➕ Carries status and request info for debugging
  • ➖ May expose URLs in exception reprs/logs (review needed for token/PII leakage)
  • ➖ Would still need custom logic to avoid mis-routing as auth and to handle non-JSON bodies cleanly

Recommendation: The PR’s current approach is a good low-risk hardening pass: it minimally changes behavior while fixing concrete misroutes and adds strong regression tests. Consider a follow-up to introduce a small set of typed transport exceptions (or HonCodedError for 429/5xx) to reduce reliance on class-name heuristics and improve long-term maintainability.

Files changed (10) +372 / -12

Enhancement (1) +6 / -1
auth.pySend OAuth refresh_token in POST body instead of query string +6/-1

Send OAuth refresh_token in POST body instead of query string

• Switches the token refresh request to send refresh_token via form-encoded body (data=) rather than URL query parameters (params=). This reduces secret exposure in logs/exception representations while keeping Salesforce compatibility.

custom_components/addhon/client/transport/auth.py

Bug fix (4) +80 / -11
session.pyMake setup() idempotent by clearing appliance inventory in-place +7/-0

Make setup() idempotent by clearing appliance inventory in-place

• Clears the existing appliance list at the start of setup() to prevent duplicate appliance objects when setup is resumed after an MFA challenge. Uses in-place clear to preserve list identity for the MQTT client, which holds a reference to the list.

custom_components/addhon/client/session.py

connection.pyFix retry/reauth routing for decode errors, 5xx/429, and 401/403 replays +46/-10

Fix retry/reauth routing for decode errors, 5xx/429, and 401/403 replays

• Prevents successful 200 responses from being replayed due to token_expires_soon flags, limiting retries to true 401/403 rejections. Raises transient (non-auth) errors on 5xx/429 so backoff retry paths engage instead of silently decoding error JSON as “success”. Attaches DECODE_ERROR to non-JSON decode failures to avoid triggering Home Assistant reauth flows for transient HTML/CDN responses. Also ensures refresh generation only advances on successful refresh to avoid concurrency miscoordination.

custom_components/addhon/client/transport/connection.py

mqtt.pyAvoid committing subscriptions when session changes mid-SUBACK +13/-1

Avoid committing subscriptions when session changes mid-SUBACK

• Snapshots the subscribed-topic set identity before awaiting SUBACK and only commits the topic if the set is still the current one. Prevents marking topics subscribed on a session that disconnected/reconnected mid-flight, which could otherwise stall push updates until a rebuild.

custom_components/addhon/client/transport/mqtt.py

hon_client.pyTolerate non-auth load_statistics failures in fallback update path +14/-0

Tolerate non-auth load_statistics failures in fallback update path

• Aligns fallback load_* behavior with the primary update() path by treating load_statistics errors as non-fatal unless they are auth or retryable server errors. Keeps load_attributes and load_commands failures fatal since they represent core appliance data availability.

custom_components/addhon/hon_client.py

Tests (5) +286 / -0
test_hon_client_realtime.pyAdd tests for fallback load_statistics tolerance semantics +67/-0

Add tests for fallback load_statistics tolerance semantics

• Introduces a fallback-only appliance stub and tests that non-auth load_statistics errors are tolerated while auth/retryable errors still surface. Adds a regression guard ensuring load_attributes failures remain fatal.

tests/test_hon_client_realtime.py

test_native_session.pyTest setup() called twice does not duplicate appliances +19/-0

Test setup() called twice does not duplicate appliances

• Adds a regression test ensuring repeated setup() calls (e.g., after MFA resume) do not duplicate appliance objects. Verifies the appliance list is cleared in-place (same list object identity).

tests/test_native_session.py

test_transport_auth.pyTest refresh_token is sent in form body, not query string +30/-0

Test refresh_token is sent in form body, not query string

• Adds a capturing FakeSession to assert refresh() uses data= and does not include params= for the OAuth token endpoint. Ensures refresh_token and grant_type are present in the posted form payload.

tests/test_transport_auth.py

test_transport_connection.pyAdd regression tests for refresh generation and intercept routing +136/-0

Add regression tests for refresh generation and intercept routing

• Adds coverage ensuring _refresh_gen is not advanced on failed refresh attempts in both pre-request and 401-retry paths. Adds intercept tests validating DECODE_ERROR attachment on non-JSON bodies, retryable raising on 5xx/429, and no replay of a successful 200 when expiry flags are sticky.

tests/test_transport_connection.py

test_transport_mqtt.pyTest subscription-set rebind race during SUBACK is handled safely +34/-0

Test subscription-set rebind race during SUBACK is handled safely

• Adds a race test that simulates disconnect/reconnect rebinding _subscribed_topics_set while a subscribe await is in flight, asserting the topic is not incorrectly committed to the new set. Verifies connection state is restored while the missing topic remains pending for resubscribe.

tests/test_transport_mqtt.py

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Qodo Logo

@tis24dev
tis24dev force-pushed the fix/transport-auth-retry-mqtt branch from e0ad682 to bc03fa4 Compare July 4, 2026 21:40
…QTT-bound list

The @appliances.setter was never called in production (only test fakes
define a plain attribute of the same name). Worse, it rebinds _appliances,
which the MQTT client binds by reference at __init__ -- setup() deliberately
clears the list IN PLACE for exactly this reason. Remove the setter so a
future 'session.appliances = [...]' can't silently detach live subscriptions;
the read-only property stays and documents why there is no setter.
A 429 raised the message 'hOn server error (status 429)', which is
misleading in the logs (a rate-limit is not a server fault). classify()
already routes it correctly via the '429' token (-> RATE_LIMITED), and
_is_retryable_server_error also matches '429', so the wording carries no
routing weight. Split the branch and keep the '429' token so both stay
intact while the message reads accurately.
@tis24dev
tis24dev force-pushed the fix/transport-auth-retry-mqtt branch from ffa8ae1 to 131d088 Compare July 4, 2026 22:29
@tis24dev
tis24dev merged commit 9aa9a5b into tis24dev:dev Jul 4, 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.

2 participants