feat(api): add structured gateway errors and SDK decoding (#3313)

* feat(api): expose structured gateway errors across SDKs

Refs #3051. Add standard validation, conflict, and retry details; preserve raw transport status in Rust, Go, TypeScript, and Python; document status and recovery guidance.

This is the structured-error foundation only. Mutation result shapes, allow_missing, durable request deduplication, and exec retry semantics remain follow-up work.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* fix(python): preserve wrapped RPC cleanup handling

Inspect the original gRPC call when handling missing sandboxes during deletion waits and managed cleanup. Add intercepted cleanup regressions and clarify the error-wrapper migration contract.

Addresses the cleanup review on #3313; part of #3051.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

---------

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>
This commit is contained in:
Mrunal Patel
2026-09-15 17:51:11 +00:00
committed by GitHub
parent b799fccb8b
commit 39cf4823f7
32 changed files with 1355 additions and 179 deletions
Generated
+1
View File
@@ -4057,6 +4057,7 @@ dependencies = [
"tonic",
"tonic-prost",
"tonic-prost-build",
"tonic-types",
"tracing",
"url",
]
+1
View File
@@ -18,6 +18,7 @@ tokio = { version = "1.43", features = ["full"] }
# gRPC/Protobuf
tonic = "0.14"
tonic-types = "0.14"
tonic-prost = "0.14"
tonic-prost-build = "0.14"
prost = "0.14"
+9
View File
@@ -53,6 +53,15 @@ The gateway validates this requirement before constructing the selected driver.
## Protocol and Auth
Gateway validation and concurrency errors use the standard rich gRPC error
envelope. Shared field validators attach `google.rpc.BadRequest`, and conditional
write conflicts attach `google.rpc.ErrorInfo` with a stable reason and current
version when available. `google.rpc.RetryInfo` expresses a minimum retry delay;
it does not establish that a mutation is safe to repeat. SDKs retain the original
transport status, metadata, and unknown details alongside decoded fields.
Python cleanup inspects the original gRPC call beneath a typed error wrapper,
preserving missing-resource handling without suppressing other failures.
The gateway listens on one service port and multiplexes gRPC and HTTP traffic.
The default local single-user deployment mode is mTLS user authentication:
clients present a certificate signed by the local deployment CA, and the
+1
View File
@@ -18,6 +18,7 @@ prost = { workspace = true }
prost-types = { workspace = true }
tonic = { workspace = true, features = ["channel", "tls-aws-lc"] }
tonic-prost = { workspace = true }
tonic-types = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
thiserror = { workspace = true }
+1
View File
@@ -44,6 +44,7 @@ pub mod proposals;
pub mod proto;
pub mod proto_struct;
pub mod provider_credentials;
pub mod rpc_error;
pub mod sandbox_env;
pub mod secrets;
pub mod settings;
+172
View File
@@ -0,0 +1,172 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Standard gRPC error details shared by gateway handlers and SDK clients.
//!
//! Retry guidance describes recovery from a failure. It never establishes that
//! repeating an arbitrary mutation is safe.
use std::collections::HashMap;
use std::time::Duration;
use prost::Message;
use tonic::{Code, Status};
pub use tonic_types::{ErrorDetails, StatusExt};
/// Domain of gateway-owned machine-readable error reasons.
pub const ERROR_DOMAIN: &str = "openshell.nvidia.com";
/// Decode supported details without trusting inconsistent envelopes or delays.
/// Unknown and malformed details remain available on the original status.
pub fn decode_details(status: &Status) -> Option<ErrorDetails> {
let envelope = tonic_types::pb::Status::decode(status.details()).ok()?;
if envelope.code != status.code() as i32 || envelope.message != status.message() {
return None;
}
let mut details = ErrorDetails::new();
for detail in envelope.details {
match detail.type_url.rsplit('/').next() {
Some("google.rpc.BadRequest") => {
if let Ok(value) = tonic_types::pb::BadRequest::decode(detail.value.as_slice()) {
for violation in value.field_violations {
details.add_bad_request_violation(violation.field, violation.description);
}
}
}
Some("google.rpc.ErrorInfo") => {
if let Ok(value) = tonic_types::pb::ErrorInfo::decode(detail.value.as_slice()) {
details.set_error_info(value.reason, value.domain, value.metadata);
}
}
Some("google.rpc.RetryInfo") => {
if let Ok(value) = tonic_types::pb::RetryInfo::decode(detail.value.as_slice())
&& let Some(delay) = value.retry_delay
&& (0..=315_576_000_000).contains(&delay.seconds)
&& (0..1_000_000_000).contains(&delay.nanos)
{
details.set_retry_info(Some(Duration::new(
delay.seconds.cast_unsigned(),
delay.nanos.cast_unsigned(),
)));
}
}
_ => {}
}
}
Some(details)
}
/// Return a field-level validation failure without reflecting the field value.
pub fn invalid_argument(field: &str, message: impl Into<String>) -> Status {
let message = message.into();
let mut details = ErrorDetails::with_bad_request_violation(field, message.clone());
details.set_error_info("INVALID_ARGUMENT", ERROR_DOMAIN, HashMap::new());
Status::with_error_details(Code::InvalidArgument, message, details)
}
/// Return a precondition failure with a stable reason.
pub fn failed_precondition(reason: &str, message: impl Into<String>) -> Status {
Status::with_error_details(
Code::FailedPrecondition,
message,
ErrorDetails::with_error_info(reason, ERROR_DOMAIN, HashMap::new()),
)
}
/// A conditional write lost a race. Read fresh state before trying a new write.
pub fn resource_version_conflict(message: impl Into<String>, version: Option<u64>) -> Status {
let mut metadata = HashMap::from([("recovery".into(), "REFRESH_STATE".into())]);
if let Some(version) = version {
metadata.insert("current_resource_version".into(), version.to_string());
}
Status::with_error_details(
Code::Aborted,
message,
ErrorDetails::with_error_info("RESOURCE_VERSION_CONFLICT", ERROR_DOMAIN, metadata),
)
}
/// Return a transient failure with a minimum delay for retry-safe operations.
pub fn unavailable(reason: &str, message: impl Into<String>, delay: Duration) -> Status {
let mut details = ErrorDetails::with_error_info(reason, ERROR_DOMAIN, HashMap::new());
details.set_retry_info(Some(delay));
Status::with_error_details(Code::Unavailable, message, details)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_ignores_invalid_delay_and_preserves_other_details() {
let status = invalid_argument("name", "invalid name");
let mut envelope = tonic_types::pb::Status::decode(status.details()).unwrap();
envelope.details.push(prost_types::Any {
type_url: "type.googleapis.com/google.rpc.RetryInfo".into(),
value: tonic_types::pb::RetryInfo {
retry_delay: Some(prost_types::Duration {
seconds: -1,
nanos: 0,
}),
}
.encode_to_vec(),
});
envelope.details.push(prost_types::Any {
type_url: "type.googleapis.com/google.rpc.BadRequest".into(),
value: vec![255],
});
let status = Status::with_details(
status.code(),
status.message(),
envelope.encode_to_vec().into(),
);
let details = decode_details(&status).unwrap();
assert!(details.retry_info().is_none());
assert_eq!(
details.bad_request().unwrap().field_violations[0].field,
"name"
);
let mismatched = Status::with_details(
Code::Internal,
status.message(),
status.details().to_vec().into(),
);
assert!(decode_details(&mismatched).is_none());
}
#[test]
fn validation_identifies_the_field_and_reason() {
let status = invalid_argument("spec.command[0]", "must not be empty");
let details = status.get_error_details();
assert_eq!(status.code(), Code::InvalidArgument);
let violation = &details.bad_request().unwrap().field_violations[0];
assert_eq!(violation.field, "spec.command[0]");
assert_eq!(violation.description, "must not be empty");
assert_eq!(details.error_info().unwrap().domain, ERROR_DOMAIN);
assert!(details.retry_info().is_none());
}
#[test]
fn conflict_requires_fresh_state_without_blind_retry_delay() {
let status = resource_version_conflict("concurrent write", Some(17));
let details = status.get_error_details();
let info = details.error_info().unwrap();
assert_eq!(status.code(), Code::Aborted);
assert_eq!(info.reason, "RESOURCE_VERSION_CONFLICT");
assert_eq!(info.metadata["recovery"], "REFRESH_STATE");
assert_eq!(info.metadata["current_resource_version"], "17");
assert!(details.retry_info().is_none());
}
#[test]
fn temporary_unavailability_has_a_reason_and_delay() {
let status = unavailable("GATEWAY_NOT_READY", "try later", Duration::from_secs(1));
let details = status.get_error_details();
assert_eq!(status.code(), Code::Unavailable);
assert_eq!(details.error_info().unwrap().reason, "GATEWAY_NOT_READY");
assert_eq!(
details.retry_info().unwrap().retry_delay,
Some(Duration::from_secs(1))
);
}
}
+1 -11
View File
@@ -1179,17 +1179,7 @@ fn sandbox_template_from_response(
}
fn map_status(status: tonic::Status) -> SdkError {
let message = status.message().to_string();
match status.code() {
tonic::Code::NotFound => SdkError::NotFound { message },
tonic::Code::AlreadyExists => SdkError::AlreadyExists { message },
tonic::Code::InvalidArgument => SdkError::invalid_config(message),
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => SdkError::auth(message),
_ => SdkError::Rpc {
code: status.code() as i32,
message,
},
}
SdkError::from_status(status)
}
#[cfg(test)]
+127
View File
@@ -5,6 +5,7 @@
//! TUI, language bindings) can decide how to render or remap each kind.
use miette::Diagnostic;
pub use openshell_core::rpc_error::ErrorDetails;
use thiserror::Error;
/// SDK result type alias.
@@ -24,6 +25,8 @@ pub enum SdkError {
InvalidConfig {
/// Error message.
message: String,
/// Original gateway status, when validation happened remotely.
status: Option<Box<tonic::Status>>,
},
/// TLS material parse or rustls config build failure.
@@ -57,6 +60,8 @@ pub enum SdkError {
message: String,
/// Whether retrying the same operation may succeed.
retryable: bool,
/// Original gateway status, when authentication happened remotely.
status: Option<Box<tonic::Status>>,
},
/// Local IO failure (file read, listener bind, socket).
@@ -74,6 +79,8 @@ pub enum SdkError {
NotFound {
/// Error message.
message: String,
/// Original gateway status, including details and metadata.
status: Box<tonic::Status>,
},
/// Gateway reported the requested object already exists (gRPC `AlreadyExists`).
@@ -82,6 +89,8 @@ pub enum SdkError {
AlreadyExists {
/// Error message.
message: String,
/// Original gateway status, including details and metadata.
status: Box<tonic::Status>,
},
/// Catch-all for gRPC errors not mapped to a more specific variant.
@@ -92,6 +101,8 @@ pub enum SdkError {
code: i32,
/// Error message.
message: String,
/// Original gateway status, including unknown details and metadata.
status: Box<tonic::Status>,
},
}
@@ -100,6 +111,7 @@ impl SdkError {
pub fn invalid_config(message: impl Into<String>) -> Self {
Self::InvalidConfig {
message: message.into(),
status: None,
}
}
@@ -122,6 +134,7 @@ impl SdkError {
Self::Auth {
message: message.into(),
retryable: false,
status: None,
}
}
@@ -130,9 +143,59 @@ impl SdkError {
Self::Auth {
message: message.into(),
retryable,
status: None,
}
}
/// Map a gateway failure while retaining its complete transport status.
pub fn from_status(status: tonic::Status) -> Self {
let message = status.message().to_owned();
let code = status.code();
let status = Box::new(status);
match code {
tonic::Code::NotFound => Self::NotFound { message, status },
tonic::Code::AlreadyExists => Self::AlreadyExists { message, status },
tonic::Code::InvalidArgument => Self::InvalidConfig {
message,
status: Some(status),
},
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => Self::Auth {
message,
retryable: false,
status: Some(status),
},
_ => Self::Rpc {
code: code as i32,
message,
status,
},
}
}
/// Complete original status, including unrecognized details and metadata.
pub fn grpc_status(&self) -> Option<&tonic::Status> {
match self {
Self::InvalidConfig { status, .. } | Self::Auth { status, .. } => status.as_deref(),
Self::NotFound { status, .. }
| Self::AlreadyExists { status, .. }
| Self::Rpc { status, .. } => Some(status),
_ => None,
}
}
/// Decode standard rich details. Malformed details never replace the failure.
/// The original bytes remain accessible through [`Self::grpc_status`].
pub fn error_details(&self) -> Option<ErrorDetails> {
self.grpc_status()
.and_then(openshell_core::rpc_error::decode_details)
}
/// Server-suggested minimum retry delay, if supplied.
/// This does not establish that repeating a mutation is safe.
pub fn retry_delay(&self) -> Option<std::time::Duration> {
self.error_details()?.retry_info()?.retry_delay
}
/// Stable string code for cross-language binding consumers.
///
/// Returns one of: `invalid_config`, `tls`, `connect`, `auth`, `io`,
@@ -168,3 +231,67 @@ impl SdkError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use openshell_core::rpc_error;
use openshell_core::rpc_error::StatusExt;
#[test]
fn preserves_details_and_metadata_for_every_status_class() {
for code in [
tonic::Code::InvalidArgument,
tonic::Code::NotFound,
tonic::Code::AlreadyExists,
tonic::Code::Unauthenticated,
tonic::Code::PermissionDenied,
tonic::Code::Aborted,
tonic::Code::Unavailable,
] {
let mut status = tonic::Status::with_error_details(
code,
"invalid name",
rpc_error::invalid_argument("name", "invalid name").get_error_details(),
);
status
.metadata_mut()
.insert("request-id", "test-correlation".parse().unwrap());
let original = status.details().to_vec();
let error = SdkError::from_status(status);
let raw = error.grpc_status().unwrap();
assert_eq!(raw.code(), code);
assert_eq!(raw.details(), original);
assert_eq!(
raw.metadata().get("request-id").unwrap(),
"test-correlation"
);
assert_eq!(
error
.error_details()
.unwrap()
.bad_request()
.unwrap()
.field_violations[0]
.field,
"name"
);
}
}
#[test]
fn malformed_details_do_not_replace_the_original_error() {
let status =
tonic::Status::with_details(tonic::Code::Unavailable, "offline", vec![255].into());
let error = SdkError::from_status(status);
assert_eq!(error.grpc_status().unwrap().details(), &[255]);
assert!(error.error_details().is_none());
assert!(error.retry_delay().is_none());
}
#[test]
fn local_errors_have_no_transport_status() {
assert!(SdkError::invalid_config("local").grpc_status().is_none());
assert!(SdkError::auth("local").error_details().is_none());
}
}
@@ -109,7 +109,8 @@ pub async fn authorize_list_workspace_selector(
pub fn selected_workspace_name(selector: Option<&WorkspaceSelector>) -> Result<&str, Status> {
match selected_workspace(selector)? {
WorkspaceSelection::Workspace(workspace) => Ok(workspace),
WorkspaceSelection::AllWorkspaces(_) => Err(Status::invalid_argument(
WorkspaceSelection::AllWorkspaces(_) => Err(openshell_core::rpc_error::invalid_argument(
"workspace_scope",
"all_workspaces is not supported by this request",
)),
}
@@ -119,7 +120,12 @@ pub fn selected_workspace_name(selector: Option<&WorkspaceSelector>) -> Result<&
fn selected_workspace(selector: Option<&WorkspaceSelector>) -> Result<&WorkspaceSelection, Status> {
let selection = selector
.and_then(|selector| selector.selection.as_ref())
.ok_or_else(|| Status::invalid_argument("workspace_scope is required"))?;
.ok_or_else(|| {
openshell_core::rpc_error::invalid_argument(
"workspace_scope",
"workspace_scope is required",
)
})?;
if let WorkspaceSelection::Workspace(workspace) = selection {
crate::grpc::workspace::validate_workspace_name(workspace)?;
+8 -5
View File
@@ -82,11 +82,14 @@ pub fn persistence_error_to_status(
match err {
PersistenceError::Conflict {
current_resource_version,
} => Status::aborted(format!(
"{} failed due to concurrent modification (current resource_version: {})",
operation,
current_resource_version.map_or_else(|| "unknown".to_string(), |v| v.to_string())
)),
} => openshell_core::rpc_error::resource_version_conflict(
format!(
"{} failed due to concurrent modification (current resource_version: {})",
operation,
current_resource_version.map_or_else(|| "unknown".to_string(), |v| v.to_string())
),
current_resource_version,
),
other => Status::internal(format!("{operation} failed: {other}")),
}
}
+266 -142
View File
@@ -12,6 +12,7 @@ use openshell_core::proto::{
CredentialHandle, ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy,
SandboxSpec, SandboxTemplate,
};
use openshell_core::rpc_error::invalid_argument;
use prost::Message;
use tonic::Status;
@@ -44,32 +45,36 @@ pub(super) const MAX_MAIN_PROCESS_ARGV_SIZE: usize = 256 * 1024;
/// Environment values and workdir reject both NUL and newlines.
pub(super) fn validate_exec_request_fields(req: &ExecSandboxRequest) -> Result<(), Status> {
if req.command.len() > MAX_EXEC_COMMAND_ARGS {
return Err(Status::invalid_argument(format!(
"command array exceeds {MAX_EXEC_COMMAND_ARGS} argument limit"
)));
return Err(invalid_argument(
"command",
format!("command array exceeds {MAX_EXEC_COMMAND_ARGS} argument limit"),
));
}
for (i, arg) in req.command.iter().enumerate() {
if arg.len() > MAX_EXEC_ARG_LEN {
return Err(Status::invalid_argument(format!(
"command argument {i} exceeds {MAX_EXEC_ARG_LEN} byte limit"
)));
return Err(invalid_argument(
&format!("command[{i}]"),
format!("command argument {i} exceeds {MAX_EXEC_ARG_LEN} byte limit"),
));
}
reject_null_char(arg, &format!("command argument {i}"))?;
reject_null_char(arg, &format!("command[{i}]"))?;
}
for (key, value) in &req.environment {
if value.len() > MAX_EXEC_ARG_LEN {
return Err(Status::invalid_argument(format!(
"environment value for '{key}' exceeds {MAX_EXEC_ARG_LEN} byte limit"
)));
return Err(invalid_argument(
"environment",
format!("environment value for '{key}' exceeds {MAX_EXEC_ARG_LEN} byte limit"),
));
}
reject_control_chars(value, &format!("environment value for '{key}'"))?;
reject_control_chars(value, "environment")?;
}
validate_exec_env_entries(&req.environment, "environment")?;
if !req.workdir.is_empty() {
if req.workdir.len() > MAX_EXEC_WORKDIR_LEN {
return Err(Status::invalid_argument(format!(
"workdir exceeds {MAX_EXEC_WORKDIR_LEN} byte limit"
)));
return Err(invalid_argument(
"workdir",
format!("workdir exceeds {MAX_EXEC_WORKDIR_LEN} byte limit"),
));
}
reject_control_chars(&req.workdir, "workdir")?;
}
@@ -86,9 +91,10 @@ pub(super) fn reject_control_chars(value: &str, field_name: &str) -> Result<(),
/// Reject null bytes in a user-supplied value.
pub(super) fn reject_null_char(value: &str, field_name: &str) -> Result<(), Status> {
if value.bytes().any(|b| b == 0) {
return Err(Status::invalid_argument(format!(
"{field_name} contains null bytes"
)));
return Err(invalid_argument(
field_name,
format!("{field_name} contains null bytes"),
));
}
Ok(())
}
@@ -96,9 +102,10 @@ pub(super) fn reject_null_char(value: &str, field_name: &str) -> Result<(), Stat
/// Reject newline and carriage return characters in a user-supplied value.
pub(super) fn reject_newline_chars(value: &str, field_name: &str) -> Result<(), Status> {
if value.bytes().any(|b| b == b'\n' || b == b'\r') {
return Err(Status::invalid_argument(format!(
"{field_name} contains newline or carriage return characters"
)));
return Err(invalid_argument(
field_name,
format!("{field_name} contains newline or carriage return characters"),
));
}
Ok(())
}
@@ -117,28 +124,34 @@ pub(super) fn validate_dns1123_label(name: &str, field: &str) -> Result<(), Stat
return Ok(());
}
if name.len() > MAX_NAME_LEN {
return Err(Status::invalid_argument(format!(
"{field} exceeds maximum length ({} > {MAX_NAME_LEN})",
name.len()
)));
return Err(invalid_argument(
field,
format!(
"{field} exceeds maximum length ({} > {MAX_NAME_LEN})",
name.len()
),
));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(Status::invalid_argument(format!(
"{field} must contain only lowercase alphanumeric characters or hyphens",
)));
return Err(invalid_argument(
field,
format!("{field} must contain only lowercase alphanumeric characters or hyphens"),
));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(Status::invalid_argument(format!(
"{field} must not start or end with a hyphen",
)));
return Err(invalid_argument(
field,
format!("{field} must not start or end with a hyphen"),
));
}
if name.contains("--") {
return Err(Status::invalid_argument(format!(
"{field} must not contain consecutive hyphens",
)));
return Err(invalid_argument(
field,
format!("{field} must not contain consecutive hyphens"),
));
}
Ok(())
}
@@ -159,10 +172,13 @@ pub(super) fn validate_sandbox_spec(name: &str, spec: &SandboxSpec) -> Result<()
// --- spec.log_level ---
if spec.log_level.len() > MAX_LOG_LEVEL_LEN {
return Err(Status::invalid_argument(format!(
"log_level exceeds maximum length ({} > {MAX_LOG_LEVEL_LEN})",
spec.log_level.len()
)));
return Err(invalid_argument(
"spec.log_level",
format!(
"log_level exceeds maximum length ({} > {MAX_LOG_LEVEL_LEN})",
spec.log_level.len()
),
));
}
// --- spec.environment ---
@@ -209,20 +225,26 @@ pub(super) fn validate_sandbox_governance_spec(
fn validate_sandbox_name(name: &str) -> Result<(), Status> {
if !name.is_empty() && name.len() > MAX_ROUTABLE_NAME_LEN {
return Err(Status::invalid_argument(format!(
"name exceeds maximum length ({} > {MAX_ROUTABLE_NAME_LEN})",
name.len()
)));
return Err(invalid_argument(
"name",
format!(
"name exceeds maximum length ({} > {MAX_ROUTABLE_NAME_LEN})",
name.len()
),
));
}
validate_dns1123_label(name, "name")
}
fn validate_sandbox_provider_count(spec: &SandboxSpec) -> Result<(), Status> {
if spec.providers.len() > MAX_PROVIDERS {
return Err(Status::invalid_argument(format!(
"providers list exceeds maximum ({} > {MAX_PROVIDERS})",
spec.providers.len()
)));
return Err(invalid_argument(
"spec.providers",
format!(
"providers list exceeds maximum ({} > {MAX_PROVIDERS})",
spec.providers.len()
),
));
}
Ok(())
}
@@ -231,9 +253,10 @@ fn validate_sandbox_policy_size(spec: &SandboxSpec) -> Result<(), Status> {
if let Some(ref policy) = spec.policy {
let size = policy.encoded_len();
if size > MAX_POLICY_SIZE {
return Err(Status::invalid_argument(format!(
"policy serialized size exceeds maximum ({size} > {MAX_POLICY_SIZE})"
)));
return Err(invalid_argument(
"spec.policy",
format!("policy serialized size exceeds maximum ({size} > {MAX_POLICY_SIZE})"),
));
}
}
@@ -242,26 +265,30 @@ fn validate_sandbox_policy_size(spec: &SandboxSpec) -> Result<(), Status> {
fn validate_main_process_command(command: &[String]) -> Result<(), Status> {
if command.len() > MAX_MAIN_PROCESS_ARGS {
return Err(Status::invalid_argument(format!(
"spec.command exceeds {MAX_MAIN_PROCESS_ARGS} argument limit"
)));
return Err(invalid_argument(
"spec.command",
format!("spec.command exceeds {MAX_MAIN_PROCESS_ARGS} argument limit"),
));
}
if command[0].is_empty() {
return Err(Status::invalid_argument(
return Err(invalid_argument(
"spec.command[0]",
"spec.command[0] must not be empty",
));
}
let argv_size: usize = command.iter().map(String::len).sum();
if argv_size > MAX_MAIN_PROCESS_ARGV_SIZE {
return Err(Status::invalid_argument(format!(
"spec.command total size exceeds {MAX_MAIN_PROCESS_ARGV_SIZE} byte limit"
)));
return Err(invalid_argument(
"spec.command",
format!("spec.command total size exceeds {MAX_MAIN_PROCESS_ARGV_SIZE} byte limit"),
));
}
for (index, argument) in command.iter().enumerate() {
if argument.len() > MAX_EXEC_ARG_LEN {
return Err(Status::invalid_argument(format!(
"spec.command[{index}] exceeds {MAX_EXEC_ARG_LEN} byte limit"
)));
return Err(invalid_argument(
&format!("spec.command[{index}]"),
format!("spec.command[{index}] exceeds {MAX_EXEC_ARG_LEN} byte limit"),
));
}
reject_null_char(argument, &format!("spec.command[{index}]"))?;
}
@@ -271,7 +298,10 @@ fn validate_main_process_command(command: &[String]) -> Result<(), Status> {
fn validate_gpu_request_fields(spec: &SandboxSpec) -> Result<(), Status> {
if openshell_core::gpu::sandbox_gpu_count(spec.resource_requirements.as_ref()) == Some(0) {
return Err(Status::invalid_argument("gpu count must be greater than 0"));
return Err(invalid_argument(
"spec.resource_requirements.gpu.count",
"gpu count must be greater than 0",
));
}
Ok(())
@@ -281,15 +311,18 @@ fn validate_gpu_request_fields(spec: &SandboxSpec) -> Result<(), Status> {
fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> {
// String fields.
for (field, value) in [
("template.image", &tmpl.image),
("template.runtime_class_name", &tmpl.runtime_class_name),
("template.agent_socket", &tmpl.agent_socket),
("spec.template.image", &tmpl.image),
("spec.template.runtime_class_name", &tmpl.runtime_class_name),
("spec.template.agent_socket", &tmpl.agent_socket),
] {
if value.len() > MAX_TEMPLATE_STRING_LEN {
return Err(Status::invalid_argument(format!(
"{field} exceeds maximum length ({} > {MAX_TEMPLATE_STRING_LEN})",
value.len()
)));
return Err(invalid_argument(
field,
format!(
"{field} exceeds maximum length ({} > {MAX_TEMPLATE_STRING_LEN})",
value.len()
),
));
}
}
@@ -299,38 +332,44 @@ fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> {
MAX_TEMPLATE_MAP_ENTRIES,
MAX_MAP_KEY_LEN,
MAX_MAP_VALUE_LEN,
"template.labels",
"spec.template.labels",
)?;
validate_string_map(
&tmpl.annotations,
MAX_TEMPLATE_MAP_ENTRIES,
MAX_MAP_KEY_LEN,
MAX_MAP_VALUE_LEN,
"template.annotations",
"spec.template.annotations",
)?;
validate_string_map(
&tmpl.environment,
MAX_TEMPLATE_MAP_ENTRIES,
MAX_MAP_KEY_LEN,
MAX_MAP_VALUE_LEN,
"template.environment",
"spec.template.environment",
)?;
// Struct fields (serialized size).
if let Some(ref s) = tmpl.resources {
let size = s.encoded_len();
if size > MAX_TEMPLATE_STRUCT_SIZE {
return Err(Status::invalid_argument(format!(
"template.resources serialized size exceeds maximum ({size} > {MAX_TEMPLATE_STRUCT_SIZE})"
)));
return Err(invalid_argument(
"spec.template.resources",
format!(
"template.resources serialized size exceeds maximum ({size} > {MAX_TEMPLATE_STRUCT_SIZE})"
),
));
}
}
if let Some(ref s) = tmpl.driver_config {
let size = s.encoded_len();
if size > MAX_TEMPLATE_STRUCT_SIZE {
return Err(Status::invalid_argument(format!(
"template.driver_config serialized size exceeds maximum ({size} > {MAX_TEMPLATE_STRUCT_SIZE})"
)));
return Err(invalid_argument(
"spec.template.driver_config",
format!(
"template.driver_config serialized size exceeds maximum ({size} > {MAX_TEMPLATE_STRUCT_SIZE})"
),
));
}
reject_gateway_owned_driver_config_keys(s)?;
}
@@ -352,10 +391,13 @@ fn reject_gateway_owned_driver_config_keys(config: &prost_types::Struct) -> Resu
};
for key in GATEWAY_OWNED_DRIVER_CONFIG_KEYS {
if driver_config.fields.contains_key(*key) {
return Err(Status::invalid_argument(format!(
"template.driver_config.{driver_name}.{key} is set by the gateway \
return Err(invalid_argument(
"spec.template.driver_config",
format!(
"template.driver_config.{driver_name}.{key} is set by the gateway \
and cannot be supplied by the caller"
)));
),
));
}
}
}
@@ -371,23 +413,32 @@ pub(super) fn validate_string_map(
field_name: &str,
) -> Result<(), Status> {
if map.len() > max_entries {
return Err(Status::invalid_argument(format!(
"{field_name} exceeds maximum entries ({} > {max_entries})",
map.len()
)));
return Err(invalid_argument(
field_name,
format!(
"{field_name} exceeds maximum entries ({} > {max_entries})",
map.len()
),
));
}
for (key, value) in map {
if key.len() > max_key_len {
return Err(Status::invalid_argument(format!(
"{field_name} key exceeds maximum length ({} > {max_key_len})",
key.len()
)));
return Err(invalid_argument(
field_name,
format!(
"{field_name} key exceeds maximum length ({} > {max_key_len})",
key.len()
),
));
}
if value.len() > max_value_len {
return Err(Status::invalid_argument(format!(
"{field_name} value exceeds maximum length ({} > {max_value_len})",
value.len()
)));
return Err(invalid_argument(
field_name,
format!(
"{field_name} value exceeds maximum length ({} > {max_value_len})",
value.len()
),
));
}
}
Ok(())
@@ -431,9 +482,12 @@ fn validate_env_entries(
) -> Result<(), Status> {
let total_size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum();
if total_size > MAX_ENV_SERIALIZED_SIZE {
return Err(Status::invalid_argument(format!(
"{field_name} total size exceeds {MAX_ENV_SERIALIZED_SIZE} byte limit ({total_size} bytes)"
)));
return Err(invalid_argument(
field_name,
format!(
"{field_name} total size exceeds {MAX_ENV_SERIALIZED_SIZE} byte limit ({total_size} bytes)"
),
));
}
validate_env_entries_inner(map, field_name, &[])
}
@@ -452,16 +506,18 @@ fn validate_env_entries_inner(
) -> Result<(), Status> {
for (key, value) in map {
if !super::provider::is_valid_env_key(key) {
return Err(Status::invalid_argument(format!(
"{field_name} keys must match ^[A-Za-z_][A-Za-z0-9_]*$; got '{key}'"
)));
return Err(invalid_argument(
field_name,
format!("{field_name} keys must match ^[A-Za-z_][A-Za-z0-9_]*$; got '{key}'"),
));
}
if key.starts_with("OPENSHELL_") && !allowed_openshell_keys.contains(&key.as_str()) {
return Err(Status::invalid_argument(format!(
"{field_name} keys starting with OPENSHELL_ are reserved; got '{key}'"
)));
return Err(invalid_argument(
field_name,
format!("{field_name} keys starting with OPENSHELL_ are reserved; got '{key}'"),
));
}
reject_control_chars(value, &format!("{field_name} value for '{key}'"))?;
reject_control_chars(value, field_name)?;
}
Ok(())
}
@@ -474,15 +530,19 @@ fn validate_env_entries_inner(
pub(super) fn validate_provider_fields(provider: &Provider) -> Result<(), Status> {
let name_len = provider.metadata.as_ref().map_or(0, |m| m.name.len());
if name_len > MAX_NAME_LEN {
return Err(Status::invalid_argument(format!(
"provider.name exceeds maximum length ({name_len} > {MAX_NAME_LEN})"
)));
return Err(invalid_argument(
"provider.metadata.name",
format!("provider.name exceeds maximum length ({name_len} > {MAX_NAME_LEN})"),
));
}
if provider.r#type.len() > MAX_PROVIDER_TYPE_LEN {
return Err(Status::invalid_argument(format!(
"provider.type exceeds maximum length ({} > {MAX_PROVIDER_TYPE_LEN})",
provider.r#type.len()
)));
return Err(invalid_argument(
"provider.type",
format!(
"provider.type exceeds maximum length ({} > {MAX_PROVIDER_TYPE_LEN})",
provider.r#type.len()
),
));
}
validate_provider_mutable_fields(provider)
}
@@ -512,20 +572,27 @@ pub(super) fn validate_provider_mutable_fields(provider: &Provider) -> Result<()
"provider.config",
)?;
if provider.credential_expires_at_ms.len() > MAX_PROVIDER_CREDENTIALS_ENTRIES {
return Err(Status::invalid_argument(format!(
"provider.credential_expires_at_ms exceeds maximum entries ({} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})",
provider.credential_expires_at_ms.len()
)));
return Err(invalid_argument(
"provider.credential_expires_at_ms",
format!(
"provider.credential_expires_at_ms exceeds maximum entries ({} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})",
provider.credential_expires_at_ms.len()
),
));
}
for (key, value) in &provider.credential_expires_at_ms {
if key.len() > MAX_MAP_KEY_LEN {
return Err(Status::invalid_argument(format!(
"provider.credential_expires_at_ms key exceeds maximum length ({} > {MAX_MAP_KEY_LEN})",
key.len()
)));
return Err(invalid_argument(
"provider.credential_expires_at_ms",
format!(
"provider.credential_expires_at_ms key exceeds maximum length ({} > {MAX_MAP_KEY_LEN})",
key.len()
),
));
}
if *value < 0 {
return Err(Status::invalid_argument(
return Err(invalid_argument(
"provider.credential_expires_at_ms",
"provider.credential_expires_at_ms value must be greater than or equal to 0",
));
}
@@ -536,16 +603,22 @@ pub(super) fn validate_provider_mutable_fields(provider: &Provider) -> Result<()
fn validate_provider_credential_sources(provider: &Provider) -> Result<(), Status> {
let total_credentials = provider.credentials.len() + provider.credential_handles.len();
if total_credentials > MAX_PROVIDER_CREDENTIALS_ENTRIES {
return Err(Status::invalid_argument(format!(
"provider credential sources exceed maximum entries ({total_credentials} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})"
)));
return Err(invalid_argument(
"provider.credentials",
format!(
"provider credential sources exceed maximum entries ({total_credentials} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})"
),
));
}
for key in provider.credential_handles.keys() {
if provider.credentials.contains_key(key) {
return Err(Status::invalid_argument(format!(
"provider credential key '{key}' cannot be present in both provider.credentials and provider.credential_handles"
)));
return Err(invalid_argument(
"provider.credential_handles",
format!(
"provider credential key '{key}' cannot be present in both provider.credentials and provider.credential_handles"
),
));
}
}
Ok(())
@@ -555,23 +628,32 @@ fn validate_provider_credential_handles(
credential_handles: &std::collections::HashMap<String, CredentialHandle>,
) -> Result<(), Status> {
if credential_handles.len() > MAX_PROVIDER_CREDENTIALS_ENTRIES {
return Err(Status::invalid_argument(format!(
"provider.credential_handles exceeds maximum entries ({} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})",
credential_handles.len()
)));
return Err(invalid_argument(
"provider.credential_handles",
format!(
"provider.credential_handles exceeds maximum entries ({} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})",
credential_handles.len()
),
));
}
for (credential_key, handle) in credential_handles {
if credential_key.len() > MAX_MAP_KEY_LEN {
return Err(Status::invalid_argument(format!(
"provider.credential_handles key exceeds maximum length ({} > {MAX_MAP_KEY_LEN})",
credential_key.len()
)));
return Err(invalid_argument(
"provider.credential_handles",
format!(
"provider.credential_handles key exceeds maximum length ({} > {MAX_MAP_KEY_LEN})",
credential_key.len()
),
));
}
if !super::provider::is_valid_env_key(credential_key) {
return Err(Status::invalid_argument(format!(
"provider.credential_handles keys must match ^[A-Za-z_][A-Za-z0-9_]*$; got '{credential_key}'"
)));
return Err(invalid_argument(
"provider.credential_handles",
format!(
"provider.credential_handles keys must match ^[A-Za-z_][A-Za-z0-9_]*$; got '{credential_key}'"
),
));
}
validate_credential_handle(
handle,
@@ -593,8 +675,8 @@ fn validate_credential_handle(handle: &CredentialHandle, field_name: &str) -> Re
&format!("{field_name}.metadata"),
)?;
for (key, value) in &handle.metadata {
reject_control_chars(key, &format!("{field_name}.metadata key"))?;
reject_control_chars(value, &format!("{field_name}.metadata value for '{key}'"))?;
reject_control_chars(key, &format!("{field_name}.metadata"))?;
reject_control_chars(value, &format!("{field_name}.metadata"))?;
}
Ok(())
}
@@ -605,9 +687,10 @@ fn validate_required_credential_handle_string(
component: &str,
) -> Result<(), Status> {
if value.trim().is_empty() {
return Err(Status::invalid_argument(format!(
"{field_name}.{component} is required"
)));
return Err(invalid_argument(
&format!("{field_name}.{component}"),
format!("{field_name}.{component} is required"),
));
}
validate_optional_credential_handle_string(value, field_name, component)
}
@@ -618,10 +701,13 @@ fn validate_optional_credential_handle_string(
component: &str,
) -> Result<(), Status> {
if value.len() > MAX_MAP_VALUE_LEN {
return Err(Status::invalid_argument(format!(
"{field_name}.{component} exceeds maximum length ({} > {MAX_MAP_VALUE_LEN})",
value.len()
)));
return Err(invalid_argument(
&format!("{field_name}.{component}"),
format!(
"{field_name}.{component} exceeds maximum length ({} > {MAX_MAP_VALUE_LEN})",
value.len()
),
));
}
reject_control_chars(value, &format!("{field_name}.{component}"))
}
@@ -1040,9 +1126,47 @@ pub(super) fn level_matches(log_level: &str, min_level: &str) -> bool {
mod tests {
use super::*;
use openshell_core::proto::SandboxSpec;
use openshell_core::rpc_error::StatusExt;
use std::collections::HashMap;
use tonic::Code;
#[test]
fn sandbox_and_exec_validation_return_wire_field_paths() {
let cases = [
(
validate_sandbox_spec("UPPER", &SandboxSpec::default()).unwrap_err(),
"name",
),
(
validate_sandbox_spec(
"",
&SandboxSpec {
command: vec![String::new()],
..Default::default()
},
)
.unwrap_err(),
"spec.command[0]",
),
(
validate_exec_request_fields(&ExecSandboxRequest {
command: vec!["a\0b".into()],
..Default::default()
})
.unwrap_err(),
"command[0]",
),
];
for (status, field) in cases {
assert_eq!(status.code(), Code::InvalidArgument);
let details = status.get_error_details();
assert_eq!(
details.bad_request().unwrap().field_violations[0].field,
field
);
}
}
use crate::grpc::{
MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN,
MAX_NAME_LEN, MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES,
@@ -200,10 +200,14 @@ impl ProviderProfileSource for GatewayInterceptorProfileSource {
) -> Result<ProviderProfileSnapshot, Status> {
let InterceptorProfileSnapshot { revision, profiles } =
Self::snapshot(self).await.map_err(|err| {
Status::unavailable(format!(
"provider profile source '{}' snapshot failed: {err}",
self.source_id()
))
openshell_core::rpc_error::unavailable(
"PROFILE_SOURCE_UNAVAILABLE",
format!(
"provider profile source '{}' snapshot failed: {err}",
self.source_id()
),
std::time::Duration::from_secs(1),
)
})?;
let profiles = profiles
.into_iter()
+85
View File
@@ -0,0 +1,85 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
title: "API Errors"
sidebar-title: "API Errors"
description: "Inspect structured gateway failures and choose an appropriate recovery action."
keywords: "OpenShell, SDK, gRPC, API errors, retries"
---
Gateway errors include a gRPC status code and a message. Shared request validation
and concurrency checks also return standard protobuf details in
`grpc-status-details-bin`. Use the status code and structured fields for decisions.
Treat messages as explanations whose text can change.
## Structured details
The SDKs decode these standard detail types while retaining the original failure.
Older gateways and checks that have not adopted structured details can return only
a code and message. Missing details do not change the meaning of the status code.
| Detail | Meaning |
|---|---|
| `google.rpc.BadRequest` | `field_violations` identifies rejected fields and explains each violation. Shared sandbox, exec, provider size, and workspace selector checks supply these details. |
| `google.rpc.ErrorInfo` | `reason`, `domain`, and `metadata` identify a failure without parsing its message. Gateway reasons use the `openshell.nvidia.com` domain. |
| `google.rpc.RetryInfo` | `retry_delay` gives a minimum delay before an otherwise safe retry. Its presence does not guarantee that a mutation has not already committed. |
Recognized gateway reasons include the following.
| Reason | Code | Recovery |
|---|---|---|
| `INVALID_ARGUMENT` | `INVALID_ARGUMENT` | Correct the fields listed in `BadRequest`. |
| `RESOURCE_VERSION_CONFLICT` | `ABORTED` | Read the resource again and construct a new conditional write. `metadata.recovery` is `REFRESH_STATE`; `current_resource_version` is included when known. |
| `PROFILE_SOURCE_UNAVAILABLE` | `UNAVAILABLE` | Retry a profile snapshot read after at least the supplied delay. |
## Status and retry guidance
A timeout or disconnected transport can occur after a mutation commits. Do not
automatically repeat creates, credential rotation, or command execution based
only on a transient status. Retry a mutation only when its documented operation
contract makes the repeated request safe. A correlation ID does not provide that
guarantee.
| Status | Recovery |
|---|---|
| `INVALID_ARGUMENT`, `OUT_OF_RANGE` | Correct the request. |
| `UNAUTHENTICATED` | Refresh or replace credentials before a new attempt. |
| `PERMISSION_DENIED` | Obtain the required authorization. |
| `NOT_FOUND` | Check the resource and workspace. Follow the operation's missing-resource contract. |
| `ALREADY_EXISTS` | Inspect the existing resource before deciding whether it satisfies the request. |
| `FAILED_PRECONDITION` | Resolve the reported state or configuration requirement. |
| `ABORTED` | Read fresh state before retrying a conditional operation. |
| `UNAVAILABLE`, `RESOURCE_EXHAUSTED` | A transient condition may recover. Apply backoff and any minimum retry delay only to a retry-safe operation. |
| `DEADLINE_EXCEEDED`, `CANCELLED` | A mutation's outcome can be unknown. Cancellation does not imply rollback. |
| `UNIMPLEMENTED` | Check gateway and SDK version compatibility. |
| `INTERNAL`, `UNKNOWN`, `DATA_LOSS` | Preserve the status and correlation metadata for diagnosis. Do not blindly retry mutations. |
## SDK access
Each SDK exposes decoded fields and an escape hatch for complete transport data.
Unknown details remain available through the original error even when the SDK
does not recognize their message type.
| SDK | Decoded details | Original failure |
|---|---|---|
| Rust | `SdkError::error_details()`, `retry_delay()` | `SdkError::grpc_status()` returns the original `tonic::Status`, including details bytes and metadata. |
| Go | `StatusError.FieldViolations`, `ErrorInfo`, `RetryDelay`, `GRPCCode` | `StatusError.Cause` retains the gRPC error. `status.FromError` can recover its details through error wrapping. |
| TypeScript | `SdkError.fieldViolations`, `errorInfo`, `retryDelayMs`, `connectCode` | `SdkError.cause` retains the `ConnectError`, including all details and metadata. Use `fromConnect` for raw calls. |
| Python | `GatewayError.field_violations`, `error_info`, `retry_delay` | `raw_error` retains the gRPC exception and metadata; `raw_status` retains the parsed envelope. Use `from_grpc_error` for raw calls. |
Retry delays are Rust and Go durations, TypeScript milliseconds, and Python
seconds. Absent or invalid delay details do not produce a suggested delay.
## Migration
Existing error codes and human-readable messages remain available. Python curated
clients now raise `GatewayError`, which remains a `grpc.RpcError`; existing
`except grpc.RpcError` handlers continue to work. `GatewayError` is not a
`grpc.Call`. If your handler checks that interface, inspect `raw_error` before
checking the status. The Python SDK's deletion wait and managed-sandbox cleanup
use the original call to recognize `NOT_FOUND`; other failures still propagate.
Rust error variants now retain
status fields; use `..` when destructuring variants that do not need those fields.
Go and TypeScript add typed detail fields to their existing error types.
No SDK automatically retries a mutation as a result of decoding these details.
+12
View File
@@ -1082,6 +1082,7 @@ dependencies = [
"tonic",
"tonic-prost",
"tonic-prost-build",
"tonic-types",
"tracing",
"url",
]
@@ -2134,6 +2135,17 @@ dependencies = [
"tonic-build",
]
[[package]]
name = "tonic-types"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8"
dependencies = [
"prost",
"prost-types",
"tonic",
]
[[package]]
name = "tower"
version = "0.5.3"
+12
View File
@@ -969,6 +969,7 @@ dependencies = [
"tonic",
"tonic-prost",
"tonic-prost-build",
"tonic-types",
"tracing",
"url",
]
@@ -1781,6 +1782,17 @@ dependencies = [
"tonic-build",
]
[[package]]
name = "tonic-types"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8"
dependencies = [
"prost",
"prost-types",
"tonic",
]
[[package]]
name = "tower"
version = "0.5.3"
+1
View File
@@ -16,6 +16,7 @@ requires-python = ">=3.11"
dependencies = [
"cloudpickle>=3.0",
"grpcio>=1.60",
"googleapis-common-protos>=1.63",
"httpx>=0.27",
"protobuf>=4.25",
]
+5
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
from .errors import ErrorInfo, FieldViolation, GatewayError, from_grpc_error
from .sandbox import (
ClientCredentialsAuth,
ExecChunk,
@@ -33,8 +34,11 @@ except Exception:
__all__ = [
"ClientCredentialsAuth",
"ErrorInfo",
"ExecChunk",
"ExecResult",
"FieldViolation",
"GatewayError",
"Page",
"Pager",
"Sandbox",
@@ -49,4 +53,5 @@ __all__ = [
"WorkspaceClient",
"WorkspaceRef",
"__version__",
"from_grpc_error",
]
+149
View File
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Typed gateway errors that retain the original gRPC status and metadata."""
from __future__ import annotations
from dataclasses import dataclass
import grpc
from google.protobuf.message import DecodeError
from google.rpc import error_details_pb2, status_pb2
@dataclass(frozen=True)
class FieldViolation:
"""A request field rejected by the gateway."""
field: str
description: str
@dataclass(frozen=True)
class ErrorInfo:
"""A machine-readable error reason scoped to its producing service."""
reason: str
domain: str
metadata: dict[str, str]
class GatewayError(grpc.RpcError):
"""A gRPC failure with decoded standard details.
``raw_error`` retains the complete original exception and metadata.
``raw_status`` retains all decoded Any messages, including unknown types.
A retry delay is guidance only; it does not make a mutation safe to repeat.
Existing ``except grpc.RpcError`` handlers continue to match this type.
"""
def __init__(self, error: grpc.RpcError) -> None:
super().__init__(str(error))
self.raw_error = error
self.raw_status: status_pb2.Status | None = None
self.field_violations: tuple[FieldViolation, ...] = ()
self.error_info: ErrorInfo | None = None
self.retry_delay: float | None = None
trailing = getattr(error, "trailing_metadata", lambda: None)() or ()
for key, value in trailing:
if key != "grpc-status-details-bin" or not isinstance(value, bytes):
continue
try:
status = status_pb2.Status.FromString(value)
except DecodeError:
continue
# The transport status is authoritative. Inconsistent rich status
# must not provide misleading recovery guidance.
code = self.code()
if (
code is None
or status.code != code.value[0]
or status.message != self.details()
):
continue
self.raw_status = status
break
if self.raw_status is None:
return
violations = []
for detail in self.raw_status.details:
try:
if detail.Is(error_details_pb2.BadRequest.DESCRIPTOR):
bad_request = error_details_pb2.BadRequest()
detail.Unpack(bad_request)
violations.extend(
FieldViolation(v.field, v.description)
for v in bad_request.field_violations
)
elif detail.Is(error_details_pb2.ErrorInfo.DESCRIPTOR):
info = error_details_pb2.ErrorInfo()
detail.Unpack(info)
self.error_info = ErrorInfo(
info.reason, info.domain, dict(info.metadata)
)
elif detail.Is(error_details_pb2.RetryInfo.DESCRIPTOR):
retry = error_details_pb2.RetryInfo()
detail.Unpack(retry)
delay = retry.retry_delay
if (
retry.HasField("retry_delay")
and 0 <= delay.seconds <= 315_576_000_000
and 0 <= delay.nanos < 1_000_000_000
):
self.retry_delay = delay.seconds + delay.nanos / 1_000_000_000
except DecodeError:
continue
self.field_violations = tuple(violations)
def code(self):
"""Return the original gRPC status code."""
return getattr(self.raw_error, "code", lambda: None)()
def details(self):
"""Return the original human-readable gRPC message."""
return getattr(self.raw_error, "details", lambda: str(self.raw_error))()
def __getattr__(self, name):
return getattr(self.raw_error, name)
def from_grpc_error(error: grpc.RpcError) -> GatewayError:
"""Decode a raw RPC failure without discarding unrecognized details."""
return error if isinstance(error, GatewayError) else GatewayError(error)
class _ErrorMappingStream:
def __init__(self, call):
self._call = call
self._iterator = iter(call)
def __iter__(self):
return self
def __next__(self):
try:
return next(self._iterator)
except grpc.RpcError as error:
raise from_grpc_error(error) from error
def __getattr__(self, name):
return getattr(self._call, name)
class _ErrorMappingInterceptor(
grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor
):
def intercept_unary_unary(self, continuation, client_call_details, request):
call = continuation(client_call_details, request)
error = call.exception()
if isinstance(error, grpc.RpcError):
raise from_grpc_error(error) from error
return call
def intercept_unary_stream(self, continuation, client_call_details, request):
return _ErrorMappingStream(continuation(client_call_details, request))
def _error_mapping_channel(channel):
return grpc.intercept_channel(channel, _ErrorMappingInterceptor())
+127
View File
@@ -0,0 +1,127 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Rich error decoding and preservation across unary and streaming calls."""
from concurrent import futures
import grpc
import pytest
from google.rpc import error_details_pb2, status_pb2
from openshell.errors import GatewayError, _error_mapping_channel, from_grpc_error
def rich_status():
status = status_pb2.Status(
code=grpc.StatusCode.UNAVAILABLE.value[0], message="try later"
)
retry = error_details_pb2.RetryInfo()
retry.retry_delay.seconds = 1
retry.retry_delay.nanos = 250_000_000
for message in [
error_details_pb2.BadRequest(
field_violations=[
error_details_pb2.BadRequest.FieldViolation(
field="name", description="invalid name"
),
]
),
error_details_pb2.ErrorInfo(
reason="GATEWAY_NOT_READY",
domain="openshell.nvidia.com",
metadata={"scope": "test"},
),
retry,
]:
detail = status.details.add()
detail.Pack(message)
status.details.add(
type_url="type.googleapis.com/future.ErrorDetail", value=b"\x08\x01"
)
return status
@pytest.mark.parametrize("streaming", [False, True])
def test_maps_real_rpc_errors_without_losing_details(streaming):
status = rich_status()
def fail(_request, context):
context.set_trailing_metadata(
(
("grpc-status-details-bin", status.SerializeToString()),
("request-id", "test-correlation"),
)
)
context.abort(grpc.StatusCode.UNAVAILABLE, "try later")
server = grpc.server(futures.ThreadPoolExecutor(max_workers=1))
handler = (
grpc.unary_stream_rpc_method_handler
if streaming
else grpc.unary_unary_rpc_method_handler
)
server.add_generic_rpc_handlers(
(grpc.method_handlers_generic_handler("test.Errors", {"Fail": handler(fail)}),)
)
port = server.add_insecure_port("127.0.0.1:0")
server.start()
try:
with _error_mapping_channel(
grpc.insecure_channel(f"127.0.0.1:{port}")
) as channel:
call = channel.unary_stream if streaming else channel.unary_unary
with pytest.raises(GatewayError) as caught:
response = call("/test.Errors/Fail")(b"", timeout=5)
if streaming:
list(response)
error = caught.value
assert error.code() == grpc.StatusCode.UNAVAILABLE
assert error.details() == "try later"
assert error.field_violations[0].field == "name"
assert error.error_info is not None
assert error.error_info.reason == "GATEWAY_NOT_READY"
assert error.retry_delay == 1.25
assert error.raw_status == status
assert ("request-id", "test-correlation") in error.trailing_metadata()
assert from_grpc_error(error) is error
finally:
server.stop(0).wait()
class RawError(grpc.RpcError):
def __init__(self, details):
self._details = details
def code(self):
return grpc.StatusCode.UNAVAILABLE
def details(self):
return "try later"
def trailing_metadata(self):
return [("grpc-status-details-bin", self._details)]
def test_malformed_or_inconsistent_details_preserve_raw_error():
for payload in [
b"\xff",
status_pb2.Status(code=3, message="different").SerializeToString(),
]:
raw = RawError(payload)
error = from_grpc_error(raw)
assert error.raw_error is raw
assert error.raw_status is None
assert error.retry_delay is None
assert error.trailing_metadata() == raw.trailing_metadata()
def test_malformed_known_detail_does_not_hide_other_details():
status = rich_status()
status.details.add(
type_url="type.googleapis.com/google.rpc.BadRequest", value=b"\xff"
)
error = from_grpc_error(RawError(status.SerializeToString()))
assert error.field_violations[0].field == "name"
assert error.raw_status is not None
assert len(error.raw_status.details) == 5
+12 -7
View File
@@ -28,6 +28,7 @@ from ._proto import (
openshell_pb2,
openshell_pb2_grpc,
)
from .errors import GatewayError, _error_mapping_channel
_ClientCallDetailsBase = namedtuple(
"_ClientCallDetailsBase",
@@ -618,7 +619,9 @@ class SandboxClient:
self._channel,
_BearerAuthInterceptor(provider),
)
self._stub = openshell_pb2_grpc.OpenShellStub(self._channel)
self._stub = openshell_pb2_grpc.OpenShellStub(
_error_mapping_channel(self._channel)
)
@classmethod
def from_active_cluster(
@@ -988,9 +991,10 @@ class SandboxClient:
try:
self.get(sandbox_name, workspace=workspace)
except grpc.RpcError as exc:
call = exc.raw_error if isinstance(exc, GatewayError) else exc
if (
isinstance(exc, grpc.Call)
and exc.code() == grpc.StatusCode.NOT_FOUND
isinstance(call, grpc.Call)
and call.code() == grpc.StatusCode.NOT_FOUND
):
return
raise
@@ -1175,7 +1179,7 @@ class SandboxTemplateClient:
"""gRPC client for reusable sandbox template lifecycle operations."""
def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None:
self._stub = openshell_pb2_grpc.OpenShellStub(channel)
self._stub = openshell_pb2_grpc.OpenShellStub(_error_mapping_channel(channel))
self._timeout = timeout
@classmethod
@@ -1355,7 +1359,7 @@ class WorkspaceClient:
"""gRPC client for workspace lifecycle operations."""
def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None:
self._stub = openshell_pb2_grpc.OpenShellStub(channel)
self._stub = openshell_pb2_grpc.OpenShellStub(_error_mapping_channel(channel))
self._timeout = timeout
@classmethod
@@ -1559,9 +1563,10 @@ class Sandbox:
workspace=self._workspace,
)
except grpc.RpcError as exc:
call = exc.raw_error if isinstance(exc, GatewayError) else exc
if (
not isinstance(exc, grpc.Call)
or exc.code() != grpc.StatusCode.NOT_FOUND
not isinstance(call, grpc.Call)
or call.code() != grpc.StatusCode.NOT_FOUND
):
raise
finally:
+151
View File
@@ -0,0 +1,151 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Cleanup status handling through the curated client's real gRPC interceptor."""
from concurrent import futures
from contextlib import nullcontext
from types import SimpleNamespace
import grpc
import pytest
from openshell._proto import openshell_pb2
from openshell.errors import GatewayError
from openshell.sandbox import Sandbox, SandboxClient
@pytest.fixture
def cleanup_client():
state = SimpleNamespace(
exists=False,
code=grpc.StatusCode.NOT_FOUND,
calls=[],
closed=[],
)
def fail(context):
context.set_trailing_metadata((("request-id", "cleanup-regression"),))
context.abort(state.code, "cleanup status")
def get(request, context):
state.calls.append("GetSandbox")
assert request.name == "cleanup-test"
assert request.workspace_scope.workspace == "default"
if not state.exists:
fail(context)
response = openshell_pb2.SandboxResponse()
response.sandbox.metadata.id = "sandbox-1"
response.sandbox.metadata.name = request.name
response.sandbox.metadata.workspace = "default"
response.sandbox.status.phase = openshell_pb2.SANDBOX_PHASE_READY
return response
def delete(request, context):
state.calls.append("DeleteSandbox")
assert request.name == "cleanup-test"
assert request.workspace_scope.workspace == "default"
fail(context)
server = grpc.server(futures.ThreadPoolExecutor(max_workers=1))
server.add_generic_rpc_handlers(
(
grpc.method_handlers_generic_handler(
"openshell.v1.OpenShell",
{
"GetSandbox": grpc.unary_unary_rpc_method_handler(
get,
request_deserializer=openshell_pb2.GetSandboxRequest.FromString,
response_serializer=openshell_pb2.SandboxResponse.SerializeToString,
),
"DeleteSandbox": grpc.unary_unary_rpc_method_handler(
delete,
request_deserializer=openshell_pb2.DeleteSandboxRequest.FromString,
response_serializer=openshell_pb2.DeleteSandboxResponse.SerializeToString,
),
},
),
)
)
port = server.add_insecure_port("127.0.0.1:0")
server.start()
client = SandboxClient(
f"127.0.0.1:{port}",
timeout=5,
_bearer_close=lambda: state.closed.append(True),
)
try:
yield client, state
finally:
client.close()
server.stop(0).wait()
def assert_original_call(error, code):
assert isinstance(error, GatewayError)
assert isinstance(error.raw_error, grpc.Call)
assert error.code() == error.raw_error.code() == code
assert error.details() == "cleanup status"
assert ("request-id", "cleanup-regression") in error.trailing_metadata()
@pytest.mark.parametrize(
"code",
[
grpc.StatusCode.NOT_FOUND,
grpc.StatusCode.PERMISSION_DENIED,
grpc.StatusCode.UNAVAILABLE,
],
)
def test_wait_deleted_handles_intercepted_status(cleanup_client, code):
client, state = cleanup_client
state.code = code
# Prove this is the curated intercepted stub, not a fake raw exception.
with pytest.raises(GatewayError) as observed:
client.get("cleanup-test", workspace="default")
assert_original_call(observed.value, code)
expected = (
nullcontext()
if code == grpc.StatusCode.NOT_FOUND
else pytest.raises(GatewayError)
)
with expected as caught:
client.wait_deleted("cleanup-test", workspace="default", timeout_seconds=5)
if caught is not None:
assert_original_call(caught.value, code)
assert state.calls == ["GetSandbox", "GetSandbox"]
@pytest.mark.parametrize(
"code",
[
grpc.StatusCode.NOT_FOUND,
grpc.StatusCode.PERMISSION_DENIED,
grpc.StatusCode.UNAVAILABLE,
],
)
def test_context_cleanup_handles_intercepted_status(cleanup_client, monkeypatch, code):
client, state = cleanup_client
state.code = code
state.exists = True
monkeypatch.setattr(
SandboxClient,
"from_active_cluster",
classmethod(lambda _cls, **_kwargs: client),
)
managed = Sandbox(workspace="default", sandbox="cleanup-test")
expected = (
nullcontext()
if code == grpc.StatusCode.NOT_FOUND
else pytest.raises(GatewayError)
)
with expected as caught, managed:
state.exists = False
if caught is not None:
assert_original_call(caught.value, code)
assert state.calls == ["GetSandbox", "GetSandbox", "DeleteSandbox"]
assert state.closed == [True]
assert managed._client is None
assert managed._session is None
+9 -2
View File
@@ -10,7 +10,7 @@ When an operation returns a classified status error, inspect it directly or use
var se *v1.StatusError
if errors.As(err, &se) {
fmt.Printf("code: %s, message: %s\n", se.Code, se.Message)
// se.Details contains optional structured metadata
// se.ErrorInfo and se.FieldViolations contain optional structured details.
}
```
@@ -18,7 +18,14 @@ if errors.As(err, &se) {
|-----------|-------------------|--------------------------------------|
| `Code` | `ErrorCode` | Machine-readable error classification |
| `Message` | `string` | Human-readable error description |
| `Details` | `map[string]string` | Optional structured metadata |
| `GRPCCode` | `int32` | Original gRPC code before SDK classification |
| `FieldViolations` | `[]FieldViolation` | Rejected request fields and descriptions |
| `ErrorInfo` | `*ErrorInfo` | Server reason, domain, and metadata |
| `RetryDelay` | `*time.Duration` | Suggested minimum delay for an otherwise safe retry |
| `Cause` | `error` | Original gRPC error, including unknown details |
Use `status.FromError(err)` to inspect the complete gRPC status through the SDK's
error wrapper. A delay does not prove that a timed-out mutation was not applied.
## Predicate Functions
+6
View File
@@ -28,6 +28,12 @@ const (
// StatusError is the typed error returned by all SDK operations.
type StatusError = types.StatusError
// FieldViolation identifies an invalid request field.
type FieldViolation = types.FieldViolation
// ErrorInfo describes a server failure using a stable reason within a domain.
type ErrorInfo = types.ErrorInfo
// IsNotFound returns true if the error indicates a resource was not found.
func IsNotFound(err error) bool { return types.IsNotFound(err) }
@@ -5,7 +5,11 @@
package converter
import (
"maps"
"time"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@@ -48,9 +52,33 @@ func FromGRPCError(err error) error {
code = types.ErrorInternal
}
return &types.StatusError{
Code: code,
Message: st.Message(),
Cause: err,
result := &types.StatusError{
Code: code,
Message: st.Message(),
Cause: err,
GRPCCode: int32(st.Code()),
}
for _, detail := range st.Details() {
switch detail := detail.(type) {
case *errdetails.BadRequest:
for _, violation := range detail.GetFieldViolations() {
result.FieldViolations = append(result.FieldViolations, types.FieldViolation{
Field: violation.GetField(), Description: violation.GetDescription(),
})
}
case *errdetails.ErrorInfo:
result.ErrorInfo = &types.ErrorInfo{
Reason: detail.GetReason(), Domain: detail.GetDomain(),
Metadata: maps.Clone(detail.GetMetadata()),
}
case *errdetails.RetryInfo:
delay := detail.GetRetryDelay()
if delay != nil && delay.CheckValid() == nil && delay.GetSeconds() >= 0 && delay.GetNanos() >= 0 {
// AsDuration saturates values that exceed time.Duration's range.
value := time.Duration(delay.AsDuration())
result.RetryDelay = &value
}
}
}
return result
}
@@ -5,14 +5,55 @@ package converter
import (
"testing"
"time"
v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/wrapperspb"
)
func TestFromGRPCError_PreservesStructuredAndUnknownDetails(t *testing.T) {
st, err := status.New(codes.ResourceExhausted, "try later").WithDetails(
&errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{
{Field: "name", Description: "too long"},
}},
&errdetails.ErrorInfo{Reason: "RATE_LIMITED", Domain: "openshell.nvidia.com", Metadata: map[string]string{"key": "value"}},
&errdetails.RetryInfo{RetryDelay: durationpb.New(time.Second)},
wrapperspb.String("unrecognized detail"),
)
require.NoError(t, err)
raw := st.Err()
converted := FromGRPCError(raw)
var typed *v1.StatusError
require.ErrorAs(t, converted, &typed)
assert.Equal(t, int32(codes.ResourceExhausted), typed.GRPCCode)
assert.Equal(t, []v1.FieldViolation{{Field: "name", Description: "too long"}}, typed.FieldViolations)
require.NotNil(t, typed.ErrorInfo)
assert.Equal(t, "RATE_LIMITED", typed.ErrorInfo.Reason)
require.NotNil(t, typed.RetryDelay)
assert.Equal(t, time.Second, *typed.RetryDelay)
assert.Same(t, raw, typed.Cause)
// errors.Unwrap/status.FromError still exposes every original Any.
assert.Equal(t, st.Proto().GetDetails(), status.Convert(converted).Proto().GetDetails())
typed.ErrorInfo.Metadata["key"] = "changed"
assert.Equal(t, "value", st.Details()[1].(*errdetails.ErrorInfo).GetMetadata()["key"])
}
func TestFromGRPCError_IgnoresInvalidRetryDelay(t *testing.T) {
st, err := status.New(codes.Unavailable, "try later").WithDetails(
&errdetails.RetryInfo{RetryDelay: durationpb.New(-time.Second)},
)
require.NoError(t, err)
var typed *v1.StatusError
require.ErrorAs(t, FromGRPCError(st.Err()), &typed)
assert.Nil(t, typed.RetryDelay)
}
func TestFromGRPCError_NotFound(t *testing.T) {
grpcErr := status.Error(codes.NotFound, "sandbox not found")
err := FromGRPCError(grpcErr)
+22
View File
@@ -6,6 +6,7 @@ package types
import (
"errors"
"fmt"
"time"
)
// ErrorCode classifies SDK errors by their gRPC origin.
@@ -61,6 +62,27 @@ type StatusError struct {
Code ErrorCode
Message string
Cause error
// GRPCCode retains the exact transport code before SDK classification.
GRPCCode int32
// FieldViolations identifies invalid request fields without parsing Message.
FieldViolations []FieldViolation
// ErrorInfo is the server's machine-readable reason, domain, and metadata.
ErrorInfo *ErrorInfo
// RetryDelay is a suggested minimum delay, not proof a mutation is safe to repeat.
RetryDelay *time.Duration
}
// FieldViolation identifies an invalid request field.
type FieldViolation struct {
Field string
Description string
}
// ErrorInfo describes a server failure using a stable reason within a domain.
type ErrorInfo struct {
Reason string
Domain string
Metadata map[string]string
}
func (e *StatusError) Error() string {
+4
View File
@@ -17,6 +17,10 @@ inputs:
- ../../proto/sandbox.proto
- ../../proto/datamodel.proto
- ../../proto/options.proto
# Standard rich gRPC error messages, pinned to an immutable Google APIs commit.
- module: buf.build/googleapis/googleapis:c17df5b2beca46928cc87d5656bd5343
paths:
- google/rpc/error_details.proto
plugins:
- local: ./node_modules/.bin/protoc-gen-es
out: src/gen
+32
View File
@@ -7,8 +7,40 @@
import { Code, ConnectError } from '@connectrpc/connect';
import { describe, expect, it } from 'vitest';
import { errorCode, fromConnect, SdkError, type SdkErrorCode } from './errors.js';
import { BadRequestSchema, ErrorInfoSchema, RetryInfoSchema } from './gen/google/rpc/error_details_pb.js';
describe('fromConnect', () => {
it('decodes standard details and preserves unknown and malformed wire details', () => {
const raw = new ConnectError('try later', Code.Unavailable, { 'request-id': 'correlation' }, [
{ desc: BadRequestSchema, value: { fieldViolations: [{ field: 'name', description: 'invalid' }] } },
{
desc: ErrorInfoSchema,
value: { reason: 'GATEWAY_NOT_READY', domain: 'openshell.nvidia.com', metadata: { scope: 'test' } },
},
{ desc: RetryInfoSchema, value: { retryDelay: { seconds: 1n, nanos: 250000000 } } },
]);
raw.details.push({ type: 'google.rpc.BadRequest', value: new Uint8Array([255]) });
raw.details.push({ type: 'future.ErrorDetail', value: new Uint8Array([8, 1]) });
const error = fromConnect(raw);
expect(error.fieldViolations).toEqual([{ field: 'name', description: 'invalid' }]);
expect(error.errorInfo).toEqual({
reason: 'GATEWAY_NOT_READY',
domain: 'openshell.nvidia.com',
metadata: { scope: 'test' },
});
expect(error.retryDelayMs).toBe(1250);
expect(error.cause).toBe(raw);
expect(raw.details).toHaveLength(5);
expect(raw.metadata.get('request-id')).toBe('correlation');
});
it('does not fabricate retry guidance from the status code', () => {
expect(fromConnect(new ConnectError('temporary', Code.Unavailable)).retryDelayMs).toBeUndefined();
const raw = new ConnectError('invalid delay', Code.Unavailable, undefined, [
{ desc: RetryInfoSchema, value: { retryDelay: { seconds: -1n } } },
]);
expect(fromConnect(raw).retryDelayMs).toBeUndefined();
});
const cases: Array<[Code, SdkErrorCode]> = [
[Code.NotFound, 'not_found'],
[Code.AlreadyExists, 'already_exists'],
+35
View File
@@ -7,6 +7,20 @@
// an identical contract.
import { Code, ConnectError } from '@connectrpc/connect';
import { BadRequestSchema, ErrorInfoSchema, RetryInfoSchema } from './gen/google/rpc/error_details_pb.js';
/** A field rejected by the gateway. */
export interface FieldViolation {
field: string;
description: string;
}
/** A stable error reason scoped to its producing service. */
export interface ErrorInfo {
reason: string;
domain: string;
metadata: Record<string, string>;
}
export type SdkErrorCode =
| 'invalid_config'
@@ -32,12 +46,32 @@ export class SdkError extends Error {
readonly code: SdkErrorCode;
/** The Connect status code when this error originated from an RPC. */
readonly connectCode?: Code;
/** Decoded field violations; the complete wire details remain in cause. */
readonly fieldViolations: FieldViolation[] = [];
/** Server-provided reason, domain, and metadata, when present. */
readonly errorInfo?: ErrorInfo;
/** Suggested minimum delay in milliseconds; does not establish mutation retry safety. */
readonly retryDelayMs?: number;
constructor(code: SdkErrorCode, message: string, options?: SdkErrorOptions) {
// Format `[code] message` so errorCode() can recover the code from any Error.
super(`[${code}] ${message}`, options?.cause !== undefined ? { cause: options.cause } : undefined);
this.name = 'SdkError';
this.code = code;
if (options?.connectCode !== undefined) this.connectCode = options.connectCode;
if (options?.cause instanceof ConnectError) {
const error = options.cause;
this.fieldViolations = error
.findDetails(BadRequestSchema)
.flatMap((detail) => detail.fieldViolations.map(({ field, description }) => ({ field, description })));
const info = error.findDetails(ErrorInfoSchema)[0];
if (info) {
this.errorInfo = { reason: info.reason, domain: info.domain, metadata: { ...info.metadata } };
}
const delay = error.findDetails(RetryInfoSchema)[0]?.retryDelay;
if (delay && delay.seconds >= 0n && delay.seconds <= 315576000000n && delay.nanos >= 0 && delay.nanos < 1e9) {
this.retryDelayMs = Number(delay.seconds) * 1000 + delay.nanos / 1e6;
}
}
}
}
@@ -72,6 +106,7 @@ export function fromConnect(err: unknown): SdkError {
// Extract the `[code]` prefix from any error message.
export function errorCode(err: unknown): string | null {
if (err instanceof SdkError) return err.code;
const msg = err instanceof Error ? err.message : String(err);
const m = /^\[([a-z_]+)\]/.exec(msg);
return m ? m[1] : null;
+2 -2
View File
@@ -47,7 +47,7 @@ export type {
WorkspaceListScope,
} from './client.js';
export { errorCode, OpenShellClient, Pager, SandboxClient, SandboxTemplateClient } from './client.js';
export type { SdkErrorCode } from './errors.js';
export { SdkError } from './errors.js';
export type { ErrorInfo, FieldViolation, SdkErrorCode } from './errors.js';
export { fromConnect, SdkError } from './errors.js';
export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js';
export { clientCredentials } from './oidc.js';
+1
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
export * from './gen/datamodel_pb.js';
export * from './gen/google/rpc/error_details_pb.js';
// Advanced surface: the full generated protobuf types (messages, enums, and the
// OpenShell service descriptor) for callers using the raw escape hatch on
// OpenShellClient / SandboxClient (`.raw` and `.transport`). These are the
Generated
+14
View File
@@ -143,6 +143,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.75.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/7a/7d79170c6ce6f12e109df2b3879d6b934010cf4f99aea8de8b7e5408c174/googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2", size = 306984, upload-time = "2026-09-03T22:30:45.133Z" },
]
[[package]]
name = "grpcio"
version = "1.78.0"
@@ -307,6 +319,7 @@ name = "openshell"
source = { editable = "." }
dependencies = [
{ name = "cloudpickle" },
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "httpx" },
{ name = "protobuf" },
@@ -329,6 +342,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cloudpickle", specifier = ">=3.0" },
{ name = "googleapis-common-protos", specifier = ">=1.63" },
{ name = "grpcio", specifier = ">=1.60" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "protobuf", specifier = ">=4.25" },