Skip to content

Commit e168b30

Browse files
committed
fix(sdk): preserve transient/terminal distinction in refresh errors
The refresh single-flight collapsed both `RefreshError::Transient` and `RefreshError::Terminal` into a stringified `SdkError::Auth`, so consumers (CLI, TUI, future language bindings) had no machine-readable way to tell a retryable IdP blip from a dead session that needs re-authentication. Carry the `RefreshError` through the shared outcome (kept `Clone` for `Shared`) instead of its rendered text, and map it at the await site to a new `retryable` flag on `SdkError::Auth`. Add `SdkError::auth_retryable` and a `SdkError::retryable()` accessor; transient refresh failures report `true`, every other error `false`. Add classification tests. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent de73344 commit e168b30

2 files changed

Lines changed: 86 additions & 11 deletions

File tree

‎crates/openshell-sdk/src/error.rs‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,18 @@ pub enum SdkError {
4545

4646
/// Auth-related failure: OIDC discovery / refresh, token format invalid
4747
/// for header injection.
48+
///
49+
/// `retryable` mirrors the refresh contract's transient/terminal split: a
50+
/// transient failure (network or `IdP` blip) may succeed on retry, while a
51+
/// terminal one (session revoked, refresh token expired) requires
52+
/// re-authentication. All non-refresh auth failures are non-retryable.
4853
#[error("auth error: {message}")]
4954
#[diagnostic(code(openshell::sdk::auth))]
5055
Auth {
5156
/// Error message.
5257
message: String,
58+
/// Whether retrying the same operation may succeed.
59+
retryable: bool,
5360
},
5461

5562
/// Local IO failure (file read, listener bind, socket).
@@ -110,10 +117,19 @@ impl SdkError {
110117
}
111118
}
112119

113-
/// Create an `Auth` error.
120+
/// Create a non-retryable `Auth` error.
114121
pub fn auth(message: impl Into<String>) -> Self {
115122
Self::Auth {
116123
message: message.into(),
124+
retryable: false,
125+
}
126+
}
127+
128+
/// Create an `Auth` error, tagging whether retrying may succeed.
129+
pub fn auth_retryable(message: impl Into<String>, retryable: bool) -> Self {
130+
Self::Auth {
131+
message: message.into(),
132+
retryable,
117133
}
118134
}
119135

