mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
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:
Generated
+1
@@ -4057,6 +4057,7 @@ dependencies = [
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
"tonic-prost-build",
|
||||
"tonic-types",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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",
|
||||
]
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user