From aec6f6c393de8f2e8a38af21d966fdf3feb21652 Mon Sep 17 00:00:00 2001 From: sandikodev Date: Sat, 4 Apr 2026 22:53:59 +0700 Subject: [PATCH] fix(auth): surface 'No token' dispatch failure as AuthError::NoToken When a session expires mid-chat, the AWS SDK returns a dispatch failure with 'No token' in the error chain. This was converted to ChatError::Client and displayed as an opaque multi-line error instead of the clear 'Your session has expired. Run q login' message. Add is_no_token_error() to walk the error source chain and detect this case, converting it to ChatError::Auth(AuthError::NoToken) so the existing handler in handle_chat_error displays the actionable message. Fixes #3173 --- crates/chat-cli/src/cli/chat/mod.rs | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 099c0c876..ba0624d3b 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -624,10 +624,73 @@ impl ReasonCode for ChatError { impl From for ChatError { fn from(value: ApiClientError) -> Self { + // A dispatch failure with "No token" in the error chain means the session has + // expired. Surface this as AuthError::NoToken so the handler in handle_chat_error + // can display a clear "run `q login`" message instead of an opaque SDK error. + // See: #3173 + if is_no_token_error(&value) { + return Self::Auth(crate::auth::AuthError::NoToken); + } Self::Client(Box::new(value)) } } +/// Returns true if the error chain contains a "No token" dispatch failure, +/// which indicates the user's session has expired. +fn is_no_token_error(err: &ApiClientError) -> bool { + use std::error::Error; + let mut source: Option<&dyn Error> = Some(err); + while let Some(e) = source { + if e.to_string().contains("No token") { + return true; + } + source = e.source(); + } + false +} + +#[cfg(test)] +mod is_no_token_error_tests { + use crate::api_client::error::ApiClientError; + use crate::auth::AuthError; + + use super::{ChatError, is_no_token_error}; + + #[test] + fn detects_auth_no_token() { + let err = ApiClientError::AuthError(AuthError::NoToken); + assert!(is_no_token_error(&err)); + } + + #[test] + fn ignores_unrelated_errors() { + let err = ApiClientError::DefaultModelNotFound; + assert!(!is_no_token_error(&err)); + } + + #[test] + fn from_api_client_error_converts_no_token_to_auth_error() { + let err = ApiClientError::AuthError(AuthError::NoToken); + let chat_err = ChatError::from(err); + assert!( + matches!(chat_err, ChatError::Auth(AuthError::NoToken)), + "expected ChatError::Auth(NoToken), got: {:?}", + chat_err + ); + } + + #[test] + fn from_api_client_error_keeps_other_errors_as_client() { + let err = ApiClientError::DefaultModelNotFound; + let chat_err = ChatError::from(err); + assert!( + matches!(chat_err, ChatError::Client(_)), + "expected ChatError::Client, got: {:?}", + chat_err + ); + } +} + impl From for ChatError { fn from(value: parser::SendMessageError) -> Self { Self::SendMessage(Box::new(value))