Survive a permanently dead refresh token while the access token works

A stale config can hold a refresh token issued by a Tidal client that
no longer exists ("Client id ... not found") next to an access token
that is still perfectly valid. The proactive refresh in
ensure_fresh_token treated its own failure as fatal, so every request
died on the unusable refresh token without ever trying the working
access token.

Classify refresh failures: a 4xx from the token endpoint is permanent,
so warn once, stop proactive refreshing, and keep serving with the
current access token; transport errors and 5xx are transient and keep
the retry metadata. The 401-retry in make_request stays as the backstop
for an access token that has actually expired, and a restart heals the
state via the device-login fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AI User 2026-07-20 11:48:06 +02:00
parent 86ce63ae61
commit 9706e3b5d2
1 changed files with 38 additions and 2 deletions

View File

@ -344,6 +344,11 @@ impl Client {
/// Refreshes the access token if it is expired or about to expire. /// Refreshes the access token if it is expired or about to expire.
/// Without this, long-running sessions ended up with an expired token /// Without this, long-running sessions ended up with an expired token
/// and every track fetch failed, silently stopping playback. /// and every track fetch failed, silently stopping playback.
///
/// A failed proactive refresh never fails the request: the current
/// access token may still be valid (stale `expires_after` metadata from
/// an old config, for example), and `make_request`'s 401-retry remains
/// the backstop for a token that is actually dead.
async fn ensure_fresh_token(&self) -> Result<(), ClientError> { async fn ensure_fresh_token(&self) -> Result<(), ClientError> {
let login = self.login_snapshot(); let login = self.login_snapshot();
let Some(expires_after) = login.expires_after else { let Some(expires_after) = login.expires_after else {
@ -354,7 +359,30 @@ impl Client {
return Ok(()); return Ok(());
} }
info!("access token expired or expiring soon"); info!("access token expired or expiring soon");
self.force_refresh_token().await match self.force_refresh_token().await {
Ok(()) => Ok(()),
Err(err @ ClientError::AuthError(_)) => {
// The refresh token is permanently unusable. Stop trying
// proactively so every request doesn't hammer the endpoint;
// a new device login (server restart) is the only cure once
// the access token dies.
warn!(
"stored refresh token was rejected and can never work; \
continuing with the current access token restart the \
server to re-login once it expires: {err}"
);
let mut login = match self.login.write() {
Ok(login) => login,
Err(poisoned) => poisoned.into_inner(),
};
login.expires_after = None;
Ok(())
}
Err(err) => {
warn!("token refresh failed transiently, continuing with the current token: {err}");
Ok(())
}
}
} }
/// Performs an authenticated GET against the hifi API. /// Performs an authenticated GET against the hifi API.
@ -689,12 +717,20 @@ impl Client {
if status.is_success() { if status.is_success() {
let res = req.json::<RefreshResponse>().await?; let res = req.json::<RefreshResponse>().await?;
Ok(res) Ok(res)
} else { } else if status.is_client_error() {
// A 4xx means the endpoint understood us and rejected the token
// (e.g. it was issued by a client that no longer exists). This
// cannot succeed on retry — only a new device login helps.
let body = req.text().await.unwrap_or_default(); let body = req.text().await.unwrap_or_default();
let snippet: String = body.chars().take(300).collect(); let snippet: String = body.chars().take(300).collect();
Err(ClientError::AuthError(format!( Err(ClientError::AuthError(format!(
"token refresh returned {status}: {snippet}" "token refresh returned {status}: {snippet}"
))) )))
} else {
let body = req.text().await.unwrap_or_default();
let snippet: String = body.chars().take(300).collect();
error!("token refresh returned {status}: {snippet}");
Err(ClientError::ApiError(status.as_u16()))
} }
} }
#[instrument(skip(self))] #[instrument(skip(self))]