Skip to content

Fix/auth refresh robustness 1 - #410

Merged
tehkillerbee merged 12 commits into
mainfrom
fix/auth-refresh-robustness-1
Oct 13, 2026
Merged

tehkillerbee merged 12 commits into
mainfrom
fix/auth-refresh-robustness-1

Conversation

@tehkillerbee

Copy link
Copy Markdown
Collaborator
  • Bugfix: Keep the rotated refresh token returned by TIDAL in token_refresh, previously users were logged out after a few days - tehkillerbee_
  • Bugfix: Serialize concurrent access token refreshes, so parallel requests no longer refresh with an already invalidated refresh token - tehkillerbee_
  • Bugfix: Refresh the access token shortly before it expires, instead of relying on the wording of the expired-token error - tehkillerbee_
  • Bugfix: Retry a request with an expired access token only once, instead of recursing without limit - tehkillerbee_
  • Bugfix: login_oauth works after client_enable_hires has been called - tehkillerbee_
  • Bugfix: Sessions created without a config no longer share one Config object - tehkillerbee_
  • save_session_to_file and load_session_from_file now persist expiry_time - tehkillerbee_
  • Added AbuseDetected, raised when TIDAL answers with an anti-abuse HTTP 403 - tehkillerbee_
  • Added Config(backoff=...), enabled by default: stop sending requests after a rate limit or abuse response and raise TidalBackoffError until it has passed. Pass backoff=False to disable - tehkillerbee_
  • Bugfix: Non-JSON error responses and Retry-After given as a date no longer break error handling - tehkillerbee_

TIDAL rotates refresh tokens: a refresh response can carry a new
refresh_token and the previous one is invalidated server-side.
token_refresh() updated the access token and expiry but never read the
refresh_token field, so a session saved afterwards kept re-persisting
the old token. Once TIDAL killed it, the next refresh failed and the
user had to log in again.
When several threads (e.g. the paginated workers) hit an expired access
token at the same time, each one POSTed the same refresh token. TIDAL
rotates refresh tokens, so every POST after the first used a token that
had just been invalidated, and the resulting broken lineage could end up
persisted.

Refreshes now go through a lock. A thread that gets the lock after
another thread has already refreshed (the session's access token differs
from the one its request was sent with) skips the refresh and just
retries its request with the new token.
The access token was only refreshed after a 401 whose userMessage starts
with exactly "The token has expired.". TIDAL has changed that wording
before; when it doesn't match, the 401 propagates to the caller (seen
with stream/download requests).

When the expiry time is known, requests now refresh the token up front
if it has expired or expires within a minute. The message based refresh
stays as a fallback. A failed proactive refresh is logged and does not
fail the request, since the token may still be accepted.
save_session_to_file() left expiry_time commented out, so a session
restored with load_session_from_file() never knew when its access token
expires and could only react to a 401 after the fact. Store it as an ISO
8601 string and restore it on load. Files written by older versions
have no expiry_time and keep loading as before.
client_enable_hires() overwrites config.client_id/client_secret with the
PKCE pair. login_oauth() reads those same fields, but TIDAL rejects the
PKCE client on the device authorization endpoint ("Client is not a
Limited Input Device client"). Loading a PKCE session and then starting
a device login therefore failed.

Remember the previous pair when swapping and use it for the device login
flow. config.client_id keeps its current meaning for existing callers.
Session.__init__ used `config: Config = Config()`, which is evaluated
once at import time. Every Session created without an explicit config
therefore shared the same Config object, including its PKCE
code_verifier and anything mutated later such as client_enable_hires().
Two sessions in one process (e.g. examples/transfer_favorites.py) would
affect each other.

Create a fresh Config per Session instead.
After refreshing the access token, basic_request() retried by calling
itself. If TIDAL still answered "The token has expired." (for example
because of clock skew), the retry refreshed and retried again, without
limit, until the recursion limit was hit.

Send the request through a helper and retry exactly once; a response
that is still an error is returned to the caller.
TIDAL answers suspicious traffic with HTTP 429, or with HTTP 403 and a
body mentioning abuse or a suspension. Requests sent while that is in
effect only prolong it and can end in a permanent ban, but the library
kept sending them and surfaced the 403 as a plain HTTPError.

- AbuseDetected is raised for the 403 anti-abuse response, carrying
  retry_after and the reason.
- Config(backoff=True) makes a Session stop sending requests after a 429
  or an abuse response, for Retry-After seconds (60 s for a 429 and
  30 minutes for abuse when absent). Requests made meanwhile raise
  TidalBackoffError with the remaining time, without reaching TIDAL.
  Session.request.backoff_remaining() exposes it. It is off by default,
  as it changes how long a rate limited session stays unusable.
- Error bodies that are not JSON no longer break the error mapping, and
  a Retry-After given as an HTTP date no longer raises ValueError.
Requests sent while TIDAL is rate limiting or suspecting an account only
prolong it, so holding them back is the safer behavior. Config.backoff
now defaults to True; pass backoff=False to keep sending requests
regardless.

Only inspect the response for 429 and anti-abuse 403 when deciding
whether to back off. This runs for every request now, and parsing
Retry-After on successful responses was unnecessary work that also broke
for mocked responses.
@tehkillerbee tehkillerbee self-assigned this Oct 13, 2026
@tehkillerbee
tehkillerbee merged commit a72f31f into main Oct 13, 2026
1 check failed
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