/* * Author: Ruben Fiszel * Copyright: Windmill Labs, Inc 2022 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ use std::panic::Location; use axum::body::Body; use axum::response::Response; use axum::{response::IntoResponse, response::Json}; use hyper::StatusCode; use sqlx::migrate::MigrateError; use thiserror::Error; use tokio::io; pub type Result = std::result::Result; pub type JsonResult = std::result::Result, Error>; #[derive(Debug, Error)] pub enum Error { #[error("Bad gateway: {0}")] BadGateway(String), #[error("Bad config: {0}")] BadConfig(String), #[error("Connecting to database: {0}")] ConnectingToDatabase(String), #[error("Not found: {0}")] NotFound(String), #[error("Not authorized: {0}")] NotAuthorized(String), #[error("Metric not found: {0}")] MetricNotFound(String), #[error("Permission denied: {0}")] PermissionDenied(String), #[error("Require Admin privileges for {0}")] RequireAdmin(String), #[error("{0}")] ExecutionErr(String), #[error("{0}")] ResultTooLarge(String), #[error("IoErr: {error:#} @{location:#}")] IoErr { error: io::Error, location: String }, #[error("Utf8Err: {error:#} @{location:#}")] Utf8Err { error: std::string::FromUtf8Error, location: String }, #[error("UuidErr: {error:#} @{location:#}")] UuidErr { error: uuid::Error, location: String }, #[error("SqlErr: {error:#} @{location:#}")] SqlErr { error: sqlx::Error, location: String }, #[error("SerdeJson: {error:#} @{location:#}")] SerdeJson { error: serde_json::Error, location: String }, #[error("Bad request: {0}")] BadRequest(String), #[error("Quota exceeded: {0}")] QuotaExceeded(String), #[error("Internal: {0}")] InternalErr(String), #[error("Internal: {message} @{location}")] InternalErrLoc { message: String, location: String }, #[error("Internal: {0}: {1}")] InternalErrAt(&'static Location<'static>, String), #[error("HexErr: {error:#} @{location:#}")] HexErr { error: hex::FromHexError, location: String }, #[error("Migrating database: {0}")] DatabaseMigration(#[from] MigrateError), #[error("Non-zero exit status for {0}: {1}")] ExitStatus(String, i32), #[error("ExecutionRawError: {0}")] ExecutionRawError(Box), #[error("Error: {error:#} @{location:#}")] Anyhow { error: anyhow::Error, location: String }, #[error("{}", format_json_err_message(.0))] JsonErr(serde_json::Value), #[error("{0}")] AIError(String), #[error("{0}")] AlreadyCompleted(String), #[error("WAC job suspended: {0}")] WacSuspended(String), #[error("Find python error: {0}")] FindPythonError(String), #[error("Problem with arguments: {0}")] ArgumentErr(String), #[error("{1}")] Generic(StatusCode, String), #[error("{feature} is unavailable due to some workers being behind. Do not use the feature or make sure all workers run at least {min_version}")] WorkersAreBehind { feature: String, min_version: String }, #[error( "Breaking change was introduced in v{version} ({feature}). Follow this migration guide: {guide_url}" )] MigrationNeeded { version: String, feature: String, guide_url: url::Url }, #[error("{0} is unavailable. It is possible for this worker to be behind.")] FeatureUnavailable(String), } impl Error { pub fn name(&self) -> &str { match self { Self::ExecutionErr(_) => "ExecutionErr", Self::ResultTooLarge(_) => "ResultTooLarge", Self::BadRequest(_) => "BadRequest", Self::QuotaExceeded(_) => "QuotaExceeded", Self::InternalErr(_) => "InternalErr", Self::InternalErrLoc { .. } => "InternalErr", Self::InternalErrAt(_, _) => "InternalErr", Self::Anyhow { .. } => "Anyhow", Self::JsonErr(_) => "JsonErr", Self::AIError(_) => "AIError", Self::AlreadyCompleted(_) => "AlreadyCompleted", Self::WacSuspended(_) => "WacSuspended", Self::FindPythonError(_) => "FindPythonError", Self::ArgumentErr(_) => "ArgumentErr", Self::Generic(_, _) => "Generic", Self::IoErr { .. } => "IoErr", Self::Utf8Err { .. } => "Utf8Err", Self::UuidErr { .. } => "UuidErr", Self::SqlErr { .. } => "SqlErr", Self::SerdeJson { .. } => "SerdeJson", Self::HexErr { .. } => "HexErr", Self::DatabaseMigration(_) => "DatabaseMigration", Self::ExitStatus(_, _) => "ExitStatus", Self::ExecutionRawError(_) => "ExecutionRawError", Self::BadGateway(_) => "BadGateway", Self::BadConfig(_) => "BadConfig", Self::ConnectingToDatabase(_) => "ConnectingToDatabase", Self::NotFound(_) => "NotFound", Self::NotAuthorized(_) => "NotAuthorized", Self::MetricNotFound(_) => "MetricNotFound", Self::PermissionDenied(_) => "PermissionDenied", _ => "InternalErr", } } } fn prettify_location(location: &'static Location<'static>) -> String { location .to_string() .split("/") .last() .unwrap_or("unknown") .to_string() } impl From for Error { #[track_caller] fn from(e: anyhow::Error) -> Self { Self::Anyhow { error: e, location: prettify_location(std::panic::Location::caller()) } } } impl From for Error { #[track_caller] fn from(e: sqlx::Error) -> Self { Self::SqlErr { error: e, location: prettify_location(std::panic::Location::caller()) } } } impl From for Error { #[track_caller] fn from(e: uuid::Error) -> Self { Self::UuidErr { error: e, location: prettify_location(std::panic::Location::caller()) } } } impl From for Error { #[track_caller] fn from(e: std::string::FromUtf8Error) -> Self { Self::Utf8Err { error: e, location: prettify_location(std::panic::Location::caller()) } } } impl From for Error { #[track_caller] fn from(e: io::Error) -> Self { Self::IoErr { error: e, location: prettify_location(std::panic::Location::caller()) } } } impl From for Error { #[track_caller] fn from(e: hex::FromHexError) -> Self { Self::HexErr { error: e, location: prettify_location(std::panic::Location::caller()) } } } impl From for Error { #[track_caller] fn from(e: serde_json::Error) -> Self { Self::SerdeJson { error: e, location: prettify_location(std::panic::Location::caller()) } } } impl From for Error { #[track_caller] fn from(e: url::ParseError) -> Self { Self::ArgumentErr(format!("Cannot parse provided url. \ne: {e}")) } } impl From for Error { fn from(value: tokio::time::error::Elapsed) -> Self { Self::InternalErr(value.to_string()) } } impl From for Error { fn from(e: semver::Error) -> Self { Self::ArgumentErr(format!("Cannot parse provided semver: {e}")) } } impl Error { /// https://docs.rs/anyhow/1/anyhow/struct.Error.html#display-representations pub fn alt(&self) -> String { format!("{:#}", self) } pub fn dbg(&self) -> String { format!("{:?}", self) } pub fn relocate_internal(self, loc: &'static Location<'static>) -> Self { match self { Self::InternalErrLoc { message, .. } | Self::InternalErrAt(_, message) | Self::InternalErr(message) => Self::InternalErrAt(loc, message), _ => self, } } #[track_caller] pub fn internal_err>(msg: T) -> Self { Self::InternalErrLoc { message: msg.as_ref().to_string(), location: prettify_location(std::panic::Location::caller()), } } } pub fn relocate_internal(loc: &'static Location<'static>) -> impl FnOnce(Error) -> Error { move |e| e.relocate_internal(loc) } pub fn to_anyhow(e: T) -> anyhow::Error { From::from(e) } impl IntoResponse for Error { fn into_response(self) -> axum::response::Response { let status = match self { Self::NotFound(_) => axum::http::StatusCode::NOT_FOUND, Self::NotAuthorized(_) => axum::http::StatusCode::UNAUTHORIZED, Self::RequireAdmin(_) | Self::PermissionDenied(_) => axum::http::StatusCode::FORBIDDEN, Self::SqlErr { .. } | Self::BadRequest(_) | Self::AIError(_) | Self::JsonErr(_) | Self::QuotaExceeded(_) => axum::http::StatusCode::BAD_REQUEST, Self::BadGateway(_) => axum::http::StatusCode::BAD_GATEWAY, Self::Generic(status_code, _) => status_code, _ => axum::http::StatusCode::INTERNAL_SERVER_ERROR, }; let e = &self; if matches!(status, axum::http::StatusCode::NOT_FOUND) { tracing::warn!(message = e.to_string()); } else { tracing::error!(message = e.to_string(), error = ?e); }; let body = Body::from(e.to_string()); axum::response::Response::builder() .header("Content-Type", "text/plain") .status(status) .body(body) .unwrap() } } /// Render a `JsonErr` payload as a readable message suitable for direct /// display in a toast: surface the `error` field as the headline, append a /// short summary of `details` (e.g. duplicate paths) when present, and fall /// back to pretty JSON for unknown shapes. Avoids the Rust `Debug` output /// (`Object { "error": String("..."), ... }`) that previously leaked to users. fn format_json_err_message(v: &serde_json::Value) -> String { if let Some(obj) = v.as_object() { let headline = obj .get("error") .and_then(|e| e.as_str()) .map(|s| s.to_string()); let details_summary = obj.get("details").and_then(|d| { let arr = d.as_array()?; if arr.is_empty() { return None; } let preview = arr .iter() .take(5) .map(|item| match item { serde_json::Value::Object(o) => { let parts: Vec = o .iter() .map(|(k, val)| match val { serde_json::Value::String(s) => format!("{k}={s}"), _ => format!("{k}={val}"), }) .collect(); format!("- {}", parts.join(", ")) } serde_json::Value::String(s) => format!("- {s}"), other => format!("- {other}"), }) .collect::>() .join("\n"); let suffix = if arr.len() > 5 { format!("\n... ({} more)", arr.len() - 5) } else { String::new() }; Some(format!("{preview}{suffix}")) }); match (headline, details_summary) { (Some(h), Some(d)) => return format!("{h}\n{d}"), (Some(h), None) => return h, (None, Some(d)) => return d, (None, None) => {} } } serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()) } pub trait OrElseNotFound { fn or_else_not_found(self, s: impl ToString) -> Result; } impl OrElseNotFound for Option { fn or_else_not_found(self, s: impl ToString) -> Result { self.ok_or_else(|| Error::NotFound(s.to_string())) } } // Make our own error that wraps `anyhow::Error`. pub struct AppError(anyhow::Error); // Tell axum how to convert `AppError` into a response. impl IntoResponse for AppError { fn into_response(self) -> Response { let body = Body::from(self.0.to_string()); tracing::error!(error = self.0.to_string()); axum::response::Response::builder() .header("Content-Type", "text/plain") .status(StatusCode::INTERNAL_SERVER_ERROR) .body(body) .unwrap() } } // This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into // `Result<_, AppError>`. That way you don't need to do that manually impl From for AppError where E: Into, { fn from(err: E) -> Self { Self(err.into()) } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn json_err_message_error_and_details() { let v = json!({ "error": "Duplicate HTTP route paths detected", "details": [ { "route_path": "a", "workspace_id": "admins", "http_method": "post" }, { "route_path": "a", "workspace_id": "starter", "http_method": "post" }, ], }); let rendered = Error::JsonErr(v).to_string(); assert_eq!( rendered, "Duplicate HTTP route paths detected\n\ - route_path=a, workspace_id=admins, http_method=post\n\ - route_path=a, workspace_id=starter, http_method=post" ); } #[test] fn json_err_message_error_only() { let v = json!({ "error": "Something went wrong" }); assert_eq!(Error::JsonErr(v).to_string(), "Something went wrong"); } #[test] fn json_err_message_truncates_long_details() { let details: Vec<_> = (0..8).map(|i| json!({ "k": i })).collect(); let v = json!({ "error": "boom", "details": details }); let rendered = Error::JsonErr(v).to_string(); assert!(rendered.starts_with("boom\n- k=0\n- k=1\n- k=2\n- k=3\n- k=4")); assert!(rendered.ends_with("... (3 more)")); // Items beyond the cap aren't enumerated. assert!(!rendered.contains("- k=5")); } #[test] fn json_err_message_fallback_to_pretty_json() { let v = json!([1, 2, 3]); // Non-object payload falls back to pretty JSON instead of leaking // Rust `Debug` syntax. let rendered = Error::JsonErr(v).to_string(); assert_eq!(rendered, "[\n 1,\n 2,\n 3\n]"); } }