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

+20 -18
View File
@@ -27,6 +27,7 @@ use crate::clip_search::{
};
use crate::data::{Claims, FilterMode, MediaType};
use crate::database::ExifDao;
use crate::error::ApiError;
use crate::file_types::{is_image_file, is_video_file};
use crate::geo::{forward_geocode, gps_bounding_box, haversine_distance};
use crate::tags::TagDao;
@@ -264,14 +265,7 @@ struct UnifiedResponse {
results: Vec<SearchHit>,
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
fn bad_request(msg: impl Into<String>) -> HttpResponse {
HttpResponse::BadRequest().json(ErrorBody { error: msg.into() })
}
// Errors use the shared format (crate::error::ApiError, #202).
/// Combine the model's semantic term with any tag words that didn't match the
/// vocab, so a hallucinated/non-vocab tag becomes a soft semantic signal
@@ -306,7 +300,7 @@ pub async fn unified_search<TagD: TagDao>(
);
let nl = query.q.trim().to_string();
if nl.is_empty() {
return bad_request("query parameter `q` is required");
return crate::clip_search::empty_query().into_response();
}
let limit = query.limit.clamp(1, 200);
@@ -315,7 +309,7 @@ pub async fn unified_search<TagD: TagDao>(
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
Ok(ids) => ids,
Err(msg) => return bad_request(msg),
Err(msg) => return crate::libraries::unknown_library(msg).into_response(),
};
// ── 1. Translate the NL query, grounded on the real tag vocabulary ──
@@ -333,7 +327,13 @@ pub async fn unified_search<TagD: TagDao>(
};
let (sq, echo, explicit_tags, explicit_gps) = match merge_explicit(sq, &query) {
Ok(merged) => merged,
Err(msg) => return bad_request(msg),
Err(_) => {
return ApiError::bad_request(
"incomplete_gps_filter",
"A location filter needs a latitude, longitude and radius",
)
.into_response();
}
};
let folder = folder_prefix(query.path.as_deref());
@@ -597,7 +597,8 @@ pub async fn unified_search<TagD: TagDao>(
// Filters-only: no semantic term. Require at least one filter,
// then return the candidate set newest-first.
if !has_struct {
return bad_request("query had no searchable terms");
return ApiError::bad_request("nothing_to_search", "Add search words or a filter")
.into_response();
}
candidate.sort_by(|a, b| b.date_taken.cmp(&a.date_taken));
let total_matching = candidate.len();
@@ -684,9 +685,11 @@ async fn translate<TagD: TagDao>(
Ok(b) => b,
Err(e) => {
log::warn!("unified_search: resolve_backend failed: {e:?}");
return Err(HttpResponse::ServiceUnavailable().json(ErrorBody {
error: "LLM backend unavailable".into(),
}));
return Err(ApiError::unavailable(
"llm_unavailable",
"The language model is unavailable. Try again shortly.",
)
.into_response());
}
};
log::info!("unified_search: translating with model={}", backend.model());
@@ -700,9 +703,8 @@ async fn translate<TagD: TagDao>(
.await
.map_err(|e| {
log::warn!("unified_search: translate_nl_query failed: {e:?}");
HttpResponse::BadGateway().json(ErrorBody {
error: "could not interpret the query".into(),
})
ApiError::bad_gateway("query_not_understood", "Couldn't understand that search")
.into_response()
})
}