From b7c8151d288458ac87e8ce2346d9590aa12349bb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 13 Apr 2026 17:47:06 -0400 Subject: [PATCH] error utils (reusable) (#1355) --- Cargo.lock | 8 + Cargo.toml | 2 + crates/lib/error_utils/Cargo.toml | 16 ++ crates/lib/error_utils/src/lib.rs | 1 + crates/lib/error_utils/src/try_error_name.rs | 157 ++++++++++++++++ crates/service/web/storyteller_web/Cargo.toml | 1 + .../check_advanced_common_web_error.rs | 176 +----------------- 7 files changed, 187 insertions(+), 174 deletions(-) create mode 100644 crates/lib/error_utils/Cargo.toml create mode 100644 crates/lib/error_utils/src/lib.rs create mode 100644 crates/lib/error_utils/src/try_error_name.rs diff --git a/Cargo.lock b/Cargo.lock index 6fe0d05ec7..2ca50bfe66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3876,6 +3876,13 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "error_utils" +version = "0.0.1" +dependencies = [ + "regex", +] + [[package]] name = "errors" version = "0.0.1" @@ -11234,6 +11241,7 @@ dependencies = [ "elasticsearch", "elasticsearch_schema", "enums", + "error_utils", "errors", "fal_client", "ffmpeg_utils", diff --git a/Cargo.toml b/Cargo.toml index 460d5c0f3b..a52fea260a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ members = [ "crates/lib/data/primitives", "crates/lib/data/web_base64", "crates/lib/easyenv", # NB: Vendored version of https://github.com/echelon/easyenv.rs that we'll improve. + "crates/lib/error_utils", "crates/lib/errors", "crates/lib/ffmpeg_utils", "crates/lib/files/file_to_base64_url", @@ -236,6 +237,7 @@ cookie_store = { path = "crates/lib/cookie_store" } crockford = { path = "crates/lib/data/crockford" } easyenv = { path = "crates/lib/easyenv" } enums = { path = "crates/schema/public/enums" } +error_utils = { path = "crates/lib/error_utils" } errors = { path = "crates/lib/errors" } fal_client = { path = "crates/api_clients/fal_client" } ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" } diff --git a/crates/lib/error_utils/Cargo.toml b/crates/lib/error_utils/Cargo.toml new file mode 100644 index 0000000000..aa82e89c28 --- /dev/null +++ b/crates/lib/error_utils/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "error_utils" +edition = "2021" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +name = "error_utils" +path = "src/lib.rs" + +[dependencies] +regex.workspace = true diff --git a/crates/lib/error_utils/src/lib.rs b/crates/lib/error_utils/src/lib.rs new file mode 100644 index 0000000000..415a83a6c5 --- /dev/null +++ b/crates/lib/error_utils/src/lib.rs @@ -0,0 +1 @@ +pub mod try_error_name; diff --git a/crates/lib/error_utils/src/try_error_name.rs b/crates/lib/error_utils/src/try_error_name.rs new file mode 100644 index 0000000000..ee50a350d3 --- /dev/null +++ b/crates/lib/error_utils/src/try_error_name.rs @@ -0,0 +1,157 @@ +use regex::Regex; +use std::sync::LazyLock; + +/// Regex that matches CamelCase type names with an optional `::Variant` suffix. +/// E.g. `CommonWebError::InvalidRequest`, `EnvError`, `KinoviError::RequestError`. +static ERROR_NAME_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*(?:::[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*)?") + .expect("error name regex should compile") +}); + +/// Try to extract a human-readable error title from the causal error's Debug +/// representation. Looks for CamelCase type names like `EnvError::NotUnicode`. +pub fn try_error_name(error: &dyn std::error::Error) -> Option { + let debug_str = format!("{:?}", error); + try_extract_error_name(&debug_str) +} + +/// Extract the leading CamelCase error name (with optional `::Variant`) from +/// a Debug-formatted error string. Returns `None` if no match is found. +/// +/// ```text +/// "CommonWebError::InvalidRequest(\"bad\")" → Some("CommonWebError::InvalidRequest") +/// "EnvError::ParseError { reason: \"foo\" }" → Some("EnvError::ParseError") +/// "just a plain string" → None +/// ``` +pub (crate) fn try_extract_error_name(debug_str: &str) -> Option { + ERROR_NAME_RE.find(debug_str) + .map(|m| m.as_str().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- try_extract_error_name tests --- + + #[test] + fn extract_just_error_name() { + assert_eq!( + try_extract_error_name("JustErrorName"), + Some("JustErrorName".to_string()), + ); + } + + #[test] + fn extract_error_with_variant_and_string_payload() { + assert_eq!( + try_extract_error_name(r#"CommonWebError::InvalidRequest("Invalid request")"#), + Some("CommonWebError::InvalidRequest".to_string()), + ); + } + + #[test] + fn extract_error_with_unit_variant() { + assert_eq!( + try_extract_error_name("EnvError::NotUnicode"), + Some("EnvError::NotUnicode".to_string()), + ); + } + + #[test] + fn extract_error_with_struct_payload() { + assert_eq!( + try_extract_error_name(r#"EnvError::ParseError { reason: "foo" }"#), + Some("EnvError::ParseError".to_string()), + ); + } + + #[test] + fn extract_error_with_tuple_payload() { + assert_eq!( + try_extract_error_name("KinoviError::RequestError(asdf)"), + Some("KinoviError::RequestError".to_string()), + ); + } + + #[test] + fn extract_error_with_nested_errors() { + assert_eq!( + try_extract_error_name( + "ComplexError::SomeError(SomeError::SomeVariant(SomeInnerError::SomeInnerVariant))" + ), + Some("ComplexError::SomeError".to_string()), + ); + } + + #[test] + fn extract_error_with_struct_containing_nested() { + assert_eq!( + try_extract_error_name( + "WrappedError::Variant { inner: SomeInnerError::SomeInnerVariant }" + ), + Some("WrappedError::Variant".to_string()), + ); + } + + #[test] + fn no_match_for_lowercase_string() { + assert_eq!( + try_extract_error_name("just a plain error message with no type name"), + None, + ); + } + + #[test] + fn no_match_for_empty_string() { + assert_eq!(try_extract_error_name(""), None); + } + + #[test] + fn single_word_camel_case() { + assert_eq!( + try_extract_error_name("Error"), + Some("Error".to_string()), + ); + } + + // --- try_error_title tests (with real error types) --- + + #[test] + fn title_from_io_error() { + let err: Box = Box::new( + std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"), + ); + let title = try_error_name(err.as_ref()); + assert!(title.is_some()); + } + + /// A simple custom error for testing. + #[derive(Debug)] + enum TestAppError { + DatabaseTimeout, + InvalidInput(String), + } + + impl std::fmt::Display for TestAppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } + } + + impl std::error::Error for TestAppError {} + + #[test] + fn title_from_custom_unit_variant() { + let err = TestAppError::DatabaseTimeout; + let title = try_error_name(&err); + assert_eq!(title, Some("DatabaseTimeout".to_string())); + } + + #[test] + fn title_from_custom_tuple_variant() { + let err = TestAppError::InvalidInput("bad data".to_string()); + let title = try_error_name(&err); + assert_eq!(title, Some("InvalidInput".to_string())); + } +} diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index d9869bea40..c3dd17da8a 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -50,6 +50,7 @@ datetimes = { path = "../../../lib/data/datetimes" } easyenv.workspace = true elasticsearch_schema = { path = "../../../schema/database/elasticsearch_schema" } enums.workspace = true +error_utils.workspace = true errors.workspace = true fal_client.workspace = true filesys.workspace = true diff --git a/crates/service/web/storyteller_web/src/http_server/middleware/error_alerting_middleware/handlers/check_advanced_common_web_error.rs b/crates/service/web/storyteller_web/src/http_server/middleware/error_alerting_middleware/handlers/check_advanced_common_web_error.rs index c725313e0a..3b6565edb0 100644 --- a/crates/service/web/storyteller_web/src/http_server/middleware/error_alerting_middleware/handlers/check_advanced_common_web_error.rs +++ b/crates/service/web/storyteller_web/src/http_server/middleware/error_alerting_middleware/handlers/check_advanced_common_web_error.rs @@ -1,6 +1,5 @@ +use error_utils::try_error_name::try_error_name; use log::{debug, warn}; -use regex::Regex; -use std::sync::LazyLock; use pager::client::pager::Pager; use pager::notification::notification_details_builder::NotificationDetailsBuilder; @@ -24,7 +23,7 @@ pub(crate) fn check_advanced_common_web_error( } let maybe_error_name = error.cause() - .and_then(|cause| try_error_title(cause)); + .and_then(|cause| try_error_name(cause)); let title = match maybe_error_name { Some(name) => format!("{} on {} {}", name, method, path), @@ -61,174 +60,3 @@ pub(crate) fn check_advanced_common_web_error( true } - -/// Try to extract a human-readable error title from the causal error's Debug -/// representation. Looks for CamelCase type names like `EnvError::NotUnicode`. -fn try_error_title(error: &dyn std::error::Error) -> Option { - let debug_str = format!("{:?}", error); - try_extract_error_name(&debug_str) -} - -/// Regex that matches CamelCase type names with an optional `::Variant` suffix. -/// E.g. `CommonWebError::InvalidRequest`, `EnvError`, `KinoviError::RequestError`. -static ERROR_NAME_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*(?:::[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*)?") - .expect("error name regex should compile") -}); - -/// Extract the leading CamelCase error name (with optional `::Variant`) from -/// a Debug-formatted error string. Returns `None` if no match is found. -/// -/// ```text -/// "CommonWebError::InvalidRequest(\"bad\")" → Some("CommonWebError::InvalidRequest") -/// "EnvError::ParseError { reason: \"foo\" }" → Some("EnvError::ParseError") -/// "just a plain string" → None -/// ``` -fn try_extract_error_name(debug_str: &str) -> Option { - ERROR_NAME_RE.find(debug_str) - .map(|m| m.as_str().to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // --- try_extract_error_name tests --- - - #[test] - fn extract_just_error_name() { - assert_eq!( - try_extract_error_name("JustErrorName"), - Some("JustErrorName".to_string()), - ); - } - - #[test] - fn extract_error_with_variant_and_string_payload() { - assert_eq!( - try_extract_error_name(r#"CommonWebError::InvalidRequest("Invalid request")"#), - Some("CommonWebError::InvalidRequest".to_string()), - ); - } - - #[test] - fn extract_error_with_unit_variant() { - assert_eq!( - try_extract_error_name("EnvError::NotUnicode"), - Some("EnvError::NotUnicode".to_string()), - ); - } - - #[test] - fn extract_error_with_struct_payload() { - assert_eq!( - try_extract_error_name(r#"EnvError::ParseError { reason: "foo" }"#), - Some("EnvError::ParseError".to_string()), - ); - } - - #[test] - fn extract_error_with_tuple_payload() { - assert_eq!( - try_extract_error_name("KinoviError::RequestError(asdf)"), - Some("KinoviError::RequestError".to_string()), - ); - } - - #[test] - fn extract_error_with_nested_errors() { - // Should only grab the outermost error name. - assert_eq!( - try_extract_error_name( - "ComplexError::SomeError(SomeError::SomeVariant(SomeInnerError::SomeInnerVariant))" - ), - Some("ComplexError::SomeError".to_string()), - ); - } - - #[test] - fn extract_error_with_struct_containing_nested() { - assert_eq!( - try_extract_error_name( - "WrappedError::Variant { inner: SomeInnerError::SomeInnerVariant }" - ), - Some("WrappedError::Variant".to_string()), - ); - } - - #[test] - fn no_match_for_lowercase_string() { - assert_eq!( - try_extract_error_name("just a plain error message with no type name"), - None, - ); - } - - #[test] - fn no_match_for_empty_string() { - assert_eq!(try_extract_error_name(""), None); - } - - #[test] - fn single_word_camel_case() { - // A single CamelCase word like "Error" should not match because - // the regex requires at least two segments (e.g. "Ab" = A + b). - // "Error" = E + rror — "rror" has 4 lowercase chars which is fine. - assert_eq!( - try_extract_error_name("Error"), - Some("Error".to_string()), - ); - } - - // --- try_error_title tests (with real error types) --- - - #[test] - fn title_from_io_error() { - let err: Box = Box::new( - std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"), - ); - // io::Error Debug looks like: `Custom { kind: NotFound, error: "file not found" }` - // "Custom" doesn't match CamelCase with 2+ segments... but we get what we get. - let title = try_error_title(err.as_ref()); - // The exact output depends on Debug formatting. Just verify it doesn't panic. - // Custom has one uppercase + lowercase sequence = "Custom" which matches [A-Z][0-9a-z]+ - assert!(title.is_some()); - } - - #[test] - fn title_from_serde_json_error() { - let err: serde_json::Error = serde_json::from_str::("not json").unwrap_err(); - let title = try_error_title(&err); - // serde_json::Error Debug typically starts with "Error(...)" - assert!(title.is_some()); - } - - /// A simple custom error for testing. - #[derive(Debug)] - enum TestAppError { - DatabaseTimeout, - InvalidInput(String), - } - - impl std::fmt::Display for TestAppError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) - } - } - - impl std::error::Error for TestAppError {} - - #[test] - fn title_from_custom_unit_variant() { - let err = TestAppError::DatabaseTimeout; - let title = try_error_title(&err); - assert_eq!(title, Some("DatabaseTimeout".to_string())); - } - - #[test] - fn title_from_custom_tuple_variant() { - let err = TestAppError::InvalidInput("bad data".to_string()); - let title = try_error_title(&err); - assert_eq!(title, Some("InvalidInput".to_string())); - } -}