Files
windmill/backend/windmill-common/src/error.rs
T
Ruben Fiszel e218d60919 skip workspaced-route duplicate checks on cloud (#9305)
* fix(settings): skip workspaced-route duplicate checks on cloud

The pre-write validation hooks for `app_workspaced_route` and
`http_route_workspaced_route` query the DB for cross-workspace duplicates
and fail the save when any are found. On cloud both `custom_path_exists`
(apps) and `route_path_key_exists` (HTTP triggers) already scope lookups
by `workspace_id` regardless of these settings, so duplicates across
workspaces are expected and the validation has no runtime meaning. The
result was that any cloud super-admin attempting to save instance
settings with these toggles set to false received
`Duplicate HTTP route paths detected` even though the setting has no
effect on cloud routing.

Fixes WIN-1983

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(error): render JsonErr as readable text and return 400

`Error::JsonErr` previously rendered through `#[error("Error: {0:#?}")]`,
leaking Rust's `Debug` output (`Object { "error": String(...), "details":
Array [...] }`) into the HTTP response body, and was bucketed into the
catch-all 500 branch in `IntoResponse`. The result was a 500 status with
a wall of Rust debug syntax in the toast — confusing and user-hostile.

- Bucket `JsonErr` into 400 (Bad Request): every current call site
  (workspaced-route duplicate checks, OAuth client errors, etc.) is a
  client/validation issue, not an internal server fault.
- Add `format_json_err_message` which surfaces the `error` field as the
  headline, summarises `details` (with a `- key=value` per entry), and
  pretty-prints the rest as JSON for unknown shapes. The frontend toast
  now reads e.g.

      Duplicate HTTP route paths detected
      - route_path=a, workspace_id=admins, http_method=post
      - route_path=a, workspace_id=starter, http_method=post

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(toast): preserve newlines and escape HTML in multi-line errors

The toast renders via `{@html processMessage(message)}`, so server-side
error bodies that span multiple lines (e.g. the duplicate-route response
from the settings endpoint) collapsed into a single line because HTML
treats consecutive whitespace (including `\n`) as a single space.

When the message contains a newline, escape HTML first (defends against
injected markup in server error bodies) and convert `\n` to `<br />` so
multi-line errors stay readable in the toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fixup: address CI review feedback

- toast.ts: escape HTML unconditionally. The previous gate on `\n` left
  single-line server error bodies unsafe under {@html}, which cubic
  flagged as P0. The path regex below only inserts a `<span>` around a
  `u/...` or `f/...` capture that can't contain HTML metacharacters, so
  escaping the whole input is the simpler and correct fix.
- error.rs: add unit tests pinning the rendered shape of
  `format_json_err_message` (error+details, error-only, truncation cap,
  non-object fallback to pretty JSON).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:13:28 +00:00

423 lines
14 KiB
Rust

/*
* 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<T> = std::result::Result<T, Error>;
pub type JsonResult<T> = std::result::Result<Json<T>, 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<serde_json::value::RawValue>),
#[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<anyhow::Error> for Error {
#[track_caller]
fn from(e: anyhow::Error) -> Self {
Self::Anyhow { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
impl From<sqlx::Error> for Error {
#[track_caller]
fn from(e: sqlx::Error) -> Self {
Self::SqlErr { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
impl From<uuid::Error> for Error {
#[track_caller]
fn from(e: uuid::Error) -> Self {
Self::UuidErr { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
impl From<std::string::FromUtf8Error> for Error {
#[track_caller]
fn from(e: std::string::FromUtf8Error) -> Self {
Self::Utf8Err { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
impl From<io::Error> for Error {
#[track_caller]
fn from(e: io::Error) -> Self {
Self::IoErr { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
impl From<hex::FromHexError> for Error {
#[track_caller]
fn from(e: hex::FromHexError) -> Self {
Self::HexErr { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
impl From<serde_json::Error> for Error {
#[track_caller]
fn from(e: serde_json::Error) -> Self {
Self::SerdeJson { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
impl From<url::ParseError> for Error {
#[track_caller]
fn from(e: url::ParseError) -> Self {
Self::ArgumentErr(format!("Cannot parse provided url. \ne: {e}"))
}
}
impl From<tokio::time::error::Elapsed> for Error {
fn from(value: tokio::time::error::Elapsed) -> Self {
Self::InternalErr(value.to_string())
}
}
impl From<semver::Error> 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<T: AsRef<str>>(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<T: 'static + std::error::Error + Send + Sync>(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<String> = 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::<Vec<_>>()
.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<T> {
fn or_else_not_found(self, s: impl ToString) -> Result<T>;
}
impl<T> OrElseNotFound<T> for Option<T> {
fn or_else_not_found(self, s: impl ToString) -> Result<T> {
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<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
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]");
}
}