//! Error types surfaced by the Rust client. //! //! [`Error`] is the umbrella enum returned by every async wrapper. It //! classifies `tonic::Status` codes (auth, timeout, cancellation) and folds //! gateway protocol failures and command-level rejections into structured //! variants. Credentials embedded in status messages are scrubbed before the //! message reaches a caller. use thiserror::Error as ThisError; use tonic::Code; use crate::generated::mxaccess_gateway::v1::{MxCommandReply, ProtocolStatus, ProtocolStatusCode}; /// Top-level error type returned by the Rust client wrappers. /// /// The variants distinguish transport/setup failures, classified gRPC status /// codes, gateway protocol-level failures (`OpenSession`, `CloseSession`), /// and command-level rejections that surface a populated [`MxCommandReply`]. #[derive(Debug, ThisError)] pub enum Error { /// Endpoint URL could not be parsed or its TLS material could not be /// loaded. #[error("invalid gateway endpoint `{endpoint}`: {detail}")] InvalidEndpoint { /// Endpoint string supplied by the caller. endpoint: String, /// Human-readable explanation of the parse/load failure. detail: String, }, /// A caller-provided argument failed local validation before any RPC /// was dispatched (for example, a bulk command exceeding the size cap). #[error("invalid argument `{name}`: {detail}")] InvalidArgument { /// Name of the offending argument. name: String, /// Reason the argument was rejected. detail: String, }, /// Tonic transport-level failure (DNS, connect, TLS handshake). #[error("gateway transport error: {0}")] Transport(#[from] tonic::transport::Error), /// Server returned `Unauthenticated` — the API key was missing or /// rejected. #[error("authentication failed: {message}")] Authentication { /// Redacted server-supplied detail message. message: String, /// Original `tonic::Status` for callers that need the full context. #[source] status: Box, }, /// Server returned `PermissionDenied` — the API key is valid but lacks /// the required scope. #[error("authorization failed: {message}")] Authorization { /// Redacted server-supplied detail message. message: String, /// Original `tonic::Status`. #[source] status: Box, }, /// Server returned `DeadlineExceeded`. Usually the per-call deadline /// configured via [`crate::options::ClientOptions::with_call_timeout`]. #[error("gateway call timed out: {message}")] Timeout { /// Redacted server-supplied detail message. message: String, /// Original `tonic::Status`. #[source] status: Box, }, /// Server (or client) cancelled the call before a reply was produced. #[error("gateway call cancelled: {message}")] Cancelled { /// Redacted server-supplied detail message. message: String, /// Original `tonic::Status`. #[source] status: Box, }, /// Any other `tonic::Status` that did not match a more specific variant. #[error("gateway status error: {0}")] Status(Box), /// Gateway accepted the call but the worker reply carried a non-OK /// protocol status. The wrapped [`CommandError`] preserves the full /// reply so callers can inspect the worker's status payload. #[error("gateway command failed: {0}")] Command(#[from] Box), /// Protocol-level operation (open/close session) returned a non-OK /// [`ProtocolStatus`] envelope. #[error("gateway {operation} failed: {code:?}: {message}")] ProtocolStatus { /// Operation name, e.g. `"open session"`. operation: &'static str, /// Decoded protocol status code from the server. code: ProtocolStatusCode, /// Detail message from the server. message: String, }, /// Gateway returned an `Ok` protocol status but the reply lacked the /// expected typed payload (or carried the wrong payload arm). Distinct /// from [`Error::ProtocolStatus`] because the protocol-level envelope /// itself succeeded — the corruption is in the payload shape. #[error("gateway returned a malformed reply: {detail}")] MalformedReply { /// Human-readable description of what was missing or mismatched. detail: String, }, /// Server returned `Unavailable` or `ResourceExhausted` — classify /// transient failures separately from the catch-all [`Error::Status`]. #[error("gateway unavailable: {message}")] Unavailable { /// Redacted server-supplied detail message. message: String, /// Original `tonic::Status`. #[source] status: Box, }, } /// Wrapper around an [`MxCommandReply`] whose `protocol_status` reported a /// non-OK code. /// /// The wrapper is heap-allocated inside [`Error::Command`] to keep the /// containing enum small. Callers can recover the reply with /// [`CommandError::reply`] or [`CommandError::into_reply`]. #[derive(Clone, Debug)] pub struct CommandError { reply: MxCommandReply, } impl CommandError { /// Wrap an already-failed command reply. pub fn new(reply: MxCommandReply) -> Self { Self { reply } } /// Borrow the underlying reply (correlation id, status, payload). pub fn reply(&self) -> &MxCommandReply { &self.reply } /// Consume the error and return the underlying reply. pub fn into_reply(self) -> MxCommandReply { self.reply } } impl std::fmt::Display for CommandError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let status = self.reply.protocol_status.as_ref(); let code = status .and_then(|status| ProtocolStatusCode::try_from(status.code).ok()) .unwrap_or(ProtocolStatusCode::Unspecified); let message = status.map(|status| status.message.as_str()).unwrap_or(""); if message.is_empty() { write!(formatter, "{code:?}") } else { write!(formatter, "{code:?}: {message}") } } } impl std::error::Error for CommandError {} impl From for Error { fn from(status: tonic::Status) -> Self { let message = redact_credentials(status.message()); match status.code() { Code::Unauthenticated => Self::Authentication { message, status: Box::new(status), }, Code::PermissionDenied => Self::Authorization { message, status: Box::new(status), }, Code::DeadlineExceeded => Self::Timeout { message, status: Box::new(status), }, Code::Cancelled => Self::Cancelled { message, status: Box::new(status), }, Code::Unavailable | Code::ResourceExhausted => Self::Unavailable { message, status: Box::new(status), }, _ => Self::Status(Box::new(status)), } } } /// Promote a non-OK protocol status carried inside an [`MxCommandReply`] /// to an [`Error::Command`]. /// /// # Errors /// /// Returns [`Error::Command`] when `reply.protocol_status` is missing or /// reports any code other than [`ProtocolStatusCode::Ok`]. pub fn ensure_command_success(reply: MxCommandReply) -> Result { let code = reply .protocol_status .as_ref() .and_then(|status| ProtocolStatusCode::try_from(status.code).ok()) .unwrap_or(ProtocolStatusCode::Unspecified); if code == ProtocolStatusCode::Ok { Ok(reply) } else { Err(Box::new(CommandError::new(reply)).into()) } } /// Validate a [`ProtocolStatus`] envelope returned by an open/close-session /// reply. /// /// # Errors /// /// Returns [`Error::ProtocolStatus`] tagged with `operation` when `status` /// is missing or reports any code other than [`ProtocolStatusCode::Ok`]. pub fn ensure_protocol_success( operation: &'static str, status: Option<&ProtocolStatus>, ) -> Result<(), Error> { let code = status .and_then(|status| ProtocolStatusCode::try_from(status.code).ok()) .unwrap_or(ProtocolStatusCode::Unspecified); if code == ProtocolStatusCode::Ok { Ok(()) } else { Err(Error::ProtocolStatus { operation, code, message: status .map(|status| status.message.clone()) .unwrap_or_default(), }) } } fn redact_credentials(message: &str) -> String { message .split_whitespace() .map(|part| { if part.starts_with("mxgw_") || part.eq_ignore_ascii_case("bearer") { "" } else { part } }) .collect::>() .join(" ") } #[cfg(test)] mod tests { use tonic::{Code, Status}; use super::Error; #[test] fn classifies_authentication_status() { let error = Error::from(Status::new( Code::Unauthenticated, "invalid API key mxgw_visible_secret", )); let message = error.to_string(); assert!(matches!(error, Error::Authentication { .. })); assert!(message.contains("")); assert!(!message.contains("visible_secret")); } }