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:
parent
86ce63ae61
commit
9706e3b5d2
|
|
@ -344,6 +344,11 @@ impl Client {
|
|||
/// Refreshes the access token if it is expired or about to expire.
|
||||
/// Without this, long-running sessions ended up with an expired token
|
||||
/// 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> {
|
||||
let login = self.login_snapshot();
|
||||
let Some(expires_after) = login.expires_after else {
|
||||
|
|
@ -354,7 +359,30 @@ impl Client {
|
|||
return Ok(());
|
||||
}
|
||||
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.
|
||||
|
|
@ -689,12 +717,20 @@ impl Client {
|
|||
if status.is_success() {
|
||||
let res = req.json::<RefreshResponse>().await?;
|
||||
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 snippet: String = body.chars().take(300).collect();
|
||||
Err(ClientError::AuthError(format!(
|
||||
"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))]
|
||||
|
|
|
|||
Loading…
Reference in New Issue