Skip to content

Raise AuthFailedError on expired token during WebSocket reconnect - #818

Open
adam8833 wants to merge 2 commits into
IceBotYT:mainfrom
adam8833:fix/ws-auth-token-expiry-reconnect-loop
Open

Raise AuthFailedError on expired token during WebSocket reconnect#818
adam8833 wants to merge 2 commits into
IceBotYT:mainfrom
adam8833:fix/ws-auth-token-expiry-reconnect-loop

Conversation

@adam8833

@adam8833 adam8833 commented Jul 1, 2026

Copy link
Copy Markdown

Problem

Home Assistant users report the Nice G.O. integration silently stopping updates after roughly 1–7 days, only recovering after a full config entry reload or HA restart: home-assistant/core#126370

Debug logs from that issue show the WebSocket connection dying with WebSocket connection closed, retrying, and then receiving:

Received message: {'payload': {'errors': [{'errorType': 'UnauthorizedException', 'message': 'Token has expired.', 'errorCode': 401}]}, 'type': 'connection_error'}

Root cause

WebSocketClient.init() only recognizes connection_ack. Any other message type — including connection_error sent by AppSync when the ID token has expired — falls into the generic branch and raises a plain WebSocketError. NiceGOApi.connect()'s @retry decorator retries on WebSocketError, but nothing refreshes id_token in between retries, so it retries forever with the same expired token. The _check_response_errors helper already distinguishes UnauthorizedException this way for the HTTP GraphQL calls (get_all_barriers, open_barrier, etc.), but the WebSocket handshake path never got the same treatment.

Fix

