Skip to content

Auth robustness: serialised refresh, every HTTP status mapped, token_update_callback - #190

Open
cmatte wants to merge 4 commits into
erwindouna:mainfrom
cmatte:fix/auth-robustness
Open

cmatte wants to merge 4 commits into
erwindouna:mainfrom
cmatte:fix/auth-robustness

Conversation

@cmatte

@cmatte cmatte commented Nov 7, 2026 •

Copy link
Copy Markdown

Proposed Changes

Stacked on PR A (the test-environment repair), so its first commit is PR A's. Please review the three commits after it; once PR A is merged, this PR shows only those.

  1. Map every HTTP error to a Tado exception; a refused refresh token is an auth error. 429 → new TadoRateLimitError; any 5xx → TadoServerError (defined but never raised so far); any other status → TadoError, never KeyError. All subclass TadoError, so except TadoError keeps working. _refresh_auth() now passes login=True, so a 400 invalid_grant raises TadoAuthenticationError. This changes the exception type for that one case (it was TadoBadRequestError); test_refresh_auth_client_response_error is updated accordingly.
  2. Serialise token refreshes. _refresh_auth() takes an asyncio.Lock and checks the expiry again once it holds it, so concurrent callers reuse the token the first one got. test_parallel_requests_refresh_token_once runs 6 concurrent refreshes against a token endpoint that refuses a used token: without the lock 5 are rejected, with it 1 refresh and 0 rejected.
  3. token_update_callback. Tado(..., token_update_callback=...) is called with every refresh token tado issues (device login, login(), each refresh). A failing callback is logged and never breaks auth. Nothing changes when it isn't passed.

Tests: test_check_request_status_maps_every_status (9 cases), test_parallel_requests_refresh_token_once, test_token_update_callback_gets_every_new_token, test_token_update_callback_error_does_not_break_auth. 95 passed, coverage 96.7%, ruff, ruff format, mypy and pylint (10.00) clean.

The same fixes went into PyTado (all merged, released with 0.19.4): wmalgadey/PyTado#367, wmalgadey/PyTado#368, wmalgadey/PyTado#369.

Related Issues

cmatte and others added 4 commits November 7, 2026 09:33
The test suite has failed on main since the lock moved to newer packages:

- PyJWT 2.15 rejects trailing characters after the signature, and the test
  token loaded from auth_token.txt ends with the file's newline
  ('Invalid crypto padding'). The tests now strip it.
- aioresponses does not support aiohttp 3.14 yet (ClientResponse needs
  stream_writer, pnuckowski/aioresponses#289). A uv constraint keeps the
  development lock on aiohttp 3.13 until it does; the published requirement
  (aiohttp>=3.0.0) is unchanged.

aioresponses was also listed as a runtime dependency although only the tests
use it, so every install pulled a test mocking library. It now sits in the
dev group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an auth error

check_request_status() looked the status up in a dict that only knew 400,
401, 403 and 500, so any other status (429 when the daily quota is used up,
502/503 from the API, 404) raised KeyError from inside the library.

- 429 raises the new TadoRateLimitError
- any 5xx raises TadoServerError (defined but never used so far)
- any other status raises TadoError instead of KeyError

All of them subclass TadoError, so existing 'except TadoError' keeps working.

A 400 from the token endpoint while refreshing (invalid_grant) raised
TadoBadRequestError, so callers could not tell that the user has to log in
again. _refresh_auth() now treats it like the login calls do and raises
TadoAuthenticationError. This changes the exception type for that case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tado refresh tokens are single use. When the access token expired while
several requests ran concurrently (Home Assistant updates several entities
with asyncio.gather), each coroutine reached _refresh_auth(), sent the same
refresh token and all but the first got HTTP 400, which the callers see as a
failed authentication.

The refresh now runs under an asyncio.Lock and the expiry is checked again
once it is held, so waiting coroutines reuse the token the first one got.
test_parallel_requests_refresh_token_once fails without the lock (5 of 6
refreshes rejected) and passes with it (1 refresh, 0 rejected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tado rotates the refresh token on every refresh and the previous one stops
working. A caller that keeps the token (Home Assistant stores it in its config
entry) can only read Tado.refresh_token after its calls return; if a call fails
after the rotation, or the process stops first, it keeps a token tado has
already cancelled and the user has to log in again.

Tado(..., token_update_callback=...) is called with every refresh token tado
issues: the device login, login() and each refresh. A failing callback is
logged and never breaks authentication. Without the argument nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

Auth: parallel refreshes reuse a single-use refresh token, 429/5xx raise KeyError, refused refresh token is not an auth error

1 participant