Every error response in the { error, code, details? } format (step 2)

Moves every remaining hand-built error response onto ApiError: ~300
sites across faces, image, video, files, memories, versions,
duplicates, favorites, libraries, auth, clip/unified search, prefs,
reels, tts, insight chat, persona chat, knowledge and personas.

- 500s: the body was the error's text (anyhow / diesel / {:?}); now
  ApiError::internal logs it and sends a generic message.
- Each 4xx/502/503 has a stable code and a message written for users;
  statuses are unchanged. Shared helpers per module (faces_unavailable,
  person_not_found, unknown_library, insight_not_found, ...).
- Pre-format top-level fields kept where clients read them: upload 409
  duplicate_of / content_hash / library_name.
- PersonaChatError converts into ApiError in one place.
- Multipart upload errors use the format (error::multipart_config).
- Guard test: no non-test code builds HttpResponse::<error>() by hand.

Closes #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-11-01 13:29:13 -05:00
1 parent 1836f1b9ca
commit 431c3a7492
23 files changed
+894 -542

No files matched your search

+21 -16
View File
@@ -2,6 +2,7 @@
//! and shared across libraries — a favorite created in lib1 is visible
//! under lib2 if the same rel_path resolves there too.
use crate::error::ApiError;
use std::sync::Mutex;
use tracing_opentelemetry::OpenTelemetrySpanExt;
@@ -53,10 +54,9 @@ pub async fn favorites(
}
Ok(Err(e)) => {
span.set_status(Status::error(format!("Error getting favorites: {:?}", e)));
error!("Error getting favorites: {:?}", e);
HttpResponse::InternalServerError().finish()
ApiError::internal(format!("Error getting favorites: {:?}", e)).into_response()
}
Err(_) => HttpResponse::InternalServerError().finish(),
Err(e) => ApiError::internal(e).into_response(),
}
}
@@ -78,24 +78,24 @@ pub async fn put_add_favorite(
{
Ok(Err(e)) if e.kind == DbErrorKind::AlreadyExists => {
warn!("Favorite: {} exists for user: {}", &body.path, user_id);
HttpResponse::Ok()
}
Ok(Err(e)) => {
error!("{:?} {}. for user: {}", e, body.path, user_id);
HttpResponse::BadRequest()
HttpResponse::Ok().finish()
}
Ok(Err(e)) => ApiError::internal(format!(
"{:?} adding favorite {}. for user: {}",
e, body.path, user_id
))
.into_response(),
Ok(Ok(_)) => {
info!("Adding favorite \"{}\" for userid: {}", body.path, user_id);
HttpResponse::Created()
HttpResponse::Created().finish()
}
Err(e) => {
error!("Blocking error while inserting favorite: {:?}", e);
HttpResponse::InternalServerError()
ApiError::internal(format!("Blocking error while inserting favorite: {:?}", e))
.into_response()
}
}
} else {
error!("Unable to parse sub as i32: {}", claims.sub);
HttpResponse::BadRequest()
bad_user(&claims).into_response()
}
}
@@ -120,9 +120,14 @@ pub async fn delete_favorite(
"Removing favorite \"{}\" for userid: {}",
body.path, user_id
);
HttpResponse::Ok()
HttpResponse::Ok().finish()
} else {
error!("Unable to parse sub as i32: {}", claims.sub);
HttpResponse::BadRequest()
bad_user(&claims).into_response()
}
}
/// A token whose subject isn't a user id (favorites are per user).
fn bad_user(claims: &Claims) -> ApiError {
error!("Unable to parse sub as i32: {}", claims.sub);
ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
}
+66 -29
View File
@@ -2,6 +2,7 @@
//! legacy thumbnail lookup), upload, EXIF metadata read + GPS / date
//! mutation, and the full exiftool dump used by Apollo's details modal.
use crate::error::ApiError;
use std::error::Error;
use std::fs::File;
use std::io::ErrorKind;
@@ -56,7 +57,7 @@ pub async fn get_image(
Ok(None) => app_state.primary_library(),
Err(msg) => {
span.set_status(Status::error(msg.clone()));
return HttpResponse::BadRequest().body(msg);
return libraries::unknown_library(msg).into_response();
}
};
@@ -231,7 +232,11 @@ pub async fn get_image(
return resp;
}
span.set_status(Status::error("Thumbnail unavailable"));
return HttpResponse::NotFound().finish();
return ApiError::not_found(
"thumbnail_unavailable",
"No thumbnail for this file yet",
)
.into_response();
}
}
@@ -264,11 +269,11 @@ pub async fn get_image(
}
span.set_status(Status::error("Not found"));
HttpResponse::NotFound().finish()
file_not_found().into_response()
} else {
span.set_status(Status::error("Not found"));
error!("Path does not exist in any library: {}", req.path);
HttpResponse::NotFound().finish()
file_not_found().into_response()
}
}
@@ -557,7 +562,7 @@ pub async fn get_file_metadata(
error!("{}", message);
span.set_status(Status::error(message));
HttpResponse::InternalServerError().finish()
ApiError::internal("internal error").into_response()
}
}
}
@@ -609,19 +614,23 @@ pub async fn set_image_gps(
Some(v) => v,
None => {
span.set_status(Status::error("file not found"));
return HttpResponse::NotFound().body("File not found");
return file_not_found().into_response();
}
};
if !exif::supports_exif(&full_path) {
return HttpResponse::BadRequest().body("File format does not support EXIF GPS write");
return ApiError::bad_request(
"gps_write_unsupported",
"Location can't be saved into this file type",
)
.into_response();
}
if let Err(e) = exif::write_gps(&full_path, body.latitude, body.longitude) {
let msg = format!("exiftool write failed: {}", e);
error!("{}", msg);
span.set_status(Status::error(msg.clone()));
return HttpResponse::InternalServerError().body(msg);
return ApiError::internal(msg).into_response();
}
// Re-read EXIF from disk (the write path doesn't tell us the rest of
@@ -637,7 +646,7 @@ pub async fn set_image_gps(
// next file scan rewrites it.
let msg = format!("EXIF re-read failed after write: {}", e);
error!("{}", msg);
return HttpResponse::InternalServerError().body(msg);
return ApiError::internal(msg).into_response();
}
};
let now = Utc::now().timestamp();
@@ -742,7 +751,7 @@ pub async fn set_image_gps(
let msg = format!("EXIF DB update failed: {:?}", e);
error!("{}", msg);
span.set_status(Status::error(msg.clone()));
HttpResponse::InternalServerError().body(msg)
ApiError::internal(msg).into_response()
}
}
}
@@ -790,7 +799,7 @@ pub async fn get_full_exif(
Some(v) => v,
None => {
span.set_status(Status::error("file not found"));
return HttpResponse::NotFound().body("File not found");
return file_not_found().into_response();
}
};
@@ -825,13 +834,17 @@ pub async fn get_full_exif(
span.set_status(Status::error(msg.clone()));
// 503 — typically "exiftool isn't on PATH" or a transient spawn
// failure. Apollo surfaces a hint in the modal.
HttpResponse::ServiceUnavailable().body(msg)
ApiError::unavailable(
"exiftool_unavailable",
"Couldn't write to the file: exiftool isn't available on the server",
)
.into_response()
}
Err(e) => {
let msg = format!("blocking-pool error: {}", e);
error!("{}", msg);
span.set_status(Status::error(msg.clone()));
HttpResponse::InternalServerError().body(msg)
ApiError::internal(msg).into_response()
}
}
}
@@ -965,7 +978,7 @@ pub async fn set_image_date(
Ok(None) => app_state.primary_library(),
Err(msg) => {
span.set_status(Status::error(msg.clone()));
return HttpResponse::BadRequest().body(msg);
return libraries::unknown_library(msg).into_response();
}
};
@@ -1008,8 +1021,8 @@ pub async fn set_image_date(
error!("{}", msg);
span.set_status(Status::error(msg.clone()));
match e.kind {
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
_ => HttpResponse::InternalServerError().body(msg),
DbErrorKind::NotFound => not_indexed().into_response(),
_ => ApiError::internal(msg).into_response(),
}
}
}
@@ -1031,7 +1044,7 @@ pub async fn clear_image_date(
Ok(None) => app_state.primary_library(),
Err(msg) => {
span.set_status(Status::error(msg.clone()));
return HttpResponse::BadRequest().body(msg);
return libraries::unknown_library(msg).into_response();
}
};
@@ -1058,8 +1071,8 @@ pub async fn clear_image_date(
error!("{}", msg);
span.set_status(Status::error(msg.clone()));
match e.kind {
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
_ => HttpResponse::InternalServerError().body(msg),
DbErrorKind::NotFound => not_indexed().into_response(),
_ => ApiError::internal(msg).into_response(),
}
}
}
@@ -1089,7 +1102,7 @@ pub async fn upload_image(
Ok(None) => app_state.primary_library(),
Err(msg) => {
span.set_status(Status::error(msg.clone()));
return HttpResponse::BadRequest().body(msg);
return libraries::unknown_library(msg).into_response();
}
};
@@ -1126,7 +1139,8 @@ pub async fn upload_image(
if !file_content.is_empty() {
if file_name.is_none() {
span.set_status(Status::error("No filename provided"));
return HttpResponse::BadRequest().body("No filename provided");
return ApiError::bad_request("no_file_name", "The upload has no file name")
.into_response();
}
let full_path = PathBuf::from(&path).join(file_name.unwrap());
if let Some(full_path) = is_valid_full_path(
@@ -1154,14 +1168,22 @@ pub async fn upload_image(
.find(|l| l.id == existing.library_id)
.map(|l| l.name);
span.set_status(Status::Ok);
return HttpResponse::Conflict().json(serde_json::json!({
"duplicate_of": {
// The extra fields predate the error format; clients
// read them at the top level.
return ApiError::conflict(
"duplicate_upload",
"This photo is already in your library",
)
.with_legacy_field(
"duplicate_of",
serde_json::json!({
"library_id": existing.library_id,
"rel_path": existing.file_path,
},
"content_hash": upload_hash,
"library_name": library_name,
}));
}),
)
.with_legacy_field("content_hash", &upload_hash)
.with_legacy_field("library_name", &library_name)
.into_response();
}
}
@@ -1274,11 +1296,13 @@ pub async fn upload_image(
} else {
error!("Invalid path for upload: {:?}", full_path);
span.set_status(Status::error("Invalid path for upload"));
return HttpResponse::BadRequest().body("Path was not valid");
return ApiError::bad_request("invalid_path", "That folder isn't in the library")
.into_response();
}
} else {
span.set_status(Status::error("No file body read"));
return HttpResponse::BadRequest().body("No file body read");
return ApiError::bad_request("empty_upload", "The upload has no file in it")
.into_response();
}
app_state.stream_manager.do_send(RefreshThumbnailsMessage);
@@ -1318,6 +1342,19 @@ fn sniff_image_mime(head: &[u8]) -> Option<actix_web::mime::Mime> {
}
}
/// The path names no file in any library.
fn file_not_found() -> ApiError {
ApiError::not_found("file_not_found", "That file doesn't exist")
}
/// The file exists but has no indexed row yet.
fn not_indexed() -> ApiError {
ApiError::not_found(
"photo_not_indexed",
"This photo is still being indexed. Try again in a minute.",
)
}
#[cfg(test)]
mod tests {
use super::*;
+42 -21
View File
@@ -1,6 +1,7 @@
//! Video-related endpoints: HLS playlist generation, segment streaming,
//! and the short-clip preview pipeline.
use crate::error::ApiError;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
@@ -75,7 +76,7 @@ pub async fn generate_video(
let resolved = resolve_request_path(&app_state, &body.path, body.library.as_deref());
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
return HttpResponse::BadRequest().finish();
return invalid_path().into_response();
};
// Build the rel_path used to look up the row. Forward-slash normalized
@@ -112,7 +113,7 @@ pub async fn generate_video(
e
);
span.set_status(Status::error(format!("hash compute failed: {}", e)));
return HttpResponse::InternalServerError().finish();
return ApiError::internal("internal error").into_response();
}
},
};
@@ -198,11 +199,13 @@ pub async fn stream_hls_file(
let (hash, file) = path.into_inner();
if !is_valid_hash(&hash) {
span.set_status(Status::error("invalid hash"));
return HttpResponse::BadRequest().body("invalid hash");
return ApiError::bad_request("invalid_path", "Invalid video stream address")
.into_response();
}
if !is_allowed_hls_filename(&file) {
span.set_status(Status::error("invalid file"));
return HttpResponse::BadRequest().body("invalid file");
return ApiError::bad_request("invalid_path", "Invalid video stream address")
.into_response();
}
let shard = &hash[..2];
@@ -220,7 +223,7 @@ pub async fn stream_hls_file(
Err(e) => {
error!("Failed to canonicalize VIDEO_PATH: {:?}", e);
span.set_status(Status::error("VIDEO_PATH not canonicalisable"));
return HttpResponse::InternalServerError().finish();
return ApiError::internal("internal error").into_response();
}
};
let canonical_file = match std::fs::canonicalize(&file_path) {
@@ -228,7 +231,7 @@ pub async fn stream_hls_file(
Err(_) => {
debug!("HLS file not found: {}", file_path.display());
span.set_status(Status::error("not found"));
return HttpResponse::NotFound().finish();
return stream_not_found().into_response();
}
};
if !canonical_file.starts_with(&canonical_base) {
@@ -237,7 +240,7 @@ pub async fn stream_hls_file(
file_path.display()
);
span.set_status(Status::error("traversal"));
return HttpResponse::Forbidden().finish();
return ApiError::forbidden("invalid_path", "Invalid video stream address").into_response();
}
match NamedFile::open(&canonical_file) {
@@ -247,7 +250,7 @@ pub async fn stream_hls_file(
}
Err(_) => {
span.set_status(Status::error("not found"));
HttpResponse::NotFound().finish()
stream_not_found().into_response()
}
}
}
@@ -294,11 +297,11 @@ pub async fn original_video_url(
resolve_request_path(&app_state, &body.path, body.library.as_deref())
else {
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
return HttpResponse::BadRequest().finish();
return invalid_path().into_response();
};
if !file_types::is_video_file(&full_path) {
span.set_status(Status::error("not a video"));
return HttpResponse::BadRequest().body("not a video");
return ApiError::bad_request("not_a_video", "That file isn't a video").into_response();
}
let rel_path = rel_path_for_lookup(&full_path.to_string_lossy(), &root);
@@ -372,7 +375,11 @@ pub async fn stream_original_video(
.and_then(|t| original_url::verify(&t.0));
let Some(claims) = claims else {
span.set_status(Status::error("bad token"));
return HttpResponse::Unauthorized().finish();
return ApiError::unauthorized(
"stream_link_expired",
"This video link has expired. Open the video again.",
)
.into_response();
};
let Some(path) = app_state
.libraries
@@ -382,7 +389,7 @@ pub async fn stream_original_video(
.filter(|p| p.is_file())
else {
span.set_status(Status::error("not found"));
return HttpResponse::NotFound().finish();
return file_not_found().into_response();
};
match NamedFile::open(&path) {
@@ -394,7 +401,7 @@ pub async fn stream_original_video(
}
Err(_) => {
span.set_status(Status::error("not found"));
HttpResponse::NotFound().finish()
stream_not_found().into_response()
}
}
}
@@ -472,7 +479,7 @@ pub async fn get_video_preview(
Some(path) => path,
None => {
span.set_status(Status::error("Invalid path"));
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Invalid path"}));
return invalid_path().into_response();
}
};
@@ -532,9 +539,7 @@ pub async fn get_video_preview(
.error_message
.unwrap_or_else(|| "Unknown error".to_string());
span.set_status(Status::error(format!("Generation failed: {}", error_msg)));
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Generation failed: {}", error_msg)
}))
ApiError::internal(format!("Generation failed: {}", error_msg)).into_response()
}
_ => {
// pending or unknown status - trigger generation
@@ -569,7 +574,7 @@ pub async fn get_video_preview(
}
Err(_) => {
span.set_status(Status::error("Database error"));
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -587,8 +592,9 @@ pub async fn get_preview_status(
// Limit to 200 paths per request
if body.paths.len() > 200 {
span.set_status(Status::error("Too many paths"));
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Maximum 200 paths per request"}));
return ApiError::bad_request("too_many_files", "At most 200 videos per request")
.with_details(serde_json::json!({ "max": 200 }))
.into_response();
}
let previews = {
@@ -665,11 +671,26 @@ pub async fn get_preview_status(
}
Err(_) => {
span.set_status(Status::error("Database error"));
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
/// The path names no file in the library.
fn file_not_found() -> ApiError {
ApiError::not_found("file_not_found", "That file doesn't exist")
}
/// The path isn't a file in the library (400, as it has always been here).
fn invalid_path() -> ApiError {
ApiError::bad_request("invalid_path", "That file isn't in the library")
}
/// An HLS playlist/segment that isn't (or is no longer) generated.
fn stream_not_found() -> ApiError {
ApiError::not_found("stream_not_found", "This video isn't ready to stream yet")
}
#[cfg(test)]
mod tests {
use super::*;