Compare commits

...
Author SHA1 Message Date
Tanish Manku eb584ff926 surface gallery load errors with message and copy-to-clipboard
When listUserMediaFiles fails (HTTP error, network error, null reference,
or any other exception), the gallery now shows the error message with
a "Copy error" button instead of silently falling through to "No items yet".

This is a diagnostic aid for a macOS-only blank gallery bug where the
root cause is unknown (CORS, session cookie, or runtime exception) and
there is no access to production logs.
2026-04-13 16:01:19 -07:00
Brandon Thomas b7c8151d28 error utils (reusable) (#1355) 2026-04-13 17:47:06 -04:00
8 changed files with 218 additions and 175 deletions
Generated
+8
View File
@@ -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",
+2
View File
@@ -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" }
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "error_utils"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "error_utils"
path = "src/lib.rs"
[dependencies]
regex.workspace = true
+1
View File
@@ -0,0 +1 @@
pub mod try_error_name;
@@ -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<Regex> = 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<String> {
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<String> {
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<dyn std::error::Error> = 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()));
}
}
@@ -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
@@ -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<String> {
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<Regex> = 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<String> {
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<dyn std::error::Error> = 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::<String>("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()));
}
}
@@ -401,6 +401,9 @@ export const GalleryModal = React.memo(
// Track whether this is the very first load (no cache, no items yet)
const [initialLoading, setInitialLoading] = useState(false);
// Error message from failed listUserMediaFiles call (null = no error)
const [itemsLoadError, setItemsLoadError] = useState<string | null>(null);
// Bulk selection state (view mode only)
const [bulkSelectedIds, setBulkSelectedIds] = useState<Set<string>>(
new Set(),
@@ -545,6 +548,7 @@ export const GalleryModal = React.memo(
const response = await api.listUserMediaFiles(query);
if (response.success && response.data) {
setItemsLoadError(null);
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
@@ -589,9 +593,13 @@ export const GalleryModal = React.memo(
});
return latest;
});
} else {
setItemsLoadError(response.errorMessage || "Request failed (unknown error)");
}
} catch (error) {
console.error("Failed to fetch library items:", error);
const msg = error instanceof Error ? error.message : String(error);
console.error("Failed to fetch library items:", msg);
setItemsLoadError(msg);
}
setLoading(false);
setPaginationLoading(false);
@@ -603,6 +611,7 @@ export const GalleryModal = React.memo(
// refresh logic — shows cached items immediately, then background-refreshes
const refreshGallery = useCallback(() => {
setItemsLoadError(null);
const cacheKey = `gallery_${activeFilterRef.current}`;
const cached = galleryCacheMap.get(cacheKey);
@@ -1139,6 +1148,27 @@ export const GalleryModal = React.memo(
</button>
</div>
</div>
) : itemsLoadError && allItems.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3 text-sm max-w-xs text-center">
<div className="text-base-fg/60">Failed to load gallery.</div>
<div className="text-xs text-base-fg/40 font-mono break-all">{itemsLoadError}</div>
<div className="flex gap-3">
<button
className="text-xs text-blue-400 hover:text-blue-300 underline"
onClick={() => navigator.clipboard?.writeText(itemsLoadError!)}
>
Copy error
</button>
<button
className="text-xs text-blue-400 hover:text-blue-300 underline"
onClick={() => refreshGallery()}
>
Retry
</button>
</div>
</div>
</div>
) : (initialLoading || !username) && allItems.length === 0 ? (
<SkeletonGrid columns={gridColumns} />
) : allItems.length === 0 && !loading ? (