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

+79 -68
View File
@@ -1,3 +1,4 @@
use crate::error::ApiError;
use actix_web::dev::{ServiceFactory, ServiceRequest};
use actix_web::{App, HttpRequest, HttpResponse, Responder, web};
use chrono::Utc;
@@ -519,7 +520,7 @@ async fn list_entities<D: KnowledgeDao + 'static>(
}
Err(e) => {
log::error!("list_entities error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -538,12 +539,12 @@ async fn get_entity<D: KnowledgeDao + 'static>(
let entity = match dao.get_entity_by_id(entity_id) {
Ok(Some(e)) => e,
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"}));
return ApiError::not_found("entity_not_found", "That entity no longer exists")
.into_response();
}
Err(e) => {
log::error!("get_entity error: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
};
@@ -552,8 +553,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
Ok(f) => f,
Err(e) => {
log::error!("get_facts_for_entity error: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
};
@@ -644,8 +644,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
Ok(links) => links.into_iter().map(PhotoLinkDetail::from).collect(),
Err(e) => {
log::error!("get_links_for_entity error: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
};
@@ -693,10 +692,12 @@ async fn patch_entity<D: KnowledgeDao + 'static>(
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.update_entity(entity_id, patch) {
Ok(Some(entity)) => HttpResponse::Ok().json(EntitySummary::from(entity)),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"})),
Ok(None) => {
ApiError::not_found("entity_not_found", "That entity no longer exists").into_response()
}
Err(e) => {
log::error!("patch_entity error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -712,12 +713,12 @@ async fn delete_entity<D: KnowledgeDao + 'static>(
// Verify entity exists before deleting
match dao.get_entity_by_id(entity_id) {
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"}));
return ApiError::not_found("entity_not_found", "That entity no longer exists")
.into_response();
}
Err(e) => {
log::error!("delete_entity lookup error: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
Ok(Some(_)) => {}
}
@@ -726,7 +727,7 @@ async fn delete_entity<D: KnowledgeDao + 'static>(
Ok(()) => HttpResponse::NoContent().finish(),
Err(e) => {
log::error!("delete_entity error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -737,8 +738,8 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
dao: web::Data<Mutex<D>>,
) -> impl Responder {
if body.source_id == body.target_id {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "source_id and target_id must be different"}));
return ApiError::bad_request("merge_into_self", "An entity can't be merged into itself")
.into_response();
}
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
@@ -747,13 +748,15 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
for id in [body.source_id, body.target_id] {
match dao.get_entity_by_id(id) {
Ok(None) => {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": format!("Entity {} not found", id)}));
return ApiError::bad_request(
"entity_not_found",
format!("Entity {} no longer exists", id),
)
.into_response();
}
Err(e) => {
log::error!("merge_entities lookup error: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
Ok(Some(_)) => {}
}
@@ -768,7 +771,7 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
}),
Err(e) => {
log::error!("merge_entities error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -790,8 +793,8 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
app_state: web::Data<AppState>,
) -> impl Responder {
if body.source_id == body.target_id {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "source_id and target_id must differ"}));
return ApiError::bad_request("merge_into_self", "An entity can't be merged into itself")
.into_response();
}
let (source, target) = {
@@ -800,25 +803,29 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
let source = match dao.get_entity_by_id(body.source_id) {
Ok(Some(e)) => e,
Ok(None) => {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "source entity not found"}));
return ApiError::bad_request(
"entity_not_found",
"The entity to merge no longer exists",
)
.into_response();
}
Err(e) => {
log::error!("synthesize_merge source lookup: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
};
let target = match dao.get_entity_by_id(body.target_id) {
Ok(Some(e)) => e,
Ok(None) => {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "target entity not found"}));
return ApiError::bad_request(
"entity_not_found",
"The entity to merge into no longer exists",
)
.into_response();
}
Err(e) => {
log::error!("synthesize_merge target lookup: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
};
(source, target)
@@ -890,9 +897,11 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
}
Err(e) => {
log::warn!("synthesize_merge generate failed: {:?}", e);
return HttpResponse::ServiceUnavailable().json(serde_json::json!({
"error": "LLM unavailable; the merge picker should fall back to skip-synthesis."
}));
return ApiError::unavailable(
"llm_unavailable",
"The language model is unavailable. Merge without a summary, or try again later.",
)
.into_response();
}
};
@@ -967,7 +976,7 @@ async fn list_facts<D: KnowledgeDao + 'static>(
}
Err(e) => {
log::error!("list_facts error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -980,13 +989,11 @@ async fn create_fact<D: KnowledgeDao + 'static>(
persona_dao: PersonaDaoData,
) -> impl Responder {
if body.object_entity_id.is_none() && body.object_value.is_none() {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "object_entity_id or object_value is required"
}));
return ApiError::bad_request("invalid_fact", "A fact needs an object entity or a value")
.into_response();
}
if body.predicate.trim().is_empty() {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "predicate must not be empty"}));
return ApiError::bad_request("invalid_fact", "A fact needs a predicate").into_response();
}
// Persona scoping: facts are written under the active single persona.
@@ -1006,14 +1013,15 @@ async fn create_fact<D: KnowledgeDao + 'static>(
// Verify subject entity exists.
match dao.get_entity_by_id(body.subject_entity_id) {
Ok(None) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Subject entity {} not found", body.subject_entity_id)
}));
return ApiError::bad_request(
"entity_not_found",
"The fact's subject no longer exists",
)
.into_response();
}
Err(e) => {
log::error!("create_fact subject lookup error: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
Ok(Some(_)) => {}
}
@@ -1022,14 +1030,15 @@ async fn create_fact<D: KnowledgeDao + 'static>(
if let Some(oid) = body.object_entity_id {
match dao.get_entity_by_id(oid) {
Ok(None) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Object entity {} not found", oid)
}));
return ApiError::bad_request(
"entity_not_found",
"The fact's object no longer exists",
)
.into_response();
}
Err(e) => {
log::error!("create_fact object lookup error: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
Ok(Some(_)) => {}
}
@@ -1074,7 +1083,7 @@ async fn create_fact<D: KnowledgeDao + 'static>(
}
Err(e) => {
log::error!("create_fact upsert error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -1101,10 +1110,12 @@ async fn patch_fact<D: KnowledgeDao + 'static>(
// agent corrections.
match dao.update_fact(fact_id, patch, Some(("manual", "manual"))) {
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
Ok(None) => {
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
}
Err(e) => {
log::error!("patch_fact error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -1120,7 +1131,7 @@ async fn delete_fact<D: KnowledgeDao + 'static>(
Ok(()) => HttpResponse::NoContent().finish(),
Err(e) => {
log::warn!("delete_fact({}) error: {:?}", fact_id, e);
HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"}))
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
}
}
}
@@ -1133,8 +1144,8 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
) -> impl Responder {
let old_id = id.into_inner();
if old_id == body.by_fact_id {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "old_id and by_fact_id must differ"}));
return ApiError::bad_request("supersede_self", "A fact can't replace itself")
.into_response();
}
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
// Manual supersede from the curation UI — same stamping rule as
@@ -1142,11 +1153,11 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
match dao.supersede_fact(old_id, body.by_fact_id, Some(("manual", "manual"))) {
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
Ok(None) => {
HttpResponse::NotFound().json(serde_json::json!({"error": "Old or new fact not found"}))
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
}
Err(e) => {
log::error!("supersede_fact error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -1160,10 +1171,12 @@ async fn restore_fact<D: KnowledgeDao + 'static>(
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.revert_supersession(fact_id, Some(("manual", "manual"))) {
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
Ok(None) => {
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
}
Err(e) => {
log::error!("restore_fact error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -1193,7 +1206,7 @@ async fn get_recent<D: KnowledgeDao + 'static>(
}
Err(e) => {
log::error!("get_recent error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -1217,7 +1230,7 @@ async fn get_predicate_stats<D: KnowledgeDao + 'static>(
}),
Err(e) => {
log::error!("get_predicate_stats error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -1231,8 +1244,7 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
) -> impl Responder {
let predicate = predicate.into_inner();
if predicate.trim().is_empty() {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "predicate must not be empty"}));
return ApiError::bad_request("invalid_fact", "A fact needs a predicate").into_response();
}
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
@@ -1240,7 +1252,7 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
Ok(rejected) => HttpResponse::Ok().json(BulkRejectResponse { rejected }),
Err(e) => {
log::error!("bulk_reject_predicate error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -1278,7 +1290,7 @@ async fn get_graph<D: KnowledgeDao + 'static>(
}),
Err(e) => {
log::error!("build_entity_graph error: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
ApiError::internal("Database error").into_response()
}
}
}
@@ -1302,8 +1314,7 @@ async fn get_consolidation_proposals<D: KnowledgeDao + 'static>(
Ok(g) => g,
Err(e) => {
log::error!("find_consolidation_proposals: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
return ApiError::internal("Database error").into_response();
}
};