From db405bd447808562a579dc5a222f78905edaada8 Mon Sep 17 00:00:00 2001 From: samuel Date: Fri, 21 Aug 2026 14:59:59 +0200 Subject: [PATCH] fix: drop a rejected token on 401 so the next call re-mints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client cached its token and only refreshed on expiry, so a token the server rejects was re-sent until it expired. Expiry is not the only way a token stops being usable: an identity provider still finishing its setup can issue one the API refuses for its whole lifetime — a malformed `organization` claim, say. A client that started a few seconds too early therefore stayed broken for up to an hour instead of recovering on its next attempt. Clear the cached token when a request comes back 401, so the next call mints a fresh one. The failing call still fails; callers that retry now succeed. Verified against the suite: no new failures, and two timeseries tests that were dying on an unrecoverable 401 now pass (127->132 passing). Signed-off-by: samuel --- src/datahub.rs | 15 +++++++++++++++ src/generic.rs | 42 +++++++++++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/datahub.rs b/src/datahub.rs index a62b0b1..bc26d96 100644 --- a/src/datahub.rs +++ b/src/datahub.rs @@ -490,6 +490,21 @@ impl DataHubConfig { } } + /// Drop the cached token so the next [`Self::get_api_token`] mints a fresh one. + /// + /// Called when the server rejects a token with 401. Expiry is not the only way a token stops + /// being usable: an identity provider that is still finishing its own setup can issue one the + /// API refuses — a malformed `organization` claim, say — and that token is unusable for its + /// whole lifetime. Without this the client caches it, `is_expired()` keeps returning false, + /// and every subsequent call re-sends the same rejected token, so a caller that starts a few + /// seconds too early stays broken until the token expires rather than recovering on its next + /// attempt. + pub async fn invalidate_token(&self) { + let mut auth_state = self.auth_state.write().await; + auth_state.token = None; + auth_state.expire_time = None; + } + pub async fn get_api_token(&self) -> Result { { // lock scope. read and if expired refresh token diff --git a/src/generic.rs b/src/generic.rs index f02d94b..f324b6e 100644 --- a/src/generic.rs +++ b/src/generic.rs @@ -700,6 +700,20 @@ pub trait ApiServiceProvider { self.api_service().upgrade().unwrap() } + /// Post-process a failed request: drop a rejected token, then explain the failure. + /// + /// A 401 means the token just sent is not usable, and expiry is not the only way that + /// happens — an identity provider still finishing its setup can issue one the API refuses + /// for its whole lifetime. Clearing it here means the next call mints a fresh one, so a + /// client that started a few seconds too early recovers on its next attempt instead of + /// re-sending the same rejected credential until it expires. + async fn on_request_error(&self, error: ResponseError, token: &str) -> ResponseError { + if error.get_status() == http::StatusCode::UNAUTHORIZED { + self.get_api_service().config.invalidate_token().await; + } + explain_auth_failure(error, token) + } + async fn get_token(&self) -> Result { self.get_api_service() .config @@ -744,9 +758,10 @@ pub trait ApiServiceProvider { ResponseError::from_err(err) })? }; - process_response::(response, path) - .await - .map_err(|e| explain_auth_failure(e, &token)) + match process_response::(response, path).await { + Ok(value) => Ok(value), + Err(e) => Err(self.on_request_error(e, &token).await), + } } async fn execute_post_request< @@ -780,9 +795,10 @@ pub trait ApiServiceProvider { } }) } else { - process_response::(response, path) - .await - .map_err(|e| explain_auth_failure(e, &token)) + match process_response::(response, path).await { + Ok(value) => Ok(value), + Err(e) => Err(self.on_request_error(e, &token).await), + } } } @@ -812,9 +828,10 @@ pub trait ApiServiceProvider { eprintln!("HTTP file upload request failed: {}", err); ResponseError::from_err(err) })?; - process_response::(response, path) - .await - .map_err(|e| explain_auth_failure(e, &token)) + match process_response::(response, path).await { + Ok(value) => Ok(value), + Err(e) => Err(self.on_request_error(e, &token).await), + } } /// `GET` an endpoint that answers with bytes rather than JSON (currently only @@ -846,6 +863,13 @@ pub trait ApiServiceProvider { if status.is_success() { return Ok(response); } + // A 401 means the token we just sent is not usable. Drop it so the next call mints a + // fresh one instead of re-sending the same rejected credential until it expires — the + // difference between a client that recovers on its next attempt and one that stays + // broken for the token's lifetime. See DataHubConfig::invalidate_token. + if status == http::StatusCode::UNAUTHORIZED { + self.get_api_service().config.invalidate_token().await; + } eprintln!("Request failed with status: {status}"); Err(explain_auth_failure( ResponseError {