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

+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::*;