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.
|
/// 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))]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue