use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("gRPC error: {message} — {source}")]
GrpcError {
message: String,
#[source]
source: Box<tonic::Status>,
},
#[error("gRPC transport error: {message} — {source}")]
TransportError {
message: String,
#[source]
source: Box<tonic::transport::Error>,
},
#[error("not found: {path}")]
NotFound { path: String },
#[error("already exists: {path}")]
AlreadyExists { path: String },
#[error("permission denied: {message}")]
PermissionDenied { message: String },
#[error("invalid argument: {message}")]
InvalidArgument { message: String },
#[error("missing field in response: {field}")]
MissingField { field: String },
#[error("block IO error: {message}")]
BlockIoError { message: String },
#[error("no worker available: {message}")]
NoWorkerAvailable { message: String },
#[error("master unavailable: {message}")]
MasterUnavailable { message: String },
#[error("config error: {message}")]
ConfigError { message: String },
#[error("internal error: {message}")]
Internal {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("file is incomplete: {message}")]
FileIncomplete { message: String },
#[error("directory is not empty: {message}")]
DirectoryNotEmpty { message: String },
#[error("path is a directory: {path}")]
OpenDirectory { path: String },
#[error("invalid path: {path}")]
InvalidPath { path: String },
#[error("authentication failed: {message}")]
AuthenticationFailed { message: String },
}
impl From<tonic::Status> for Error {
fn from(status: tonic::Status) -> Self {
match status.code() {
tonic::Code::NotFound => Error::NotFound {
path: status.message().to_string(),
},
tonic::Code::AlreadyExists => Error::AlreadyExists {
path: status.message().to_string(),
},
tonic::Code::PermissionDenied => Error::PermissionDenied {
message: status.message().to_string(),
},
tonic::Code::InvalidArgument => Error::InvalidArgument {
message: status.message().to_string(),
},
tonic::Code::Unauthenticated => Error::AuthenticationFailed {
message: status.message().to_string(),
},
tonic::Code::FailedPrecondition => {
let msg = status.message();
if msg.contains("is not empty") {
Error::DirectoryNotEmpty {
message: msg.to_string(),
}
} else if msg.contains("is incomplete") {
Error::FileIncomplete {
message: msg.to_string(),
}
} else if msg.contains("Is a directory") {
Error::OpenDirectory {
path: msg.to_string(),
}
} else {
Error::GrpcError {
message: format!("[{}] {}", status.code(), msg),
source: Box::new(status),
}
}
}
_ => Error::GrpcError {
message: format!("[{}] {}", status.code(), status.message()),
source: Box::new(status),
},
}
}
}
impl From<tonic::transport::Error> for Error {
fn from(err: tonic::transport::Error) -> Self {
Error::TransportError {
message: err.to_string(),
source: Box::new(err),
}
}
}
impl Error {
pub fn is_retriable(&self) -> bool {
match self {
Error::GrpcError { source, .. } => matches!(
source.as_ref().code(),
tonic::Code::Unavailable | tonic::Code::DeadlineExceeded | tonic::Code::Aborted
),
Error::TransportError { .. } => true,
Error::AuthenticationFailed { .. } => true,
_ => false,
}
}
pub fn is_not_found(&self) -> bool {
matches!(self, Error::NotFound { .. })
}
pub fn is_already_exists(&self) -> bool {
matches!(self, Error::AlreadyExists { .. })
}
pub fn is_file_incomplete(&self) -> bool {
matches!(self, Error::FileIncomplete { .. })
}
pub fn is_directory_not_empty(&self) -> bool {
matches!(self, Error::DirectoryNotEmpty { .. })
}
pub fn is_authentication_failed(&self) -> bool {
matches!(self, Error::AuthenticationFailed { .. })
}
pub fn is_unavailable(&self) -> bool {
match self {
Error::GrpcError { source, .. } => {
matches!(source.as_ref().code(), tonic::Code::Unavailable)
}
Error::TransportError { .. } => true,
_ => false,
}
}
pub fn is_authentication_error(&self) -> bool {
self.is_authentication_failed()
}
pub fn is_access_denied(&self) -> bool {
matches!(
self,
Error::PermissionDenied { .. } | Error::AuthenticationFailed { .. }
)
}
pub fn missing_field(field: impl Into<String>) -> Self {
Error::MissingField {
field: field.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unauthenticated_maps_to_authentication_failed() {
let status = tonic::Status::unauthenticated("token expired");
let err = Error::from(status);
assert!(err.is_authentication_failed());
assert!(err.is_retriable());
}
#[test]
fn test_failed_precondition_directory_not_empty() {
let status =
tonic::Status::failed_precondition("/foo/bar is not empty, cannot delete recursively");
let err = Error::from(status);
assert!(
err.is_directory_not_empty(),
"expected DirectoryNotEmpty, got {:?}",
err
);
}
#[test]
fn test_failed_precondition_file_incomplete() {
let status = tonic::Status::failed_precondition("/tmp/partial.parquet is incomplete");
let err = Error::from(status);
assert!(
err.is_file_incomplete(),
"expected FileIncomplete, got {:?}",
err
);
}
#[test]
fn test_failed_precondition_is_directory() {
let status = tonic::Status::failed_precondition("/data/dir Is a directory");
let err = Error::from(status);
assert!(
matches!(err, Error::OpenDirectory { .. }),
"expected OpenDirectory, got {:?}",
err
);
}
#[test]
fn test_failed_precondition_unknown_falls_through_to_grpc_error() {
let status = tonic::Status::failed_precondition("some other precondition failure");
let err = Error::from(status);
assert!(
matches!(err, Error::GrpcError { .. }),
"expected GrpcError fallthrough, got {:?}",
err
);
}
#[test]
fn test_not_found_helper() {
let status = tonic::Status::not_found("/missing");
let err = Error::from(status);
assert!(err.is_not_found());
}
#[test]
fn test_is_access_denied_covers_both_variants() {
let perm = Error::PermissionDenied {
message: "no".to_string(),
};
let auth = Error::AuthenticationFailed {
message: "expired".to_string(),
};
assert!(perm.is_access_denied());
assert!(auth.is_access_denied());
}
#[test]
fn test_auth_retry_error_classification() {
let auth_err = Error::AuthenticationFailed {
message: "SASL token expired".to_string(),
};
assert!(auth_err.is_authentication_failed());
assert!(auth_err.is_retriable());
assert!(!auth_err.is_unavailable());
let unavailable_err = Error::GrpcError {
message: "connection refused".to_string(),
source: Box::new(tonic::Status::unavailable("worker unreachable")),
};
assert!(
!unavailable_err.is_authentication_failed(),
"Unavailable must NOT be auth-failed"
);
assert!(unavailable_err.is_retriable());
let deadline_err = Error::GrpcError {
message: "deadline exceeded".to_string(),
source: Box::new(tonic::Status::deadline_exceeded("timed out")),
};
assert!(
!deadline_err.is_authentication_failed(),
"DeadlineExceeded must NOT be auth-failed"
);
assert!(deadline_err.is_retriable());
let not_found = Error::NotFound {
path: "/foo".to_string(),
};
assert!(!not_found.is_authentication_failed());
assert!(!not_found.is_retriable());
let internal = Error::Internal {
message: "oops".to_string(),
source: None,
};
assert!(!internal.is_authentication_failed());
assert!(!internal.is_retriable());
}
#[test]
fn test_grpc_unauthenticated_maps_correctly_for_auth_retry() {
let status = tonic::Status::unauthenticated("SASL handshake rejected");
let err = Error::from(status);
assert!(
err.is_authentication_failed(),
"UNAUTHENTICATED must map to AuthenticationFailed for auth-retry to work"
);
assert!(
err.is_retriable(),
"AuthenticationFailed must be retriable so the reader retries after reconnect"
);
}
}