init() now inspects connection_error payloads for UnauthorizedException and raises AuthFailedError instead of WebSocketError. AuthFailedError isn't in connect()'s retry list, so it propagates immediately instead of being silently retried — letting the caller (e.g. Home Assistant's coordinator) refresh the token and reconnect, rather than hammering AppSync with a dead token indefinitely.

Testing

  • Added test_ws_init_token_expired and test_ws_init_connection_error_other to tests/test_ws_client.py.
  • poetry run pytest — 105 passed, 100% coverage.
  • poetry run ruff check / ruff format --check / mypy — clean.

Follow-up

Home Assistant's nice_go coordinator (client_listen) would need a small change once this ships, to catch AuthFailedError there and call authenticate_refresh before retrying connect() — happy to help with that PR once this is released.

Summary by Sourcery

Handle expired or invalid ID tokens during WebSocket initialization by surfacing an authentication failure instead of endlessly retrying.

Bug Fixes:

  • Stop infinite WebSocket reconnect loops when the server reports an expired ID token by raising AuthFailedError instead of WebSocketError.
  • Ensure non-authentication connection errors during WebSocket initialization still surface as generic WebSocketError.

Enhancements:

  • Introduce a shared helper to locate UnauthorizedException errors in GraphQL-style error lists and reuse it for both HTTP and WebSocket paths.
  • Align HTTP GraphQL error handling to use the shared unauthorized error helper while preserving existing ApiError behavior for other errors.

Tests:

  • Add WebSocket client tests covering connection_error messages, including expired token, other error types, empty errors list, and missing payload.

WebSocketClient.init() treated a connection_error reply (sent by
AppSync when the ID token has expired) the same as any other
unexpected message, raising a generic WebSocketError. NiceGOApi.connect
retries on WebSocketError with the same (now-expired) id_token,
producing an infinite retry loop that never re-authenticates. This is
the root cause of home-assistant/core#126370, where the integration
silently stops working until the config entry is reloaded.

Now init() inspects connection_error payloads for an
UnauthorizedException and raises AuthFailedError instead, matching the
handling already used for HTTP GraphQL calls. AuthFailedError isn't in
connect()'s retry list, so it propagates immediately, letting the
caller refresh the token instead of retrying with a dead one.
@sourcery-ai

sourcery-ai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Reviewer's Guide

Handle WebSocket connection_error messages indicating expired/invalid ID tokens as authentication failures, reuse a shared UnauthorizedException helper, and add tests to validate the new behavior.

Sequence diagram for WebSocket initialization handling expired tokens

sequenceDiagram
    actor HomeAssistantCoordinator
    participant NiceGOApi as NiceGOApi
    participant WebSocketClient as WebSocketClient
    participant AppSync as AppSync

    HomeAssistantCoordinator->>NiceGOApi: connect
    NiceGOApi->>WebSocketClient: init
    WebSocketClient->>AppSync: WebSocket handshake
    AppSync-->>WebSocketClient: connection_error
    WebSocketClient->>WebSocketClient: find_unauthorized_error(errors)
    alt UnauthorizedException found
        WebSocketClient-->>NiceGOApi: AuthFailedError
        NiceGOApi-->>HomeAssistantCoordinator: AuthFailedError
    else other connection_error
        WebSocketClient-->>NiceGOApi: WebSocketError
        NiceGOApi->>NiceGOApi: retry on WebSocketError
    end
Loading

Flow diagram for shared UnauthorizedException handling

flowchart TD
    A[GraphQL or WebSocket response with errors] --> B[find_unauthorized_error]
    B -->|UnauthorizedException found| C[raise AuthFailedError]
    B -->|No UnauthorizedException| D{Caller}
    D -->|HTTP GraphQL| E[raise ApiError]
    D -->|WebSocket init non_ack| F[raise WebSocketError]
Loading

File-Level Changes

Change Details Files
Treat WebSocket initialization failures caused by expired/invalid ID tokens as AuthFailedError instead of generic WebSocketError.
  • Import AuthFailedError in the WebSocket client and tests.
  • Extend WebSocketClient.init to detect connection_error messages, extract errors from the payload, and raise AuthFailedError when an UnauthorizedException is present.
  • Update WebSocketClient.init docstring to document the new AuthFailedError behavior.
src/nice_go/_ws_client.py
tests/test_ws_client.py
Introduce a shared helper to locate UnauthorizedException entries in GraphQL-style error lists and reuse it in both HTTP and WebSocket paths.
  • Add find_unauthorized_error utility that scans error dictionaries for errorType == 'UnauthorizedException'.
  • Use find_unauthorized_error in WebSocketClient.init to decide between AuthFailedError and WebSocketError on connection_error.
  • Refactor NiceGOApi._check_response_errors to use find_unauthorized_error instead of assuming the first error is UnauthorizedException, and fall back to ApiError on the first error when no auth issue is found.
src/nice_go/_util.py
src/nice_go/_ws_client.py
src/nice_go/nice_go_api.py
Expand test coverage for WebSocket initialization error handling around connection_error payload variations.
  • Add tests for token-expired connection_error, non-auth connection_error, empty errors list, missing payload, and UnauthorizedException not being the first error.
  • Ensure each new test configures the mocked ws.receive to return an appropriate JSON payload and asserts the raised exception type and message.
  • Adjust existing imports in tests to include AuthFailedError.
tests/test_ws_client.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In init(), you only inspect the first element of errors; consider iterating over all returned errors to catch an UnauthorizedException even if it isn't the first.
  • The WebSocket error-handling logic in init() is now similar to _check_response_errors; consider refactoring to share a common helper so the Unauthorized/other error mapping stays consistent between HTTP and WebSocket paths.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `init()`, you only inspect the first element of `errors`; consider iterating over all returned errors to catch an `UnauthorizedException` even if it isn't the first.
- The WebSocket error-handling logic in `init()` is now similar to `_check_response_errors`; consider refactoring to share a common helper so the Unauthorized/other error mapping stays consistent between HTTP and WebSocket paths.

## Individual Comments

### Comment 1
<location path="tests/test_ws_client.py" line_range="77-58" />
<code_context>
+        await mock_ws_client.init()
+
+
+async def test_ws_init_connection_error_other(mock_ws_client: WebSocketClient) -> None:
+    assert mock_ws_client.ws is not None
+    assert isinstance(mock_ws_client.ws, AsyncMock)
+    mock_ws_client.ws.receive = AsyncMock()
+    mock_ws_client.ws.receive.return_value = MagicMock(
+        data=json.dumps(
+            {
</code_context>
<issue_to_address>
**suggestion (testing):** Consider tests for other `connection_error` shapes (empty errors list, missing payload, or UnauthorizedException not in the first position)

Currently we only assert behavior when `UnauthorizedException` is the first error and when a different `errorType` is present. Please also add tests for:
- `connection_error` with an empty `errors` list
- `connection_error` with no `payload` key
- `connection_error` where `UnauthorizedException` is not the first element in `errors`
so the expected fallback behavior (e.g., using `WebSocketError` unless the first error is `UnauthorizedException`) is clearly defined and enforced.

Suggested implementation:

```python
    with pytest.raises(AuthFailedError, match="Token has expired."):
        await mock_ws_client.init()


async def test_ws_init_connection_error_empty_errors(
    mock_ws_client: WebSocketClient,
) -> None:
    assert mock_ws_client.ws is not None
    assert isinstance(mock_ws_client.ws, AsyncMock)

    mock_ws_client.ws.receive = AsyncMock()
    mock_ws_client.ws.receive.return_value = MagicMock(
        data=json.dumps(
            {
                "type": "connection_error",
                "payload": {
                    "errors": [],
                },
            }
        ),
    )

    with pytest.raises(WebSocketError):
        await mock_ws_client.init()


async def test_ws_init_connection_error_missing_payload(
    mock_ws_client: WebSocketClient,
) -> None:
    assert mock_ws_client.ws is not None
    assert isinstance(mock_ws_client.ws, AsyncMock)

    mock_ws_client.ws.receive = AsyncMock()
    mock_ws_client.ws.receive.return_value = MagicMock(
        data=json.dumps(
            {
                "type": "connection_error",
            }
        ),
    )

    with pytest.raises(WebSocketError):
        await mock_ws_client.init()


async def test_ws_init_connection_error_unauthorized_not_first(
    mock_ws_client: WebSocketClient,
) -> None:
    assert mock_ws_client.ws is not None
    assert isinstance(mock_ws_client.ws, AsyncMock)

    mock_ws_client.ws.receive = AsyncMock()
    mock_ws_client.ws.receive.return_value = MagicMock(
        data=json.dumps(
            {
                "type": "connection_error",
                "payload": {
                    "errors": [
                        {
                            "errorType": "SomeOtherError",
                            "message": "Something else failed.",
                        },
                        {
                            "errorType": "UnauthorizedException",
                            "message": "Token has expired.",
                        },
                    ],
                },
            }
        ),
    )

    with pytest.raises(WebSocketError):
        await mock_ws_client.init()

```

1. Ensure `WebSocketError` is imported at the top of `tests/test_ws_client.py` from the same module as in the existing tests for non-`UnauthorizedException` `connection_error` handling (e.g., `from <your_package>.ws_client import WebSocketError` or equivalent).
2. If your existing tests use a different error type or message pattern for the generic case, adjust the `pytest.raises(WebSocketError)` expectations (e.g., add `match="..."`) to stay consistent with the rest of the test suite.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tests/test_ws_client.py
Sourcery pointed out init() only checked errors[0], so an
UnauthorizedException later in the list would be missed. Extracted
find_unauthorized_error() to _util.py and use it from both the
WebSocket init() path and _check_response_errors (the existing HTTP
GraphQL error path), so both scan the full errors list the same way.

Non-auth connection_error messages still fall through to the existing
generic WebSocketError in init(), preserving current retry behavior
for cases unrelated to token expiry.

Added tests for empty errors, missing payload, and an
UnauthorizedException that isn't first in the list.
@adam8833

adam8833 commented Jul 1, 2026

Copy link
Copy Markdown
Author

Thanks for the review! Pushed a follow-up commit:

  • Extracted find_unauthorized_error() into _util.py and now scan the entire errors list (not just errors[0]) in both init() and _check_response_errors, so an UnauthorizedException later in the list is no longer missed.
  • Reused that helper from _check_response_errors (the existing HTTP GraphQL path) as well, so the two paths agree on how an auth error is identified.
  • I kept the raising logic separate rather than fully merging into one function: init() still falls back to a generic WebSocketError for non-auth connection_error messages (preserving the existing retry behavior for causes unrelated to token expiry), whereas the HTTP path raises ApiError. Fully unifying would have changed retry semantics for an unrelated class of errors, which felt out of scope for this fix.
  • Added tests for empty errors, missing payload, and an UnauthorizedException that isn't the first element.

105 → 108 tests, still 100% coverage, clean ruff/mypy.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (76089a4) to head (2a23770).
⚠️ Report is 9 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #818   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           15        15           
  Lines         1194      1237   +43     
=========================================
+ Hits          1194      1237   +43     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@IceBotYT

IceBotYT commented Jul 9, 2026

Copy link
Copy Markdown
Owner

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In WebSocketClient.init, consider using data.get("type") instead of data["type"] to avoid a KeyError on malformed messages and preserve the generic WebSocketError behavior.
  • The string literal "UnauthorizedException" is now used in multiple places; consider centralizing it as a constant to avoid typos and ease future changes if the backend error type name changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `WebSocketClient.init`, consider using `data.get("type")` instead of `data["type"]` to avoid a `KeyError` on malformed messages and preserve the generic `WebSocketError` behavior.
- The string literal `"UnauthorizedException"` is now used in multiple places; consider centralizing it as a constant to avoid typos and ease future changes if the backend error type name changes.

## Individual Comments

### Comment 1
<location path="tests/test_ws_client.py" line_range="77-86" />
<code_context>
+        await mock_ws_client.init()
+
+
+async def test_ws_init_connection_error_other(mock_ws_client: WebSocketClient) -> None:
+    assert mock_ws_client.ws is not None
+    assert isinstance(mock_ws_client.ws, AsyncMock)
+    mock_ws_client.ws.receive = AsyncMock()
+    mock_ws_client.ws.receive.return_value = MagicMock(
+        data=json.dumps(
+            {
+                "type": "connection_error",
+                "payload": {"errors": [{"errorType": "SomeOtherError"}]},
+            },
+        ),
+    )
+    with pytest.raises(WebSocketError):
+        await mock_ws_client.init()
+
</code_context>
<issue_to_address>
**suggestion:** Consider factoring out the repeated WebSocket mock setup into a small helper to improve test maintainability.

The tests all repeat the same setup pattern: checking `ws` is not `None`, asserting `AsyncMock`, overriding `ws.receive`, and configuring `return_value.data`. Moving this into a helper or fixture (e.g., `set_ws_receive_data(mock_ws_client, payload: dict[str, Any])`) would reduce duplication and make it easier to extend `connection_error` cases. This is an optional refactor but would improve readability and maintainability.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tests/test_ws_client.py
Comment on lines +77 to +86
async def test_ws_init_connection_error_other(mock_ws_client: WebSocketClient) -> None:
assert mock_ws_client.ws is not None
assert isinstance(mock_ws_client.ws, AsyncMock)
mock_ws_client.ws.receive = AsyncMock()
mock_ws_client.ws.receive.return_value = MagicMock(
data=json.dumps(
{
"type": "connection_error",
"payload": {"errors": [{"errorType": "SomeOtherError"}]},
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Consider factoring out the repeated WebSocket mock setup into a small helper to improve test maintainability.

The tests all repeat the same setup pattern: checking ws is not None, asserting AsyncMock, overriding ws.receive, and configuring return_value.data. Moving this into a helper or fixture (e.g., set_ws_receive_data(mock_ws_client, payload: dict[str, Any])) would reduce duplication and make it easier to extend connection_error cases. This is an optional refactor but would improve readability and maintainability.

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.

3 participants