use crate::prelude::*;
#[derive(Debug, Error, miette::Diagnostic)]
pub(crate) enum MeshError {
#[error("{0}")]
Message(String),
#[error("{message}")]
Auth {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
},
}
impl MeshError {
pub(crate) fn auth(message: impl Into<String>) -> Self {
MeshError::Auth {
message: message.into(),
source: None,
}
}
pub(crate) fn auth_with_source(message: impl Into<String>, source: miette::Report) -> Self {
MeshError::Auth {
message: message.into(),
source: Some(source.into()),
}
}
}
pub(crate) fn err<T>(message: impl Into<String>) -> Result<T> {
Err(MeshError::Message(message.into()).into())
}
pub(crate) fn auth_err<T>(message: impl Into<String>) -> Result<T> {
Err(MeshError::auth(message).into())
}
pub(crate) fn not_logged_in() -> MeshError {
MeshError::auth("not logged in. Run `mesh login` first.")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ErrorClass {
Auth,
Network,
Http,
Other,
}
impl ErrorClass {
pub(crate) fn exit_code(self) -> u8 {
match self {
ErrorClass::Other => 1,
ErrorClass::Auth => 3,
ErrorClass::Network => 4,
ErrorClass::Http => 5,
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
ErrorClass::Auth => "auth",
ErrorClass::Network => "network",
ErrorClass::Http => "http",
ErrorClass::Other => "other",
}
}
}
pub(crate) fn classify_report(report: &miette::Report) -> ErrorClass {
let mut class = ErrorClass::Other;
for cause in report.chain() {
if matches!(
cause.downcast_ref::<MeshError>(),
Some(MeshError::Auth { .. })
) {
return ErrorClass::Auth;
}
if class != ErrorClass::Other {
continue;
}
class = match cause.downcast_ref::<RequestError>() {
Some(RequestError::Status { status, .. }) => {
if *status == StatusCode::UNAUTHORIZED || *status == StatusCode::FORBIDDEN {
return ErrorClass::Auth;
}
ErrorClass::Http
}
Some(RequestError::Transport(_)) => ErrorClass::Network,
None => ErrorClass::Other,
};
}
class
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ErrorFormat {
Human,
Json,
}
pub(crate) fn error_format_from_matches(matches: &ArgMatches) -> ErrorFormat {
let value = matches
.try_get_one::<String>("error-format")
.ok()
.flatten()
.cloned()
.or_else(|| std::env::var("MESH_ERROR_FORMAT").ok());
match value.as_deref() {
Some("json") => ErrorFormat::Json,
_ => ErrorFormat::Human,
}
}
pub(crate) fn error_envelope(report: &miette::Report) -> Value {
let class = classify_report(report);
let chain: Vec<Value> = report
.chain()
.skip(1)
.map(|cause| Value::String(cause.to_string()))
.collect();
let mut http = Value::Null;
let mut retry_after_seconds = Value::Null;
for cause in report.chain() {
if let Some(RequestError::Status {
status,
url,
body,
retry_after,
}) = cause.downcast_ref::<RequestError>()
{
http = json!({
"status": status.as_u16(),
"url": url.as_str(),
"body": body,
});
if let Some(retry_after) = retry_after {
retry_after_seconds = json!(retry_after.as_secs());
}
break;
}
}
json!({
"error": {
"message": report.to_string(),
"chain": chain,
"class": class.as_str(),
"exit_code": class.exit_code(),
"http": http,
"retry_after_seconds": retry_after_seconds,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn status_report(status: StatusCode, retry_after: Option<Duration>) -> miette::Report {
RequestError::Status {
status,
url: Url::parse("https://mcp.example.test/tools/v2/get-contact").unwrap(),
body: json!({"detail": "boom"}),
retry_after,
}
.into()
}
#[test]
fn classify_report_maps_http_statuses() {
assert_eq!(
classify_report(&status_report(StatusCode::INTERNAL_SERVER_ERROR, None)),
ErrorClass::Http
);
assert_eq!(
classify_report(&status_report(StatusCode::NOT_FOUND, None)),
ErrorClass::Http
);
assert_eq!(
classify_report(&status_report(StatusCode::UNAUTHORIZED, None)),
ErrorClass::Auth
);
assert_eq!(
classify_report(&status_report(StatusCode::FORBIDDEN, None)),
ErrorClass::Auth
);
assert_eq!(ErrorClass::Http.exit_code(), 5);
assert_eq!(ErrorClass::Auth.exit_code(), 3);
}
#[test]
fn classify_report_finds_typed_errors_behind_wrap_err() {
let report = Err::<(), miette::Report>(status_report(StatusCode::BAD_GATEWAY, None))
.wrap_err("calling get-contact")
.unwrap_err();
assert_eq!(report.to_string(), "calling get-contact");
assert_eq!(classify_report(&report), ErrorClass::Http);
}
#[test]
fn classify_report_maps_auth_markers_and_plain_messages() {
let auth: miette::Report = MeshError::auth("not logged in").into();
assert_eq!(classify_report(&auth), ErrorClass::Auth);
let not_logged_in: miette::Report = not_logged_in().into();
assert_eq!(classify_report(¬_logged_in), ErrorClass::Auth);
let plain = err::<()>("plain failure").unwrap_err();
assert_eq!(classify_report(&plain), ErrorClass::Other);
assert_eq!(ErrorClass::Other.exit_code(), 1);
}
#[test]
fn classify_report_prefers_the_auth_marker_over_its_http_source() {
let inner = status_report(StatusCode::INTERNAL_SERVER_ERROR, None);
let report: miette::Report =
MeshError::auth_with_source("token refresh failed; run `mesh login` again", inner)
.into();
assert_eq!(classify_report(&report), ErrorClass::Auth);
}
#[tokio::test]
async fn classify_report_maps_transport_errors_to_network() {
let error = HttpClient::new()
.get("http://127.0.0.1:1/")
.timeout(Duration::from_secs(5))
.send()
.await
.expect_err("connect to loopback port 1 must fail");
let report = Err::<(), miette::Report>(RequestError::Transport(error).into())
.wrap_err("requesting me.sh")
.unwrap_err();
assert_eq!(classify_report(&report), ErrorClass::Network);
assert_eq!(ErrorClass::Network.exit_code(), 4);
}
#[test]
fn error_envelope_reports_http_failures_with_chain_in_order() {
let report = Err::<(), miette::Report>(status_report(
StatusCode::INTERNAL_SERVER_ERROR,
Some(Duration::from_secs(7)),
))
.wrap_err("inner context")
.wrap_err("outer context")
.unwrap_err();
let envelope = error_envelope(&report);
let error = &envelope["error"];
assert_eq!(error["message"], json!("outer context"));
let chain = error["chain"].as_array().expect("chain array");
assert_eq!(chain.len(), 2);
assert_eq!(chain[0], json!("inner context"));
assert!(
chain[1]
.as_str()
.unwrap()
.starts_with("HTTP 500 Internal Server Error from"),
"got: {}",
chain[1]
);
assert_eq!(error["class"], json!("http"));
assert_eq!(error["exit_code"], json!(5));
assert_eq!(error["http"]["status"], json!(500));
assert_eq!(
error["http"]["url"],
json!("https://mcp.example.test/tools/v2/get-contact")
);
assert_eq!(error["http"]["body"], json!({"detail": "boom"}));
assert_eq!(error["retry_after_seconds"], json!(7));
}
#[test]
fn error_envelope_reports_auth_failures_without_http_details() {
let report: miette::Report = not_logged_in().into();
let envelope = error_envelope(&report);
let error = &envelope["error"];
assert_eq!(
error["message"],
json!("not logged in. Run `mesh login` first.")
);
assert_eq!(error["chain"], json!([]));
assert_eq!(error["class"], json!("auth"));
assert_eq!(error["exit_code"], json!(3));
assert_eq!(error["http"], Value::Null);
assert_eq!(error["retry_after_seconds"], Value::Null);
}
#[test]
fn error_envelope_display_is_a_single_line() {
let report = status_report(StatusCode::INTERNAL_SERVER_ERROR, None);
let line = error_envelope(&report).to_string();
assert!(!line.contains('\n'), "envelope must be one line: {line}");
assert!(line.starts_with("{\"error\":{"), "got: {line}");
}
#[test]
fn error_format_reads_the_global_flag_and_defaults_to_human() {
let matches = build_cli().get_matches_from(["mesh", "--error-format", "json", "whoami"]);
assert_eq!(error_format_from_matches(&matches), ErrorFormat::Json);
if std::env::var_os("MESH_ERROR_FORMAT").is_some() {
return;
}
let matches = clap::Command::new("test").get_matches_from(["test"]);
assert_eq!(error_format_from_matches(&matches), ErrorFormat::Human);
let matches = build_cli().get_matches_from(["mesh", "whoami"]);
assert_eq!(error_format_from_matches(&matches), ErrorFormat::Human);
}
}