Skip to content

Do not refresh OAuth token during Tibber unload - #176595

Merged
joostlek merged 4 commits into
home-assistant:devfrom
honzup:tibber-unload-no-oauth-refresh
Jul 27, 2026
Merged

Do not refresh OAuth token during Tibber unload#176595
joostlek merged 4 commits into
home-assistant:devfrom
honzup:tibber-unload-no-oauth-refresh

Conversation

@honzup

@honzup honzup commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Breaking change

Proposed change

async_unload_entry currently obtains the client via runtime_data.async_get_client(hass) — solely to disconnect it. That call performs a network OAuth token refresh (OAuth2Session.async_ensure_token_valid()), and when the token has rotated it calls Tibber.set_access_token(), which does a full realtime websocket reset, reconnect and resubscribe — all in the middle of tearing the entry down. Any exception escaping here (flaky Tibber API, auth failure), or a hang in the unbounded rt_disconnect() close path on a half-dead websocket, wedges the entry into the non-recoverable FAILED_UNLOAD state, from which only a Home Assistant restart recovers.

This change:

  • keeps a single way to obtain the client, async_get_client(), which is always authenticated, and adds an async_disconnect() action on TibberRuntimeData for teardown,
  • makes async_unload_entry disconnect the cached client directly (no network round-trip, no token refresh, no websocket rebuild during teardown),
  • bounds the disconnect with asyncio.timeout and logs instead of raising, so a dead websocket that won't close politely cannot fail the unload,
  • applies the same bounded disconnect to the EVENT_HOMEASSISTANT_STOP handler.

Real-world reports of reloads wedging into FAILED_UNLOAD with this integration: #168480 (several comments), #166228 (March comment with the full traceback chain), #176268 (the realtime-drop scenario in which users attempt the reload).

Test evidence: pytest tests/components/tibber/ → 93 passed. The new and changed tests fail against unpatched code, verified by reverting the component and re-running: test_entry_unload asserts set_access_token is not called during unload, a parametrised test_entry_unload_rt_disconnect_error asserts the entry reaches NOT_LOADED even when rt_disconnect hangs past the deadline or raises, and test_rotated_token_pushed_to_cached_client asserts a rotated OAuth token reaches the cached client without rebuilding it. The runtime-data tests now drive the integration through its config entry rather than constructing TibberRuntimeData directly.

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

Checklist

  • I understand the code I am submitting and can explain how it works.
  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.
  • Any generated code has been carefully reviewed for correctness and compliance with project standards.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.

To help with the load of incoming pull requests:

async_unload_entry fetched a client via async_get_client, which performs
a network OAuth token refresh and, when the token rotated, a full
websocket reset, reconnect and resubscribe - all while tearing the entry
down. Any exception escaping the unload path wedges the entry into the
non-recoverable FAILED_UNLOAD state, and the EVENT_HOMEASSISTANT_STOP
listener awaited rt_disconnect without a timeout.

Disconnect the cached client instead, exposed via a read-only property
on TibberRuntimeData, bound the disconnect with a 10 second timeout and
log instead of raising so unload cannot fail because a dead websocket
would not close politely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DERobfZZh3npFRbwpdxzz2
Copilot AI review requested due to automatic review settings July 16, 2026 08:30
@honzup
honzup requested a review from Danielhiversen as a code owner July 16, 2026 08:30

@home-assistant home-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @honzup

It seems you haven't yet signed a CLA. Please do so here.

Once you do that we will be able to review and accept this pull request.

Thanks!

@home-assistant home-assistant Bot added bugfix cla-needed has-tests integration: tibber small-pr PRs with less than 30 lines. Top 200 Integration is ranked within the top 200 by usage labels Jul 16, 2026
@home-assistant

Copy link
Copy Markdown
Contributor

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

@home-assistant

Copy link
Copy Markdown
Contributor

Hey there @Danielhiversen, mind taking a look at this pull request as it has been labeled with an integration (tibber) you are listed as a code owner for? Thanks!

Code owner commands

Code owners of tibber can trigger bot actions by commenting:

  • @home-assistant close Closes the pull request.
  • @home-assistant mark-draft Mark the pull request as draft.
  • @home-assistant ready-for-review Remove the draft status from the pull request.
  • @home-assistant rename Awesome new title Renames the pull request.
  • @home-assistant reopen Reopen the pull request.
  • @home-assistant unassign tibber Removes the current integration label and assignees on the pull request, add the integration domain after the command.
  • @home-assistant update-branch Update the pull request branch with the base branch.
  • @home-assistant add-label needs-more-information Add a label (needs-more-information, problem in dependency, problem in custom component, problem in config, problem in device, feature-request) to the pull request.
  • @home-assistant remove-label needs-more-information Remove a label (needs-more-information, problem in dependency, problem in custom component, problem in config, problem in device, feature-request) on the pull request.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents Tibber unloads from refreshing OAuth tokens or hanging on realtime disconnection.

