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:
1 parent
1836f1b9ca
commit
431c3a7492
23 files changed
+894
-542
No files matched your search
+77
-89
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user