@@ -123,6 +139,22 @@ impl SdkError {
123139
/// `not_found`, `already_exists`, `rpc`. Phase 3 (napi binding) will
124140
/// surface this as the JS error's `code` field for discriminated-union
125141
/// ergonomics.
142+
/// Whether retrying the same operation may succeed.
143+
///
144+
/// Only a transient [`SdkError::Auth`] failure reports `true`; every other
145+
/// error is non-retryable and the caller should surface it rather than
146+
/// loop. Consumers use this to distinguish a retryable refresh blip from a
147+
/// dead session that needs re-authentication.
148+
pub const fn retryable(&self) -> bool {
149+
matches!(
150+
self,
151+
Self::Auth {
152+
retryable: true,
153+
..
154+
}
155+
)
156+
}
157+
126158
pub const fn code(&self) -> &'static str {
127159
match self {
128160
Self::InvalidConfig { .. } => "invalid_config",

‎crates/openshell-sdk/src/refresh.rs‎

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use tokio::sync::{Mutex, RwLock};
3535
/// Domain-specific, deliberately not coupled to `tonic`, `napi`, or any
3636
/// FFI-facing error type. The SDK maps these into [`SdkError::Auth`] before
3737
/// surfacing to callers.
38-
#[derive(Debug)]
38+
#[derive(Debug, Clone)]
3939
#[non_exhaustive]
4040
pub enum RefreshError {
4141
/// Refresh failed but a retry might succeed (network blip, transient
@@ -60,7 +60,8 @@ impl std::error::Error for RefreshError {}
6060

6161
impl From<RefreshError> for SdkError {
6262
fn from(value: RefreshError) -> Self {
63-
Self::auth(value.to_string())
63+
let retryable = matches!(value, RefreshError::Transient(_));
64+
Self::auth_retryable(value.to_string(), retryable)
6465
}
6566
}
6667

@@ -127,9 +128,10 @@ struct TokenState {
127128
}
128129

129130
/// Cloneable outcome of a single refresh attempt, shared across all waiters
130-
/// that joined it. `Err` carries the rendered message (so the type stays
131-
/// `Clone` for [`Shared`]); the SDK remaps it to [`SdkError::Auth`].
132-
type RefreshOutcome = std::result::Result<String, String>;
131+
/// that joined it. `Err` carries the [`RefreshError`] itself (kept `Clone`
132+
/// for [`Shared`]) so its Transient/Terminal kind survives to the await site,
133+
/// where it maps to [`SdkError::Auth`] with `retryable` set accordingly.
134+
type RefreshOutcome = std::result::Result<String, RefreshError>;
133135
type RefreshFuture = Shared<Pin<Box<dyn Future<Output = RefreshOutcome> + Send>>>;
134136

135137
/// In-flight refresh attempt, if any. `epoch` lets the leader that started an
@@ -257,10 +259,10 @@ impl TokenSource {
257259
Ok(state.token.clone())
258260
}
259261
}
260-
// Store the bare refresh-error text; the single
261-
// `SdkError::auth` wrap happens where the shared
262-
// result is awaited below, so it isn't double-wrapped.
263-
Err(err) => Err(err.to_string()),
262+
// Carry the `RefreshError` itself so its
263+
// Transient/Terminal kind survives; the single
264+
// `SdkError` wrap happens at the await site below.
265+
Err(err) => Err(err),
264266
};
265267
// Clear this attempt's slot so the next caller starts a
266268
// fresh refresh. Epoch-guarded so a newer attempt is never
@@ -283,7 +285,7 @@ impl TokenSource {
283285
}
284286
};
285287

286-
shared.await.map_err(SdkError::auth)
288+
shared.await.map_err(SdkError::from)
287289
}
288290

289291
/// Replace the current token without invoking the refresher.
@@ -635,4 +637,45 @@ mod tests {
635637
assert!(outcome.is_err(), "every waiter should observe the failure");
636638
}
637639
}
640+
641+
#[test]
642+
fn refresh_error_maps_to_classified_auth() {
643+
// The Transient/Terminal split must survive conversion into `SdkError`
644+
// so consumers can tell a retryable blip from a dead session, and the
645+
// message must be wrapped exactly once.
646+
let transient = SdkError::from(RefreshError::Transient("blip".into()));
647+
assert!(transient.retryable(), "transient refresh is retryable");
648+
assert_eq!(
649+
transient.to_string(),
650+
"auth error: transient refresh error: blip",
651+
"single wrap, no doubled `auth error:` prefix"
652+
);
653+
654+
let terminal = SdkError::from(RefreshError::Terminal("revoked".into()));
655+
assert!(!terminal.retryable(), "terminal refresh needs re-auth");
656+
assert_eq!(terminal.code(), "auth");
657+
}
658+
659+
#[tokio::test]
660+
async fn terminal_refresh_surfaces_non_retryable() {
661+
// End-to-end: a Terminal error from the refresher reaches the caller as
662+
// a non-retryable auth error through the shared single-flight path.
663+
struct TerminalRefresher;
664+
#[async_trait::async_trait]
665+
impl Refresh for TerminalRefresher {
666+
async fn refresh(&self) -> std::result::Result<RefreshedToken, RefreshError> {
667+
Err(RefreshError::Terminal("session revoked".into()))
668+
}
669+
}
670+
let source = TokenSource::new(
671+
RefreshedToken::new("initial").with_expires_at(0),
672+
Arc::new(TerminalRefresher),
673+
);
674+
let err = source.refresh_now().await.expect_err("refresh must fail");
675+
assert!(!err.retryable(), "terminal failure is not retryable");
676+
assert!(
677+
err.to_string()
678+
.contains("terminal refresh error: session revoked")
679+
);
680+
}
638681
}

0 commit comments

Comments
 (0)