use std::error::Error as StdError;
use thiserror::Error;
use tonic::{Code, Status};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GrpcCode {
Ok,
Cancelled,
Unknown,
InvalidArgument,
DeadlineExceeded,
NotFound,
AlreadyExists,
PermissionDenied,
ResourceExhausted,
FailedPrecondition,
Aborted,
OutOfRange,
Unimplemented,
Internal,
Unavailable,
DataLoss,
Unauthenticated,
}
impl GrpcCode {
pub fn as_str(self) -> &'static str {
match self {
GrpcCode::Ok => "ok",
GrpcCode::Cancelled => "cancelled",
GrpcCode::Unknown => "unknown",
GrpcCode::InvalidArgument => "invalid_argument",
GrpcCode::DeadlineExceeded => "deadline_exceeded",
GrpcCode::NotFound => "not_found",
GrpcCode::AlreadyExists => "already_exists",
GrpcCode::PermissionDenied => "permission_denied",
GrpcCode::ResourceExhausted => "resource_exhausted",
GrpcCode::FailedPrecondition => "failed_precondition",
GrpcCode::Aborted => "aborted",
GrpcCode::OutOfRange => "out_of_range",
GrpcCode::Unimplemented => "unimplemented",
GrpcCode::Internal => "internal",
GrpcCode::Unavailable => "unavailable",
GrpcCode::DataLoss => "data_loss",
GrpcCode::Unauthenticated => "unauthenticated",
}
}
}
impl std::fmt::Display for GrpcCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl From<Code> for GrpcCode {
fn from(code: Code) -> GrpcCode {
match code {
Code::Ok => GrpcCode::Ok,
Code::Cancelled => GrpcCode::Cancelled,
Code::Unknown => GrpcCode::Unknown,
Code::InvalidArgument => GrpcCode::InvalidArgument,
Code::DeadlineExceeded => GrpcCode::DeadlineExceeded,
Code::NotFound => GrpcCode::NotFound,
Code::AlreadyExists => GrpcCode::AlreadyExists,
Code::PermissionDenied => GrpcCode::PermissionDenied,
Code::ResourceExhausted => GrpcCode::ResourceExhausted,
Code::FailedPrecondition => GrpcCode::FailedPrecondition,
Code::Aborted => GrpcCode::Aborted,
Code::OutOfRange => GrpcCode::OutOfRange,
Code::Unimplemented => GrpcCode::Unimplemented,
Code::Internal => GrpcCode::Internal,
Code::Unavailable => GrpcCode::Unavailable,
Code::DataLoss => GrpcCode::DataLoss,
Code::Unauthenticated => GrpcCode::Unauthenticated,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransportKind {
Timeout,
Connection,
}
impl std::fmt::Display for TransportKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
TransportKind::Timeout => "timeout",
TransportKind::Connection => "connection",
})
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SailError {
#[error("{message}")]
Config {
message: String,
},
#[error("{message}")]
Internal {
message: String,
},
#[error("{kind} error: {message}")]
Transport {
kind: TransportKind,
message: String,
#[source]
source: Option<Box<dyn StdError + Send + Sync + 'static>>,
},
#[error("{message}")]
Creation {
message: String,
status: u16,
body: serde_json::Value,
},
#[error("{message}")]
NotFound {
message: String,
},
#[error("{message}")]
PermissionDenied {
message: String,
},
#[error("{message}")]
FileNotFound {
message: String,
},
#[error("{message}")]
InvalidArgument {
message: String,
},
#[error("{message}")]
Api {
status: u16,
message: String,
body: serde_json::Value,
},
#[error("{message}")]
ExecRequestNotFound {
message: String,
},
#[error("{message}")]
Terminated {
message: String,
},
#[error("{message}")]
WorkerLost {
message: String,
},
#[error("{message}")]
ExecInterrupted {
retryable: bool,
message: String,
},
#[error("{message}")]
ExecCanceled {
message: String,
},
#[error("{message}")]
BrokenPipe {
message: String,
},
#[error("{code}: {detail}")]
Execution {
code: GrpcCode,
detail: String,
},
}
fn transport_failure(status: &Status) -> Option<SailError> {
status.source()?;
let message = status.message().to_string();
let lower = message.to_lowercase();
let kind = if status.code() == Code::DeadlineExceeded
|| lower.contains("timed out")
|| lower.contains("timeout")
{
TransportKind::Timeout
} else {
TransportKind::Connection
};
Some(SailError::Transport {
kind,
message,
source: Some(Box::new(status.clone())),
})
}
impl SailError {
pub fn from_exec_status(status: &Status) -> SailError {
if let Some(err) = transport_failure(status) {
return err;
}
let detail = if status.message().is_empty() {
"unknown sailbox exec error"
} else {
status.message()
};
if status.code() == Code::NotFound {
if detail.contains("exec request") {
return SailError::ExecRequestNotFound {
message: detail.to_string(),
};
}
return SailError::Terminated {
message: format!(
"{detail}; this is likely because this Sailbox is no longer running"
),
};
}
SailError::Execution {
code: status.code().into(),
detail: detail.to_string(),
}
}
pub fn from_rpc_status(status: &Status) -> SailError {
if let Some(err) = transport_failure(status) {
return err;
}
let detail = if status.message().is_empty() {
"unknown worker-proxy error"
} else {
status.message()
};
match status.code() {
Code::NotFound => SailError::NotFound {
message: detail.to_string(),
},
Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
message: detail.to_string(),
},
code => SailError::Execution {
code: code.into(),
detail: detail.to_string(),
},
}
}
pub fn from_file_rpc_status(status: &Status) -> SailError {
if let Some(err) = transport_failure(status) {
return err;
}
let detail = if status.message().is_empty() {
"unknown sailbox file error"
} else {
status.message()
};
match status.code() {
Code::NotFound => SailError::FileNotFound {
message: detail.to_string(),
},
Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
message: detail.to_string(),
},
Code::InvalidArgument => SailError::InvalidArgument {
message: detail.to_string(),
},
code => SailError::Execution {
code: code.into(),
detail: detail.to_string(),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_found_without_exec_request_is_terminated() {
let err = SailError::from_exec_status(&Status::not_found("sailbox sb_x not found"));
match err {
SailError::Terminated { message } => {
assert!(message.contains("no longer running"));
}
other => panic!("expected Terminated, got {other:?}"),
}
}
#[test]
fn not_found_with_exec_request_is_request_not_found() {
let err = SailError::from_exec_status(&Status::not_found("exec request er_x not found"));
assert!(matches!(err, SailError::ExecRequestNotFound { .. }));
}
#[test]
fn other_codes_carry_structured_grpc_code() {
let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
match err {
SailError::Execution { code, detail } => {
assert_eq!(code, GrpcCode::Unavailable);
assert_eq!(detail, "upstream draining");
}
other => panic!("expected Execution, got {other:?}"),
}
}
#[test]
fn empty_detail_uses_default() {
let err = SailError::from_exec_status(&Status::internal(""));
match err {
SailError::Execution { code, detail } => {
assert_eq!(code, GrpcCode::Internal);
assert_eq!(detail, "unknown sailbox exec error");
}
other => panic!("expected Execution, got {other:?}"),
}
}
#[test]
fn client_transport_failure_maps_to_transport() {
let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
let status = Status::from_error(Box::new(io));
match SailError::from_rpc_status(&status) {
SailError::Transport { kind, source, .. } => {
assert_eq!(kind, TransportKind::Connection);
assert!(source.is_some());
}
other => panic!("expected Transport, got {other:?}"),
}
}
#[test]
fn server_sent_unavailable_stays_in_status_taxonomy() {
let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
assert!(matches!(err, SailError::Execution { .. }));
}
#[test]
fn rpc_and_file_status_taxonomies_diverge_where_intended() {
use assert_matches::assert_matches;
assert_matches!(
SailError::from_rpc_status(&Status::not_found("x")),
SailError::NotFound { .. }
);
assert_matches!(
SailError::from_rpc_status(&Status::permission_denied("x")),
SailError::PermissionDenied { .. }
);
assert_matches!(
SailError::from_rpc_status(&Status::unauthenticated("x")),
SailError::PermissionDenied { .. }
);
assert_matches!(
SailError::from_rpc_status(&Status::invalid_argument("x")),
SailError::Execution {
code: GrpcCode::InvalidArgument,
..
}
);
assert_matches!(
SailError::from_file_rpc_status(&Status::not_found("x")),
SailError::FileNotFound { .. }
);
assert_matches!(
SailError::from_file_rpc_status(&Status::invalid_argument("x")),
SailError::InvalidArgument { .. }
);
assert_matches!(
SailError::from_file_rpc_status(&Status::permission_denied("x")),
SailError::PermissionDenied { .. }
);
assert_matches!(
SailError::from_file_rpc_status(&Status::internal("x")),
SailError::Execution {
code: GrpcCode::Internal,
..
}
);
}
#[test]
fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
let table = [
(Code::Ok, "ok"),
(Code::Cancelled, "cancelled"),
(Code::Unknown, "unknown"),
(Code::InvalidArgument, "invalid_argument"),
(Code::DeadlineExceeded, "deadline_exceeded"),
(Code::NotFound, "not_found"),
(Code::AlreadyExists, "already_exists"),
(Code::PermissionDenied, "permission_denied"),
(Code::ResourceExhausted, "resource_exhausted"),
(Code::FailedPrecondition, "failed_precondition"),
(Code::Aborted, "aborted"),
(Code::OutOfRange, "out_of_range"),
(Code::Unimplemented, "unimplemented"),
(Code::Internal, "internal"),
(Code::Unavailable, "unavailable"),
(Code::DataLoss, "data_loss"),
(Code::Unauthenticated, "unauthenticated"),
];
for (code, expected) in table {
assert_eq!(GrpcCode::from(code).as_str(), expected);
}
}
}