mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-08 09:04:26 +08:00
* feat(sdk): add openshell-sdk crate Additive extraction of the shared async gRPC client core (transport, TLS, OIDC single-flight refresh, edge tunnel, high-level sandbox surface, raw escape hatch) as a new workspace crate. No existing consumers yet; CLI/TUI migration follows in a separate PR. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * refactor(sdk,core): reuse shared JWT exp decoder for refresh deadlines Extract the signature-unverified JWT exp decode out of openshell-core/grpc_client.rs into openshell_core::jwt::parse_exp_secs, and have the openshell-sdk refresh path reuse it to derive a proactive refresh deadline from a bearer JWT when the caller does not advertise expires_at. Addresses review feedback to reuse pre-existing logic rather than reimplement JWT expiry handling per client. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): harden refresh single-flight and redact tokens in Debug Addresses review feedback on the openshell-sdk refresh path. - Make the single-flight cleanup cancellation-safe. The in-flight slot is now cleared by the shared refresh computation itself (epoch-guarded) rather than the leader's post-await code. Previously, if the leader future was dropped (e.g. an FFI caller cancelling its promise) after a follower drove the refresh to completion, the completed future was stranded in the slot and later refresh_now() calls re-joined it, pinning the client to a stale or already-rejected token. Adds a regression test that cancels the leader and asserts the next refresh starts a fresh attempt. - Redact bearer secrets from Debug. RefreshedToken and the oidc RefreshTokenInput/RefreshTokenOutput now use manual Debug impls that omit the access/refresh token fields via finish_non_exhaustive, matching the house style (e.g. SecretResolver, SandboxJwtIssuer). Prevents a stray {:?} or a containing struct's derived Debug from writing tokens to logs. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): fail refresh when the new token can't be encoded as metadata store_bearer now returns an error instead of silently keeping the previous bearer value. The TokenSource commits the refreshed token to its state before the client writes it into the interceptor slot, so a silent drop left the interceptor on the old (expiring) token with no path back to a refresh. Surfacing the error fails the call loudly instead. Adds a unit test covering a token that can't be encoded as gRPC metadata. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): refresh OIDC tokens on raw routes and harden rotation Raw gRPC access never triggered OIDC refresh: a client that only used raw_grpc/raw_inference kept sending the initial bearer until it expired, with no proactive or reactive refresh. Add raw_grpc_fresh and raw_inference_fresh accessors that refresh before returning the client, plus force_refresh for reactive recovery after an Unauthenticated raw RPC. Guard the single-flight refresh commit against a concurrent replace(). The in-flight attempt now records the generation it started from and skips its write when an external replace() has advanced it, so timer or callback driven rotation is no longer clobbered by a slower refresh. Remove TokenSource::snapshot(): it returned an empty string under write contention and had no consumer on the CLI/TUI path. Tests read committed state directly instead. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * docs(sdk): rewrite crate README for consumers Recast the openshell-sdk README as a usable crate README rather than an RFC excerpt. Drop the Responsibilities/Non-responsibilities/Consumers scope-boundary sections and the mTLS migration rationale, folding the useful facts (explicit token, no disk/name resolution, Refresh trait, SdkError mapping) into the intro, a new Auth and refresh section, and Public surface. Remove the dead relative RFC link and status-label prose so the doc renders cleanly wherever it is published. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): address review feedback on OIDC refresh and transport Apply Drew's review notes on PR #1862: - Drop unused `rustls-pemfile` dependency and move `tokio-stream` to dev-dependencies (only used by tests). - Guard OIDC `expires_at` against u64 overflow with `saturating_add`. - Fix stale `#[non_exhaustive]` rationale in `AuthConfig` (the struct `Oidc` variant it described as future already ships). - Stop double-wrapping refresh errors: store the bare refresh-error text so the single `SdkError::auth` wrap happens once at await. - Strip stale CLI porting breadcrumbs from `build_channel` docs, keeping the branch table. - Treat proactive token refresh as best-effort: a transient failure falls through to the request instead of failing an RPC whose current token is still valid, with a regression test. - Collapse `exec`'s inline auth retry into the shared `unary` helper. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * 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> --------- Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
171 lines
5.4 KiB
Rust
171 lines
5.4 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! SDK error type. Surfaces a discriminable variant set so consumers (CLI,
|
|
//! TUI, language bindings) can decide how to render or remap each kind.
|
|
|
|
use miette::Diagnostic;
|
|
use thiserror::Error;
|
|
|
|
/// SDK result type alias.
|
|
pub type Result<T> = std::result::Result<T, SdkError>;
|
|
|
|
/// Errors produced by `openshell-sdk`.
|
|
///
|
|
/// CLI consumers convert these to `miette::Report` at the call boundary;
|
|
/// future TS/Python bindings will map them to language-native exceptions
|
|
/// via the [`SdkError::code`] accessor.
|
|
#[derive(Debug, Error, Diagnostic)]
|
|
pub enum SdkError {
|
|
/// Caller-supplied configuration is invalid (URL parse, missing field,
|
|
/// illegal token characters).
|
|
#[error("invalid configuration: {message}")]
|
|
#[diagnostic(code(openshell::sdk::invalid_config))]
|
|
InvalidConfig {
|
|
/// Error message.
|
|
message: String,
|
|
},
|
|
|
|
/// TLS material parse or rustls config build failure.
|
|
#[error("TLS error: {message}")]
|
|
#[diagnostic(code(openshell::sdk::tls))]
|
|
Tls {
|
|
/// Error message.
|
|
message: String,
|
|
},
|
|
|
|
/// Failed to establish a connection to the gateway (TCP, TLS handshake,
|
|
/// HTTP/2, WebSocket upgrade).
|
|
#[error("connect error: {message}")]
|
|
#[diagnostic(code(openshell::sdk::connect))]
|
|
Connect {
|
|
/// Error message.
|
|
message: String,
|
|
},
|
|
|
|
/// Auth-related failure: OIDC discovery / refresh, token format invalid
|
|
/// for header injection.
|
|
///
|
|
/// `retryable` mirrors the refresh contract's transient/terminal split: a
|
|
/// transient failure (network or `IdP` blip) may succeed on retry, while a
|
|
/// terminal one (session revoked, refresh token expired) requires
|
|
/// re-authentication. All non-refresh auth failures are non-retryable.
|
|
#[error("auth error: {message}")]
|
|
#[diagnostic(code(openshell::sdk::auth))]
|
|
Auth {
|
|
/// Error message.
|
|
message: String,
|
|
/// Whether retrying the same operation may succeed.
|
|
retryable: bool,
|
|
},
|
|
|
|
/// Local IO failure (file read, listener bind, socket).
|
|
#[error("I/O error: {source}")]
|
|
#[diagnostic(code(openshell::sdk::io))]
|
|
Io {
|
|
/// Underlying I/O error.
|
|
#[from]
|
|
source: std::io::Error,
|
|
},
|
|
|
|
/// Gateway reported the requested object does not exist (gRPC `NotFound`).
|
|
#[error("not found: {message}")]
|
|
#[diagnostic(code(openshell::sdk::not_found))]
|
|
NotFound {
|
|
/// Error message.
|
|
message: String,
|
|
},
|
|
|
|
/// Gateway reported the requested object already exists (gRPC `AlreadyExists`).
|
|
#[error("already exists: {message}")]
|
|
#[diagnostic(code(openshell::sdk::already_exists))]
|
|
AlreadyExists {
|
|
/// Error message.
|
|
message: String,
|
|
},
|
|
|
|
/// Catch-all for gRPC errors not mapped to a more specific variant.
|
|
#[error("gateway error ({code}): {message}")]
|
|
#[diagnostic(code(openshell::sdk::rpc))]
|
|
Rpc {
|
|
/// Numeric gRPC status code (see [`tonic::Code`]).
|
|
code: i32,
|
|
/// Error message.
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
impl SdkError {
|
|
/// Create an `InvalidConfig` error.
|
|
pub fn invalid_config(message: impl Into<String>) -> Self {
|
|
Self::InvalidConfig {
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
/// Create a `Tls` error.
|
|
pub fn tls(message: impl Into<String>) -> Self {
|
|
Self::Tls {
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
/// Create a `Connect` error.
|
|
pub fn connect(message: impl Into<String>) -> Self {
|
|
Self::Connect {
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
/// Create a non-retryable `Auth` error.
|
|
pub fn auth(message: impl Into<String>) -> Self {
|
|
Self::Auth {
|
|
message: message.into(),
|
|
retryable: false,
|
|
}
|
|
}
|
|
|
|
/// Create an `Auth` error, tagging whether retrying may succeed.
|
|
pub fn auth_retryable(message: impl Into<String>, retryable: bool) -> Self {
|
|
Self::Auth {
|
|
message: message.into(),
|
|
retryable,
|
|
}
|
|
}
|
|
|
|
/// Stable string code for cross-language binding consumers.
|
|
///
|
|
/// Returns one of: `invalid_config`, `tls`, `connect`, `auth`, `io`,
|
|
/// `not_found`, `already_exists`, `rpc`. Phase 3 (napi binding) will
|
|
/// surface this as the JS error's `code` field for discriminated-union
|
|
/// ergonomics.
|
|
/// Whether retrying the same operation may succeed.
|
|
///
|
|
/// Only a transient [`SdkError::Auth`] failure reports `true`; every other
|
|
/// error is non-retryable and the caller should surface it rather than
|
|
/// loop. Consumers use this to distinguish a retryable refresh blip from a
|
|
/// dead session that needs re-authentication.
|
|
pub const fn retryable(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Auth {
|
|
retryable: true,
|
|
..
|
|
}
|
|
)
|
|
}
|
|
|
|
pub const fn code(&self) -> &'static str {
|
|
match self {
|
|
Self::InvalidConfig { .. } => "invalid_config",
|
|
Self::Tls { .. } => "tls",
|
|
Self::Connect { .. } => "connect",
|
|
Self::Auth { .. } => "auth",
|
|
Self::Io { .. } => "io",
|
|
Self::NotFound { .. } => "not_found",
|
|
Self::AlreadyExists { .. } => "already_exists",
|
|
Self::Rpc { .. } => "rpc",
|
|
}
|
|
}
|
|
}
|