Changes:

  • Exposes the cached Tibber client.
  • Adds bounded, non-raising realtime disconnect handling.
  • Adds unload regression tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
homeassistant/components/tibber/__init__.py Uses cached clients and bounded disconnects during unload and shutdown.
tests/components/tibber/test_init.py Tests token-refresh avoidance and disconnect failures.

Comment thread tests/components/tibber/test_init.py Outdated
@pytest.mark.parametrize(
"side_effect",
[
pytest.param(TimeoutError(), id="timeout"),
return self._client


async def _async_disconnect_client(client: tibber.Tibber | None) -> None:
Address the Copilot review comment on home-assistant#176595: raising a
preconstructed TimeoutError only tested exception suppression and
would still pass without the timeout bound. Make rt_disconnect hang
on an event that is never set and patch the disconnect timeout, now
extracted into the DISCONNECT_TIMEOUT constant, to 0.05 seconds so
the asyncio.timeout deadline genuinely expires and cancels it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DERobfZZh3npFRbwpdxzz2
Copilot AI review requested due to automatic review settings July 16, 2026 08:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@honzup
honzup marked this pull request as ready for review July 16, 2026 08:51
@home-assistant
home-assistant Bot dismissed their stale review July 16, 2026 08:51

Stale

Comment on lines +89 to +99
async def _async_disconnect_client(client: tibber.Tibber | None) -> None:
"""Disconnect the realtime connection without raising."""
if client is None:
return
try:
async with asyncio.timeout(DISCONNECT_TIMEOUT):
await client.rt_disconnect()
except Exception: # noqa: BLE001
_LOGGER.warning(
"Error disconnecting the Tibber realtime connection", exc_info=True
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay so I am wondering, why do we need to rt_disconnect and why does that require a token? I believe the token is a JWT token, and in theory you don't have to logout with such token as it will just expire. Is there a way we can modify the library to not need a token to disconnect?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good instinct - disconnecting doesn't need a token at all, and that's essentially what this PR fixes.

rt_disconnect() in pyTibber takes no credentials: it just closes the websocket and stops the watchdog task. The token involvement in the current code is incidental - async_unload_entry calls
runtime_data.async_get_client(hass) merely to obtain the client object, and that helper unconditionally refreshes the OAuth token over the network and, if the token rotated, calls set_access_token(), which tears down and
rebuilds the websocket in the middle of the unload. So the unload was paying for a token it never needed, and any failure or hang in that path wedges the entry into FAILED_UNLOAD.

This PR sidesteps that by disconnecting the cached client directly (via the new client property), so unload is now token-free and network-free apart from the socket close itself - no library change needed.

As for why we disconnect at all: the realtime websocket and its watchdog task would otherwise keep running after unload, and a reload builds a fresh client, so the old connection would leak. The asyncio.timeout bound is there because a half-dead websocket can hang in wait_closed() — that's the deadlock behind the FAILED_UNLOAD reports in #168480/#166228. Bounding the close on the library side too is proposed separately in Danielhiversen/pyTibber#435.

@home-assistant
home-assistant Bot marked this pull request as draft July 16, 2026 13:35
@honzup
honzup requested a review from joostlek July 16, 2026 15:25
@honzup
honzup marked this pull request as ready for review July 23, 2026 06:18

@joostlek joostlek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. Hmm, I think it's now weird to have 1 method to get the client, and one property to get the client. Given that the tibber client already got a reference to the refresh access token method, I would assume it would keep it up to date. However, I think we should simplify the way we fetch the client, so in the future we don't have places where we get an unauthenticated client where we want an authenticated one, or that we fetch a token when we don't want to

@home-assistant
home-assistant Bot marked this pull request as draft July 23, 2026 10:03
Copilot AI review requested due to automatic review settings July 23, 2026 12:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@honzup

honzup commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Good point on the two accessors. I've collapsed it: there's now a single async_get_client() (always authenticated), the client property is gone, and disconnecting on unload/shutdown is an async_disconnect() action on the runtime data rather than
a second way to fetch a client – so nothing hands back an unauthenticated client.

Worth flagging the constraint: in pyTibber 0.37.6 the refresh_access_token callback is only wired to the realtime websocket reconnect (realtime.py), not to the GraphQL/Data-API REST calls – those send the stored _access_token with no refresh-on-401. So the per-fetch re-auth in async_get_client() is doing real work: it's what keeps the polling coordinators authenticated after the OAuth token rotates. I've kept it and added a test that rotates the token to lock that in.

A true create-once-never-reauth client would need pyTibber itself to refresh its REST token via the callback – that's a separate library change (tracked in Danielhiversen/pyTibber#435) and out of scope here. This PR stands on its own with the
current library.

@honzup
honzup requested a review from joostlek July 23, 2026 12:58
@honzup
honzup marked this pull request as ready for review July 23, 2026 13:00

@joostlek joostlek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the direction this is moving in, small comment on the tests

Comment thread tests/components/tibber/test_init.py Outdated
Comment on lines +108 to +137
@pytest.mark.usefixtures("recorder_mock")
async def test_async_get_client_propagates_rotated_token(hass: HomeAssistant) -> None:
"""Ensure a rotated OAuth token is pushed to the cached client on next fetch."""
session = MagicMock()
session.async_ensure_token_valid = AsyncMock()
session.token = {CONF_ACCESS_TOKEN: "token-1"}

runtime = TibberRuntimeData(
session=session,
)

with patch("homeassistant.components.tibber.tibber.Tibber") as mock_client_cls:
mock_client = MagicMock()
mock_client.set_access_token = AsyncMock()
mock_client_cls.return_value = mock_client

await runtime.async_get_client(hass)
mock_client_cls.assert_called_once_with(
access_token="token-1",
websession=ANY,
time_zone=ANY,
ssl=ANY,
refresh_access_token=ANY,
)

session.token = {CONF_ACCESS_TOKEN: "token-2"}
await runtime.async_get_client(hass)

mock_client_cls.assert_called_once()
mock_client.set_access_token.assert_awaited_once_with("token-2")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not touch the runtime data directly, instead we should do that via the integration

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point - those tests asserted on TibberRuntimeData internals rather than anything reachable from outside the integration. All three now go through the config entry.

The rotation test updates the stored OAuth token with async_update_entry, then triggers a client fetch through the notify entity. That last bit looks arbitrary, so the obvious trigger would be a coordinator tick, but the mocked client reports no homes, so nothing subscribes to the Data API coordinator and it never schedules a periodic refresh – the tick fires and nothing happens. notify.send_message is the shortest public path that reaches async_get_client, and test_notify.py already drives it that way.

The missing-token case now asserts the entry ends in SETUP_ERROR with a reauth flow started, rather than catching ConfigEntryAuthFailed from the helper.

I've converted the two neighbouring tests you didn't flag as well - same pattern, and they'd only have come back next round.

One fixture change in conftest.py: the patched tibber.Tibber class is now exposed as tibber_client_cls so a test can assert on construction, while tibber_mock still yields the client instance. No other test file needed changing.

@home-assistant
home-assistant Bot marked this pull request as draft July 26, 2026 18:43
Address joostlek's review on home-assistant#176595: the runtime data tests reached
past the integration and asserted on TibberRuntimeData internals.
Drive them through config entry setup instead, so they exercise the
same surface a user does.

The token rotation test now updates the stored OAuth token on the
config entry and triggers a client fetch through the notify entity,
rather than constructing a runtime object and calling async_get_client
directly. The missing token test asserts the entry ends in SETUP_ERROR
with a reauth flow, which is the observable outcome, instead of
catching ConfigEntryAuthFailed from the helper.

A coordinator tick cannot drive these tests: the mocked client reports
no homes, so nothing subscribes to the Data API coordinator and it
never schedules a periodic refresh.

Split the patched Tibber class out of the tibber_mock fixture as
tibber_client_cls so tests can assert on construction. tibber_mock
still yields the client instance, so other tests are unaffected.
Copilot AI review requested due to automatic review settings July 27, 2026 07:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment on lines +88 to +89
async with asyncio.timeout(DISCONNECT_TIMEOUT):
await self._client.rt_disconnect()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, and worth spelling out. rt_disconnect() reaches _reset_connection(), which takes LOCK_CONNECT before _reset_connection_locked() cancels the watchdog. reconnect() holds that same lock, and gets to it through an unbounded token refresh. So if the watchdog is mid-reconnect when the entry unloads, the timeout cancels the lock acquisition rather than the disconnect, and the watchdog survives.

It's a deliberate trade rather than a fault this PR introduces. Before it, the unload awaited rt_disconnect() unbounded: the watchdog did always stop eventually, but a half-dead socket could block the unload indefinitely and leave the entry in FAILED_UNLOAD, which needs a Home Assistant restart to clear. This swaps a state the user can't recover from for a task that can leak in a narrow window, and I'd rather leak the task.

The force-stop path you describe belongs in pyTibber rather than here - the integration can't cancel the library's watchdog without reaching into private attributes. Part of it is already open as Danielhiversen/pyTibber#435, which makes the connect lock per-instance and bounds both wait_closed() and close_async(). That narrows the window without closing it: there's still no cancellation path that runs before the lock is taken, and the token refresh inside reconnect() is unbounded.

@honzup
honzup marked this pull request as ready for review July 27, 2026 07:49
@home-assistant
home-assistant Bot requested a review from joostlek July 27, 2026 07:49
@joostlek
joostlek merged commit 822c218 into home-assistant:dev Jul 27, 2026
33 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

bugfix cla-signed has-tests integration: tibber Quality Scale: No score small-pr PRs with less than 30 lines. Top 200 Integration is ranked within the top 200 by usage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants