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

+77 -89
View File
@@ -1,3 +1,4 @@
use crate::error::ApiError;
use actix_web::{HttpResponse, Responder, delete, get, post, web};
use futures::StreamExt;
use opentelemetry::trace::Status;
@@ -110,15 +111,12 @@ pub async fn generation_status_handler(
});
}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Job {} not found", jid)
}));
return ApiError::not_found("job_not_found", "That insight job no longer exists")
.into_response();
}
Err(e) => {
log::error!("Failed to look up job {}: {:?}", jid, e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to look up job"
}));
return ApiError::internal("Failed to look up job").into_response();
}
}
}
@@ -153,16 +151,12 @@ pub async fn generation_status_handler(
}
Err(e) => {
log::error!("Failed to look up active job for {}: {:?}", normalized, e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to look up active job"
}));
return ApiError::internal("Failed to look up active job").into_response();
}
}
}
HttpResponse::BadRequest().json(serde_json::json!({
"error": "Provide either job_id or path query parameter"
}))
ApiError::bad_request("invalid_request", "Give a job_id or a path").into_response()
}
#[derive(Debug, Deserialize)]
@@ -212,9 +206,7 @@ pub async fn cancel_generation_handler(
}
Err(e) => {
log::error!("Failed to cancel job {}: {:?}", jid, e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to cancel job"
}));
return ApiError::internal("Failed to cancel job").into_response();
}
}
}
@@ -267,9 +259,7 @@ pub async fn cancel_generation_handler(
}));
}
HttpResponse::BadRequest().json(serde_json::json!({
"error": "Provide either job_id or file_path in the request body"
}))
ApiError::bad_request("invalid_request", "Give a job_id or a file_path").into_response()
}
#[derive(Debug, Deserialize)]
@@ -491,9 +481,7 @@ pub async fn generate_insight_handler(
Err(e) => {
log::error!("Failed to create generation job: {:?}", e);
span.set_status(Status::error("Failed to create generation job"));
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create generation job"
}));
return ApiError::internal("Failed to create generation job").into_response();
}
}
};
@@ -640,14 +628,10 @@ pub async fn get_insight_handler(
match dao.get_insight_for_paths(&sibling_paths) {
Ok(Some(insight)) => HttpResponse::Ok().json(PhotoInsightResponse::from(insight)),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
"error": "Insight not found"
})),
Ok(None) => insight_not_found().into_response(),
Err(e) => {
log::error!("Failed to fetch insight ({}): {:?}", &query.path, e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to fetch insight: {:?}", e)
}))
ApiError::internal(format!("Failed to fetch insight: {:?}", e)).into_response()
}
}
}
@@ -671,9 +655,7 @@ pub async fn delete_insight_handler(
})),
Err(e) => {
log::error!("Failed to delete insight: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to delete insight: {:?}", e)
}))
ApiError::internal(format!("Failed to delete insight: {:?}", e)).into_response()
}
}
}
@@ -699,9 +681,7 @@ pub async fn get_all_insights_handler(
}
Err(e) => {
log::error!("Failed to fetch all insights: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to fetch insights: {:?}", e)
}))
ApiError::internal(format!("Failed to fetch insights: {:?}", e)).into_response()
}
}
}
@@ -731,9 +711,7 @@ pub async fn get_insight_history_handler(
}
Err(e) => {
log::error!("Failed to fetch insight history ({}): {:?}", &query.path, e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to fetch insight history: {:?}", e)
}))
ApiError::internal(format!("Failed to fetch insight history: {:?}", e)).into_response()
}
}
}
@@ -822,9 +800,7 @@ pub async fn generate_agentic_insight_handler(
Err(e) => {
log::error!("Failed to create agentic generation job: {:?}", e);
span.set_status(Status::error("Failed to create generation job"));
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create generation job"
}));
return ApiError::internal("Failed to create generation job").into_response();
}
}
};
@@ -1159,9 +1135,7 @@ pub async fn rate_insight_handler(
})),
Err(e) => {
log::error!("Failed to rate insight: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to rate insight: {:?}", e)
}))
ApiError::internal(format!("Failed to rate insight: {:?}", e)).into_response()
}
}
}
@@ -1213,9 +1187,7 @@ pub async fn export_training_data_handler(
}
Err(e) => {
log::error!("Failed to export training data: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to export training data: {:?}", e)
}))
ApiError::internal(format!("Failed to export training data: {:?}", e)).into_response()
}
}
}
@@ -1308,9 +1280,7 @@ pub async fn chat_turn_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1361,17 +1331,17 @@ pub async fn chat_turn_handler(
// Map well-known errors to client-facing 4xx codes.
if msg.contains("no insight found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
insight_not_found().into_response()
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
no_chat_history().into_response()
} else if msg.contains("user_message")
|| msg.contains("unknown backend")
|| msg.contains("switching from local to hybrid")
|| msg.contains("hybrid backend unavailable")
{
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
ApiError::bad_request("invalid_chat_request", msg).into_response()
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg).into_response()
}
}
}
@@ -1456,9 +1426,7 @@ pub async fn chat_rewind_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1476,13 +1444,18 @@ pub async fn chat_rewind_handler(
let msg = format!("{}", e);
log::error!("Chat rewind failed: {}", msg);
if msg.contains("no insight found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
insight_not_found().into_response()
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
no_chat_history().into_response()
} else if msg.contains("cannot discard the initial") || msg.contains("out of range") {
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
ApiError::bad_request(
"stale_branch",
"This conversation changed. Reload it and try again.",
)
.with_details(serde_json::json!({ "reason": msg }))
.into_response()
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg).into_response()
}
}
}
@@ -1553,11 +1526,11 @@ pub async fn chat_history_handler(
Err(e) => {
let msg = format!("{}", e);
if msg.contains("no insight found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
insight_not_found().into_response()
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
no_chat_history().into_response()
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg).into_response()
}
}
}
@@ -1606,14 +1579,18 @@ pub async fn chat_branches_handler(
Err(e) => {
let msg = format!("{}", e);
if msg.contains("no insight found") {
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
return insight_not_found().into_response();
} else if msg.contains("no chat history") {
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
return no_chat_history().into_response();
} else if msg.contains("not found in tree") {
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
return ApiError::bad_request(
"stale_branch",
"This conversation changed. Reload it and try again.",
)
.with_details(serde_json::json!({ "reason": msg }))
.into_response();
} else {
return HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": msg }));
return ApiError::internal(msg).into_response();
}
}
};
@@ -1638,9 +1615,7 @@ pub async fn chat_switch_branch_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1654,13 +1629,18 @@ pub async fn chat_switch_branch_handler(
let msg = format!("{}", e);
log::error!("Switch branch failed: {}", msg);
if msg.contains("no insight found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
insight_not_found().into_response()
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
no_chat_history().into_response()
} else if msg.contains("not found") || msg.contains("not a leaf") {
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
ApiError::bad_request(
"stale_branch",
"This conversation changed. Reload it and try again.",
)
.with_details(serde_json::json!({ "reason": msg }))
.into_response()
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg).into_response()
}
}
}
@@ -1698,9 +1678,7 @@ pub async fn chat_stream_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1853,9 +1831,7 @@ pub async fn turn_async_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1936,9 +1912,7 @@ pub(crate) async fn turn_replay_impl(
Some(e) => e,
None => {
span.set_status(Status::error("turn not found"));
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("turn {} not found", turn_id)
}));
return turn_not_found().into_response();
}
};
@@ -1953,9 +1927,11 @@ pub(crate) async fn turn_replay_impl(
let (initial_frames, start_skip) = match entry.replay_from(skip_before).await {
ReplayOutcome::Gone => {
span.set_status(Status::error("buffer evicted"));
return HttpResponse::Gone().json(serde_json::json!({
"error": "turn history has expired (buffer evicted)"
}));
return ApiError::gone(
"turn_expired",
"This reply is too old to resume. Reload the chat.",
)
.into_response();
}
ReplayOutcome::CaughtUp { next_skip } => (Vec::new(), next_skip),
ReplayOutcome::Events { events, next_skip } => {
@@ -2100,9 +2076,7 @@ pub(crate) async fn cancel_turn_impl(
Some(e) => e,
None => {
span.set_status(Status::error("turn not found"));
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("turn {} not found", turn_id)
}));
return turn_not_found().into_response();
}
};
@@ -2441,3 +2415,17 @@ mod owner_defaults_tests {
assert_eq!(persona.as_deref(), Some("journal"));
}
}
// ---- error responses (#202) ---------------------------------------------
fn insight_not_found() -> ApiError {
ApiError::not_found("insight_not_found", "There's no insight for this photo yet")
}
fn no_chat_history() -> ApiError {
ApiError::conflict("no_chat_history", "There's no chat for this insight yet")
}
fn turn_not_found() -> ApiError {
ApiError::not_found("turn_not_found", "That reply is no longer running")
}